Compare commits

..
8 Commits
Author SHA1 Message Date
thatguygriff 78083fc96c Merge pull request 'Let a studio choose its default payment method, and disconnect Stripe again' (#174) from feature/173-default-payment-method into main
CI / PHPStan (push) Successful in 8m6s
CI / Tests (PHP 8.1) (push) Successful in 1m6s
CI / Tests (PHP 8.2) (push) Successful in 54s
CI / Tests (PHP 8.3) (push) Successful in 6m48s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 5m55s
CI / Build Plugin Zip (push) Successful in 2m51s
Release / Build and Publish Release (push) Successful in 16m35s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #174
2026-08-20 15:53:02 +00:00
thatguygriffandClaude Opus 5 c077a653fb 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
2026-08-20 12:43:29 -03:00
thatguygriff f9e222be29 Merge pull request 'Bump version to 1.5.2' (#172) from release/bump-1.5.2 into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 46s
CI / Tests (PHP 8.2) (push) Successful in 1m4s
CI / Tests (PHP 8.3) (push) Successful in 2m43s
CI / Coding Standards (push) Successful in 3m40s
CI / PHPStan (push) Successful in 3m43s
CI / Build Plugin Zip (push) Successful in 2m51s
Reviewed-on: #172
2026-08-11 22:06:41 +00:00
Release Bot b950e35e5a Bump version to 1.5.2 and open changelog section 2026-08-11 22:06:18 +00:00
thatguygriff b220de48c5 Merge pull request 'Let a guardian enrol every child in the same group class' (#171) from fix/group-multi-student-enrolment into main
CI / No Debug Code (push) Successful in 4s
CI / Tests (PHP 8.1) (push) Successful in 1m4s
CI / Tests (PHP 8.2) (push) Successful in 1m10s
CI / Coding Standards (push) Successful in 3m35s
CI / PHPStan (push) Successful in 3m52s
CI / Tests (PHP 8.3) (push) Successful in 21m42s
CI / Build Plugin Zip (push) Successful in 14m57s
Release / Build and Publish Release (push) Successful in 3m11s
Release / Open next-version bump PR (push) Successful in 5s
Reviewed-on: #171
2026-08-11 20:44:29 +00:00
thatguygriffandClaude Opus 5 8017dbb9ff Let a guardian enrol every child in the same group class
CI / PHPStan (pull_request) Successful in 2m55s
CI / No Debug Code (pull_request) Successful in 26s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m5s
CI / Coding Standards (pull_request) Successful in 3m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 6m9s
CI / Build Plugin Zip (pull_request) Skipped
The group-class page matched enrolments to the account instead of to the
student: the lookup it built from GET /enrollments was keyed by offering id
alone, so the first household enrolment marked the class as "yours" and took
the Enrol button away from everyone else on the account. A parent could enrol
one child and was then offered nothing but Withdraw.

The server was never the constraint — hasActiveEnrollment() checks the
(offering, student) pair and GET /enrollments deliberately returns the whole
household — so the fix is to stop discarding student_id on the way in. Active
enrolments are now grouped per class as a list, each student gets their own
"… is enrolled in this class." line and their own named Withdraw button, and
the Enrol button stays (as "Enrol another student") while anyone the account
may enrol is still out.

The enrolment form offers only the students not yet enrolled. When exactly one
of them is left the picker collapses, and that case needed care: an omitted
student_id reads as "enrol the account holder" server-side, so a hidden field
carries the id rather than posting nothing and signing up the parent instead of
the last child.

Closes #170

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jy9UPhmpLUAfimsyecHN2Z
2026-08-11 17:36:01 -03:00
thatguygriff c73b10d779 Merge pull request 'Bump version to 1.5.1' (#169) from release/bump-1.5.1 into main
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 55s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m52s
CI / Coding Standards (push) Successful in 2m57s
CI / Tests (PHP 8.3) (push) Successful in 2m47s
CI / Build Plugin Zip (push) Successful in 2m51s
Reviewed-on: #169
2026-07-30 19:48:12 +00:00
Release Bot 7ea8d653ee Bump version to 1.5.1 and open changelog section 2026-07-30 19:46:05 +00:00
10 changed files with 382 additions and 66 deletions
+11
View File
@@ -11,6 +11,17 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work. each change under the current top section as you work.
## [1.5.2]
### Added
- **You can now choose which payment method the studio bills by, instead of it following your Stripe keys.** Saving Stripe keys used to move every student onto credit-card billing the moment they were entered — there was no way to have Stripe live and still bill by e-transfer while you satisfied yourself that card payments worked. **Studio Settings → Billing → Default payment method** now makes that an explicit choice between **Credit card** and **E-transfer**. Leaving it on E-transfer with Stripe configured lets you switch one student at a time to Credit card on their student detail page and watch their bookings charge for real; when you are satisfied, changing this one setting moves everyone over. Credit card remains the default, so a studio that adds keys and changes nothing else behaves exactly as before, and it still falls back to e-transfer until keys are saved — a card cannot be charged without them.
- **Stripe can now be disconnected from Studio Settings.** Keys could be replaced but never removed, so a studio that set Stripe up to try it had no way back to e-transfer short of editing the database. **Clear Stripe configuration**, at the foot of the settings page whenever any Stripe value is stored, forgets the publishable key, the secret key and the webhook signing secret, and returns the mode to Test — billing falls back to e-transfer until keys are entered again. Payments already recorded are untouched, as are your currency, HST, e-transfer and registration settings. If you are disconnecting for good, delete the webhook endpoint in the Stripe Dashboard too, or it will keep sending events this site can no longer verify.
## [1.5.1]
### Fixed
- **A parent can now enrol more than one child in the same group class.** Enrolling the first student worked, and then the class card switched to "You are enrolled in this class." with a **Withdraw** button — for the whole account. There was no way to sign up a second child short of withdrawing the first, even though nothing was ever actually full or forbidden: the class page was matching enrolments to the account rather than to the student, so one child's seat spoke for everybody. Each enrolled student now gets their own line on the card, named — "Ada is enrolled in this class." — with their own Withdraw button, and the Enrol button stays put, reading **Enrol another student**, until everyone on the account is in. The form's "Who is this for?" list offers only the students not yet enrolled, so the class cannot be double-booked for the same child by accident. Enrolments already recorded are unaffected; the seats were always separate on the studio's side, and this is the page catching up with that.
## [1.5.0] ## [1.5.0]
### Added ### Added
+115 -39
View File
@@ -137,7 +137,78 @@
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline; return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
} }
function renderClasses(offerings, enrolledMap) { // Active enrolments grouped by class. A household can hold several in the
// same class — one per student — so the value is a list, never a single id.
function activeByOffering(enrollments) {
const map = new Map();
enrollments
.filter((e) => e.status === 'active')
.forEach((e) => {
const key = Number(e.offering_id);
const held = map.get(key) || [];
held.push({ id: e.id, studentId: Number(e.student_id) });
map.set(key, held);
});
return map;
}
// Who on this account could still be enrolled in a class: everyone the
// account may enrol, minus those already holding an active enrolment in it.
// The per-student check is the point — the account used to be treated as a
// single enrollee, so enrolling one child hid the Enrol button from the rest
// of the household even though the server would have taken them happily.
function availableStudents(offeringId, enrolled) {
const held = enrolled.get(Number(offeringId)) || [];
// Degraded case: an unparseable student list leaves no id to compare
// against, so any existing enrolment is read as covering the account.
if (!students.length) return held.length ? [] : [{ id: 0, name: '', is_self: true }];
const taken = new Set(held.map((e) => e.studentId));
return students.filter((s) => !taken.has(Number(s.id)));
}
// The enrolled student's name, or '' when there is nobody to tell them apart
// from: an account with a single student reads better in the second person.
function studentName(studentId) {
if (students.length < 2) return '';
const s = students.find((st) => Number(st.id) === Number(studentId));
return s && !s.is_self ? s.name : '';
}
function enrolledRow(o, e) {
const name = studentName(e.studentId);
return `
<p class="us-enrolled"><strong>${name ? `${escHtml(name)} is` : 'You are'} enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${e.id}" data-student="${escHtml(name)}" class="us-withdraw-btn">Withdraw${name ? ` ${escHtml(name)}` : ''}</button>`
: `<p class="us-withdraw-closed">Withdrawal${name ? ` for ${escHtml(name)}` : ''} has closed — contact the studio to withdraw.</p>`}`;
}
function classCard(o, enrolled) {
const held = enrolled.get(Number(o.id)) || [];
const available = availableStudents(o.id, enrolled);
const canEnrol = available.length > 0 && isEnrollmentOpen(o);
return `
<div class="us-class">
<h3>${escHtml(o.title)}</h3>
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
${canEnrol && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${held.map((e) => enrolledRow(o, e)).join('')}
${canEnrol
? `<button data-offering-id="${o.id}" class="us-enrol-btn">${held.length ? 'Enrol another student' : 'Enrol'}</button>`
: (available.length ? '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>' : '')}
</div>`;
}
function renderClasses(offerings, enrolled) {
let groups = offerings.filter((o) => o.kind === 'group_class'); let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) { if (singleOfferingId) {
groups = groups.filter((o) => Number(o.id) === singleOfferingId); groups = groups.filter((o) => Number(o.id) === singleOfferingId);
@@ -149,44 +220,29 @@
return; return;
} }
list.innerHTML = groups.map((o) => ` list.innerHTML = groups.map((o) => classCard(o, enrolled)).join('');
<div class="us-class">
<h3>${escHtml(o.title)}</h3>
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${enrolledMap.has(Number(o.id))
? `<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${enrolledMap.get(Number(o.id))}" class="us-withdraw-btn">Withdraw</button>`
: '<p class="us-withdraw-closed">Withdrawal has closed — contact the studio to withdraw.</p>'}`
: (isEnrollmentOpen(o)
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
</div>
`).join('');
list.querySelectorAll('.us-enrol-btn').forEach((btn) => { list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId); const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
hideConfirmation(); hideConfirmation();
openEnrolment(offering); openEnrolment(offering, availableStudents(offering.id, enrolled));
}); });
}); });
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => { list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId)); btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId, btn.dataset.student || ''));
}); });
} }
function withdraw(enrollmentId) { function withdraw(enrollmentId, studentName) {
clearError(); clearError();
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) { // Named, because a household can hold more than one enrolment in the
// same class and "this class" alone would not say whose seat is going.
const prompt = studentName
? `Withdraw ${studentName} from this class? Their seat is released and any pending payment is cancelled.`
: 'Withdraw from this class? Your seat is released and any pending payment is cancelled.';
if (!window.confirm(prompt)) {
return; return;
} }
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' }) apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
@@ -194,22 +250,45 @@
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
function openEnrolment(offering) { function openEnrolment(offering, available) {
clearError(); clearError();
Promise.all([ Promise.all([
apiFetch(`offerings/${offering.id}/questions`), apiFetch(`offerings/${offering.id}/questions`),
apiFetch('policies?scope=booking'), apiFetch('policies?scope=booking'),
]) ])
.then(([questions, policies]) => renderEnrolment(offering, questions, policies)) .then(([questions, policies]) => renderEnrolment(offering, questions, policies, available))
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
function renderEnrolment(offering, questions, policies) { /**
* The "who is this for?" control for one class, offering only the students
* who are not already enrolled in it.
*
* When exactly one is left there is nothing to choose, but the id still has
* to reach the server: an omitted picker posts no student_id, which the
* server reads as "enrol the account holder" — and would enrol the parent
* instead of the one child still to be signed up.
*/
function studentFieldHtml(available) {
if (available.length > 1) {
return window.usGuardian.selectorHtml(available, 'us-enrol-student');
}
const only = available[0];
if (!only) return '';
return `<input type="hidden" id="us-enrol-student" value="${Number(only.id)}">
${students.length > 1
? `<p class="us-student-picker">For ${only.is_self ? 'yourself' : escHtml(only.name)}.</p>`
: ''}`;
}
function renderEnrolment(offering, questions, policies, available) {
list.innerHTML = ` list.innerHTML = `
<div class="us-register"> <div class="us-register">
<h3>${escHtml(offering.title)}</h3> <h3>${escHtml(offering.title)}</h3>
<form id="us-enrol-form"> <form id="us-enrol-form">
${window.usGuardian.selectorHtml(students, 'us-enrol-student')} ${studentFieldHtml(available)}
${questions.map(questionField).join('')} ${questions.map(questionField).join('')}
${policies.map(policyField).join('')} ${policies.map(policyField).join('')}
${window.usPricing.summaryHtml(offering)} ${window.usPricing.summaryHtml(offering)}
@@ -306,20 +385,17 @@
function loadClasses() { function loadClasses() {
clearError(); clearError();
hideConfirmation(); hideConfirmation();
// The student's own enrolments are fetched alongside the catalog so a // The household's enrolments are fetched alongside the catalog so a
// class they already have an active enrolment in shows its status // class a student already has an active enrolment in shows their status
// instead of offering to enrol them again (the API would reject the // instead of offering to enrol them again (the API would reject the
// duplicate anyway). A cancelled enrolment does not block re-enrolling. // duplicate anyway). Each student is tracked separately: one child being
// enrolled says nothing about their siblings, who can still be signed up
// for the same class. A cancelled enrolment does not block re-enrolling.
return Promise.all([ return Promise.all([
apiFetch('offerings?kind=group_class'), apiFetch('offerings?kind=group_class'),
apiFetch('enrollments'), apiFetch('enrollments'),
]) ])
.then(([offerings, enrollments]) => renderClasses( .then(([offerings, enrollments]) => renderClasses(offerings, activeByOffering(enrollments)))
offerings,
new Map(enrollments
.filter((e) => e.status === 'active')
.map((e) => [Number(e.offering_id), e.id]))
))
.catch((err) => showError(err.message)); .catch((err) => showError(err.message));
} }
+16 -6
View File
@@ -81,11 +81,20 @@ student detail page. Only *upcoming* sessions are added there — the
term's worth of past dates would bury the lessons under "Past lessons". term's worth of past dates would bury the lessons under "Past lessons".
## Enrolment Flow ## Enrolment Flow
The class list is loaded together with the student's own enrolments The class list is loaded together with the household's enrolments
(`GET /enrollments`); a class the student already has an `active` enrolment in (`GET /enrollments`), and the two are matched up **per student**, not per account.
shows "You are enrolled in this class." instead of the Enrol button (the Each active enrolment in a class adds its own line to the card — "Ada is enrolled
server would reject the duplicate with `409 already_enrolled` regardless — a in this class." — with its own **Withdraw** button, and the Enrol button stays
cancelled enrolment does not block re-enrolling). (reading "Enrol another student") for as long as anyone the account may enrol is
still out of the class. The enrolment form then offers only those students; when
exactly one is left the picker collapses to a hidden field carrying that student's
id, because an omitted `student_id` reads as "enrol the account holder" and would
sign up the parent instead of the last child. Only when the whole household is
enrolled does the Enrol button disappear.
The per-student matching mirrors the server, which rejects a duplicate with
`409 already_enrolled` for that `(offering, student)` pair alone — a sibling is
never a duplicate, and a cancelled enrolment does not block re-enrolling.
1. Student opens a group class from the offering catalog. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on. 1. Student opens a group class from the offering catalog. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on.
2. Student answers the offering's questions (`GET /offerings/{id}/questions`). 2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
@@ -114,7 +123,8 @@ closed. Past the deadline the details page labels these as late enrolments. See
## Withdrawal Flow ## Withdrawal Flow
A student may withdraw themselves from a class they are enrolled in through the same A student may withdraw themselves from a class they are enrolled in through the same
group-class page: an active enrolment shows a **Withdraw** button. group-class page: an active enrolment shows a **Withdraw** button. A guardian sees one
per enrolled child, labelled with the child's name, so the right seat is the one released.
`POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its `POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its
capacity seat) and voids any still-pending payment. It **never issues an account capacity seat) and voids any still-pending payment. It **never issues an account
credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved
+28 -2
View File
@@ -34,6 +34,15 @@ page (`manage_billing`, studio admin only):
| `us_currency` | Default ISO 4217 currency, e.g. `CAD` | | `us_currency` | Default ISO 4217 currency, e.g. `CAD` |
| `us_etransfer_email` | Studio-default e-transfer destination | | `us_etransfer_email` | Studio-default e-transfer destination |
| `us_hst_rate` | Default HST/tax percentage, e.g. `13` | | `us_hst_rate` | Default HST/tax percentage, e.g. `13` |
| `us_default_payment_method` | Studio-default billing method (`card` \| `etransfer`) |
Secrets are write-only in the form: a stored secret is never echoed back, and a
blank field keeps it. To disconnect Stripe entirely, **Clear Stripe
configuration** (shown once any Stripe value is stored) deletes the publishable
key, secret key and webhook secret and drops the mode back to `test`
(`StudioSettings::clearStripeConfig()`). Currency, HST, e-transfer and
registration settings are untouched, as are payments already recorded; billing
falls back to e-transfer until keys are entered again.
## HST / Tax ## HST / Tax
@@ -52,8 +61,9 @@ total when tax applies.
## Per-Student Billing Method ## Per-Student Billing Method
Each student's billing method is stored in user meta `us_payment_method`, set by the Each student's billing method is stored in user meta `us_payment_method`, set by the
studio admin (`Students → student detail → Billing method`). When unset, the studio studio admin (`Students → student detail → Billing method`). When unset, the studio
default applies `card` if Stripe is configured, otherwise `etransfer` default applies (`BillingMethodResolver::defaultMethod()`): the
(`BillingMethodResolver`): `us_default_payment_method` option, degraded to `etransfer` whenever Stripe is not
configured, since a card cannot be charged without keys.
| Method | Behaviour | | Method | Behaviour |
|------------|-----------------------------------------------------------------------| |------------|-----------------------------------------------------------------------|
@@ -61,6 +71,22 @@ default applies — `card` if Stripe is configured, otherwise `etransfer`
| `etransfer`| Payment row created `pending`; admin marks it `paid` when funds arrive | | `etransfer`| Payment row created `pending`; admin marks it `paid` when funds arrive |
| `comp` | No charge; registration is confirmed immediately, no payment row required | | `comp` | No charge; registration is confirmed immediately, no payment row required |
## Studio Default Billing Method
**Studio Settings → Billing → Default payment method** (`manage_billing`) chooses
between `card` and `etransfer` for every student without an override. Card is the
default, so a studio that adds Stripe keys and changes nothing else behaves as it
always has.
Setting it to `etransfer` is the **staged rollout** path: Stripe stays live, but
the studio keeps billing by e-transfer while individual students are switched to
`card` on their student detail page. Their bookings exercise real Stripe charges
end to end; once that is proven, flipping the studio default to `card` moves
everyone at once and the per-student overrides can be cleared.
`comp` is deliberately not offered as a studio default — it is a per-student
decision, and a studio-wide `comp` would silently stop billing everybody. A stored
value that is neither `card` nor `etransfer` reads back as `card`.
## E-transfer Destination Email ## E-transfer Destination Email
Where students send e-transfers is resolved and **frozen onto the payment** at Where students send e-transfers is resolved and **frozen onto the payment** at
booking time (`us_payments.etransfer_email`), so each record keeps the destination booking time (`us_payments.etransfer_email`), so each record keeps the destination
+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, * 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 * otherwise the studio default chosen on Studio Settings which itself falls
* it is not. * back to e-transfer whenever Stripe is not configured.
*/ */
class BillingMethodResolver { class BillingMethodResolver {
@@ -27,10 +27,17 @@ class BillingMethodResolver {
/** /**
* The studio default when a student has no explicit override. * 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 { public function defaultMethod(): string {
return $this->settings->isStripeConfigured() return Payment::METHOD_CARD === $this->settings->defaultPaymentMethod()
? Payment::METHOD_CARD && $this->settings->isStripeConfigured()
: Payment::METHOD_ETRANSFER; ? 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_ETRANSFER_EMAIL = 'us_etransfer_email';
public const OPT_HST_RATE = 'us_hst_rate'; 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 * Studio-default cancellation cutoff, stored in hours. A student may not
* cancel a lesson once it starts within this many hours. Displayed to the * 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'; 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 { public function currency(): string {
$currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) ); $currency = Val::string( get_option( self::OPT_CURRENCY, 'CAD' ) );
@@ -112,13 +131,32 @@ class StudioSettings {
return self::MODE_SELF_APPROVAL === $this->registrationMode(); 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 { public function renderPage(): void {
if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) { if ( ! current_user_can( RoleManager::CAP_MANAGE_BILLING ) ) {
wp_die( esc_html__( 'You do not have permission to manage billing settings.', 'unsupervised-schedular' ) ); 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' ) ) { 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(); $publishableKey = $this->publishableKey();
@@ -133,6 +171,10 @@ class StudioSettings {
$etransferEmail = $this->etransferEmail(); $etransferEmail = $this->etransferEmail();
$hstRate = $this->hstRate(); $hstRate = $this->hstRate();
$stripeConfigured = $this->isStripeConfigured(); $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(); $openRegistration = $this->openRegistrationEnabled();
// Stored in hours, surfaced to the admin in whole days. // Stored in hours, surfaced to the admin in whole days.
$cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 ); $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_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_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'] ?? '' ) ) ) ); 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. // 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; $hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) ); update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
+49 -1
View File
@@ -15,6 +15,9 @@ if (! defined('ABSPATH')) {
* @var string $etransferEmail * @var string $etransferEmail
* @var float $hstRate * @var float $hstRate
* @var bool $stripeConfigured * @var bool $stripeConfigured
* @var string $defaultMethod
* @var bool $stripeAnySet
* @var string $notice
* @var bool $openRegistration * @var bool $openRegistration
* @var int $cancellationCutoffDays * @var int $cancellationCutoffDays
*/ */
@@ -22,10 +25,18 @@ if (! defined('ABSPATH')) {
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e('Studio Settings', 'unsupervised-schedular'); ?></h1> <h1><?php esc_html_e('Studio Settings', 'unsupervised-schedular'); ?></h1>
<?php if ('' !== $notice) : ?>
<div class="notice notice-success inline">
<p><?php echo esc_html($notice); ?></p>
</div>
<?php endif; ?>
<div class="notice notice-info inline"> <div class="notice notice-info inline">
<p> <p>
<?php if ($stripeConfigured) : ?> <?php if ($stripeConfigured && 'card' === $defaultMethod) : ?>
<?php esc_html_e('Stripe is configured — new registrations default to credit-card billing.', 'unsupervised-schedular'); ?> <?php esc_html_e('Stripe is configured — new registrations default to credit-card billing.', 'unsupervised-schedular'); ?>
<?php elseif ($stripeConfigured) : ?>
<?php esc_html_e('Stripe is configured, but the studio default is e-transfer — only students you switch to credit card individually are billed by card.', 'unsupervised-schedular'); ?>
<?php else : ?> <?php else : ?>
<?php esc_html_e('Stripe is not configured — new registrations default to e-transfer, which a studio admin marks paid on receipt. Add your Stripe keys below to enable card billing.', 'unsupervised-schedular'); ?> <?php esc_html_e('Stripe is not configured — new registrations default to e-transfer, which a studio admin marks paid on receipt. Add your Stripe keys below to enable card billing.', 'unsupervised-schedular'); ?>
<?php endif; ?> <?php endif; ?>
@@ -80,6 +91,31 @@ if (! defined('ABSPATH')) {
</tr> </tr>
</table> </table>
<h2><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Default payment method', 'unsupervised-schedular'); ?></th>
<td>
<fieldset>
<label>
<input type="radio" name="default_payment_method" value="card" <?php checked($defaultMethod, 'card'); ?>>
<?php esc_html_e('Credit card (requires Stripe)', 'unsupervised-schedular'); ?>
</label><br>
<label>
<input type="radio" name="default_payment_method" value="etransfer" <?php checked($defaultMethod, 'etransfer'); ?>>
<?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?>
</label>
<p class="description">
<?php esc_html_e('Applies to every student without their own billing method. Keep this on e-transfer while you trial card payments: switch individual students to Credit card under Students → student detail → Billing method, confirm their bookings charge correctly, then move the whole studio over by changing this setting.', 'unsupervised-schedular'); ?>
</p>
<?php if (! $stripeConfigured) : ?>
<p class="description"><?php esc_html_e('Credit card has no effect until Stripe keys are saved above — until then every student is billed by e-transfer.', 'unsupervised-schedular'); ?></p>
<?php endif; ?>
</fieldset>
</td>
</tr>
</table>
<h2><?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?></h2> <h2><?php esc_html_e('E-transfer', 'unsupervised-schedular'); ?></h2>
<table class="form-table"> <table class="form-table">
<tr> <tr>
@@ -124,4 +160,16 @@ if (! defined('ABSPATH')) {
</table> </table>
<?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?> <?php submit_button(esc_html__('Save Settings', 'unsupervised-schedular')); ?>
</form> </form>
<?php if ($stripeAnySet) : ?>
<h2><?php esc_html_e('Clear Stripe configuration', 'unsupervised-schedular'); ?></h2>
<p class="description">
<?php esc_html_e('Forgets the publishable key, secret key and webhook signing secret, and returns the mode to Test. Billing falls back to e-transfer until Stripe is set up again; payments already recorded are untouched.', 'unsupervised-schedular'); ?>
</p>
<form method="post" onsubmit="return confirm('<?php echo esc_js(esc_html__('Clear the stored Stripe keys? Card billing stops until you enter them again.', 'unsupervised-schedular')); ?>');">
<?php wp_nonce_field('usc_settings_action'); ?>
<input type="hidden" name="usc_action" value="clear_stripe">
<?php submit_button(esc_html__('Clear Stripe configuration', 'unsupervised-schedular'), 'delete', 'submit', true); ?>
</form>
<?php endif; ?>
</div> </div>
@@ -22,13 +22,13 @@ class BillingMethodResolverTest extends TestCase
self::assertSame(Payment::METHOD_COMP, $resolver->resolve(5)); self::assertSame(Payment::METHOD_COMP, $resolver->resolve(5));
} }
public function testDefaultsToCardWhenStripeConfigured(): void public function testDefaultsToCardWhenStripeConfiguredAndCardChosen(): void
{ {
Functions\when('get_user_meta')->justReturn(''); Functions\when('get_user_meta')->justReturn('');
$settings = Mockery::mock(StudioSettings::class); $resolver = new BillingMethodResolver(
$settings->shouldReceive('isStripeConfigured')->andReturn(true); $this->settings(Payment::METHOD_CARD, true)
$resolver = new BillingMethodResolver($settings); );
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5)); self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
} }
@@ -37,22 +37,56 @@ class BillingMethodResolverTest extends TestCase
{ {
Functions\when('get_user_meta')->justReturn(''); Functions\when('get_user_meta')->justReturn('');
$settings = Mockery::mock(StudioSettings::class); $resolver = new BillingMethodResolver(
$settings->shouldReceive('isStripeConfigured')->andReturn(false); $this->settings(Payment::METHOD_CARD, false)
$resolver = new BillingMethodResolver($settings); );
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->resolve(5)); self::assertSame(Payment::METHOD_ETRANSFER, $resolver->resolve(5));
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->defaultMethod()); self::assertSame(Payment::METHOD_ETRANSFER, $resolver->defaultMethod());
} }
public function testEtransferDefaultHoldsEvenWhenStripeIsLive(): void
{
Functions\when('get_user_meta')->justReturn('');
$resolver = new BillingMethodResolver(
$this->settings(Payment::METHOD_ETRANSFER, true)
);
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->resolve(5));
self::assertSame(Payment::METHOD_ETRANSFER, $resolver->defaultMethod());
}
public function testPerStudentCardOverrideStillWinsUnderAnEtransferDefault(): void
{
// The trial path: the studio bills by e-transfer, one student is moved to
// card to prove Stripe end to end.
Functions\when('get_user_meta')->justReturn(Payment::METHOD_CARD);
$resolver = new BillingMethodResolver(
$this->settings(Payment::METHOD_ETRANSFER, true)
);
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
}
public function testInvalidOverrideFallsBackToDefault(): void public function testInvalidOverrideFallsBackToDefault(): void
{ {
Functions\when('get_user_meta')->justReturn('bogus'); Functions\when('get_user_meta')->justReturn('bogus');
$settings = Mockery::mock(StudioSettings::class); $resolver = new BillingMethodResolver(
$settings->shouldReceive('isStripeConfigured')->andReturn(true); $this->settings(Payment::METHOD_CARD, true)
$resolver = new BillingMethodResolver($settings); );
self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5)); self::assertSame(Payment::METHOD_CARD, $resolver->resolve(5));
} }
private function settings(string $default, bool $stripeConfigured): StudioSettings
{
$settings = Mockery::mock(StudioSettings::class);
$settings->shouldReceive('defaultPaymentMethod')->andReturn($default);
$settings->shouldReceive('isStripeConfigured')->andReturn($stripeConfigured);
return $settings;
}
} }
+55
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase; use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -103,6 +104,60 @@ class StudioSettingsTest extends TestCase
$this->applyMode(true); $this->applyMode(true);
} }
public function testDefaultPaymentMethodIsCardWhenUnset(): void
{
Functions\when('get_option')->alias(static fn (string $name, $default) => $default);
self::assertSame(Payment::METHOD_CARD, (new StudioSettings())->defaultPaymentMethod());
}
public function testDefaultPaymentMethodReadsStoredEtransfer(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_DEFAULT_PAYMENT_METHOD ? Payment::METHOD_ETRANSFER : '');
self::assertSame(Payment::METHOD_ETRANSFER, (new StudioSettings())->defaultPaymentMethod());
}
public function testUnrecognisedStoredDefaultPaymentMethodFallsBackToCard(): void
{
// `comp` is a per-student choice only: it must never become the studio
// default and quietly stop billing anyone.
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_DEFAULT_PAYMENT_METHOD ? Payment::METHOD_COMP : '');
self::assertSame(Payment::METHOD_CARD, (new StudioSettings())->defaultPaymentMethod());
}
public function testClearStripeConfigDeletesEveryStripeOption(): void
{
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_PUBLISHABLE);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_SECRET);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_WEBHOOK_SECRET);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_MODE);
(new StudioSettings())->clearStripeConfig();
}
public function testClearStripeConfigLeavesNonStripeSettingsAlone(): void
{
$deleted = [];
Functions\when('delete_option')->alias(static function (string $name) use (&$deleted): bool {
$deleted[] = $name;
return true;
});
Functions\expect('update_option')->never();
(new StudioSettings())->clearStripeConfig();
self::assertNotContains(StudioSettings::OPT_CURRENCY, $deleted);
self::assertNotContains(StudioSettings::OPT_ETRANSFER_EMAIL, $deleted);
self::assertNotContains(StudioSettings::OPT_HST_RATE, $deleted);
self::assertNotContains(StudioSettings::OPT_REGISTRATION_MODE, $deleted);
self::assertNotContains(StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS, $deleted);
}
private function applyMode(bool $enable): void private function applyMode(bool $enable): void
{ {
$method = new \ReflectionMethod(StudioSettings::class, 'applyRegistrationMode'); $method = new \ReflectionMethod(StudioSettings::class, 'applyRegistrationMode');
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.5.0 * Version: 1.5.2
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.5.0'); define('USC_VERSION', '1.5.2');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));