Let a studio pick its default payment method and disconnect Stripe
CI / No Debug Code (pull_request) Successful in 5s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 3m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 5m31s
CI / Build Plugin Zip (pull_request) Skipped

Stripe configuration was a one-way door. Keys could be entered but never
removed, and entering them moved every student onto card billing at once,
so there was no way to have Stripe live and satisfy yourself that card
payments worked before committing the studio to them.

Two settings-page changes open both directions:

Default payment method (`us_default_payment_method`) is now an explicit
choice between card and e-transfer for students with no per-student
override, rather than something inferred from whether keys exist. Card
remains the default, so a site that adds keys and changes nothing else
behaves as before. BillingMethodResolver still degrades a card default to
e-transfer while Stripe is unconfigured — there is nothing to charge a
card with — and `comp` is deliberately not offerable studio-wide, since
it would silently stop billing everybody; an unrecognised stored value
reads back as card. Holding the default on e-transfer with Stripe live is
the staged-rollout path: move individual students to card on their detail
page, watch real charges land, then flip the studio over.

Clear Stripe configuration deletes the publishable key, secret key and
webhook signing secret and returns the mode to test, so a re-configuration
later cannot inherit live. Currency, HST, e-transfer and registration
settings are untouched, as are recorded payments. The button only appears
when some Stripe value is stored, and reuses the page's existing nonce and
`manage_billing` check.

`composer test` (915), `composer lint` and `composer cs` all pass. Options
only — no schema change, so no version bump.

Closes #173

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WyktWmwNRgMYuwe5eBuPZm
This commit is contained in:
2026-08-20 12:43:29 -03:00
co-authored by Claude Opus 5
parent f9e222be29
commit c077a653fb
7 changed files with 242 additions and 19 deletions
+12 -5
View File
@@ -7,8 +7,8 @@ use Unsupervised\Schedular\Val;
/**
* Resolves the billing method for a student: a per-student override if set,
* otherwise the studio default — card when Stripe is configured, e-transfer when
* it is not.
* otherwise the studio default chosen on Studio Settings — which itself falls
* back to e-transfer whenever Stripe is not configured.
*/
class BillingMethodResolver {
@@ -27,10 +27,17 @@ class BillingMethodResolver {
/**
* The studio default when a student has no explicit override.
*
* Card is only ever the default when the studio asked for it *and* Stripe is
* configured; without keys there is nothing to charge a card with. A studio
* that sets the default to e-transfer keeps every student on e-transfer even
* with Stripe live, so card billing can be proven on a few students — each
* given a per-student override — before the whole studio moves over.
*/
public function defaultMethod(): string {
return $this->settings->isStripeConfigured()
? Payment::METHOD_CARD
: Payment::METHOD_ETRANSFER;
return Payment::METHOD_CARD === $this->settings->defaultPaymentMethod()
&& $this->settings->isStripeConfigured()
? Payment::METHOD_CARD
: Payment::METHOD_ETRANSFER;
}
}
+50 -1
View File
@@ -16,6 +16,14 @@ class StudioSettings {
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate';
/**
* The studio-wide default billing method for students with no per-student
* override. Card is the default; setting it to e-transfer holds every student
* on e-transfer even once Stripe is live, so card billing can be trialled on a
* few students before the whole studio moves over.
*/
public const OPT_DEFAULT_PAYMENT_METHOD = 'us_default_payment_method';
/**
* Studio-default cancellation cutoff, stored in hours. A student may not
* cancel a lesson once it starts within this many hours. Displayed to the
@@ -56,6 +64,17 @@ class StudioSettings {
return 'live' === get_option( self::OPT_MODE, 'test' ) ? 'live' : 'test';
}
/**
* The studio-default billing method: `card` or `etransfer`. A card default
* still degrades to e-transfer while Stripe is unconfigured — see
* BillingMethodResolver, which owns that fallback.
*/
public function defaultPaymentMethod(): string {
return Payment::METHOD_ETRANSFER === get_option( self::OPT_DEFAULT_PAYMENT_METHOD, Payment::METHOD_CARD )
? Payment::METHOD_ETRANSFER
: Payment::METHOD_CARD;
}
public function currency(): string {
$currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) );
@@ -112,13 +131,32 @@ class StudioSettings {
return self::MODE_SELF_APPROVAL === $this->registrationMode();
}
/**
* Forget every Stripe credential, returning the studio to e-transfer billing.
* The mode drops back to `test` so a later re-configuration cannot go live by
* inheriting the old setting.
*/
public function clearStripeConfig(): void {
delete_option( self::OPT_PUBLISHABLE );
delete_option( self::OPT_SECRET );
delete_option( self::OPT_WEBHOOK_SECRET );
delete_option( self::OPT_MODE );
}
public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) );
}
$notice = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_settings_action' ) ) {
$this->save();
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified immediately above.
if ( 'clear_stripe' === sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
$this->clearStripeConfig();
$notice = __( 'Stripe configuration cleared. New registrations default to e-transfer until Stripe is set up again.', 'unsupervised-schedular' );
} else {
$this->save();
}
}
$publishableKey = $this->publishableKey();
@@ -133,6 +171,10 @@ class StudioSettings {
$etransferEmail = $this->etransferEmail();
$hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured();
$defaultMethod = $this->defaultPaymentMethod();
// Offer the clear button whenever any Stripe value lingers, not only when
// the pair of keys makes Stripe fully usable.
$stripeAnySet = '' !== $publishableKey || $secretKeySet || $webhookSecretSet;
$openRegistration = $this->openRegistrationEnabled();
// Stored in hours, surfaced to the admin in whole days.
$cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 );
@@ -158,6 +200,13 @@ class StudioSettings {
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( Val::string( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) );
// Anything but an explicit e-transfer choice means card, so a mangled or
// missing field can never silently disable card billing studio-wide.
$defaultMethod = sanitize_key( Val::string( wp_unslash( $_POST['default_payment_method'] ?? Payment::METHOD_CARD ) ) );
update_option(
self::OPT_DEFAULT_PAYMENT_METHOD,
Payment::METHOD_ETRANSFER === $defaultMethod ? Payment::METHOD_ETRANSFER : Payment::METHOD_CARD
);
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
$hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );