Quote a monthly private lesson per lesson, a group class monthly
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / Coding Standards (pull_request) Successful in 2m52s
CI / PHPStan (pull_request) Successful in 3m0s

A monthly charge covers every lesson that falls in the month, so a private
lesson's fee reads "50.00 CAD per lesson monthly" — the figure on its own would
suggest the whole month costs 50.00. A group class is enrolled in once, as a
single schedule, so its price is quoted as the monthly figure it is.

The pay agreement follows the same split: per-lesson for a monthly private
lesson, the monthly figure for a monthly group class. Weekly, full-term and
at-booking wording is unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-28 15:07:46 -03:00
co-authored by Claude Opus 5
parent 9344ab7193
commit a276d53c1b
4 changed files with 57 additions and 15 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ each change under the current top section as you work.
## [1.2.3] ## [1.2.3]
### Added ### Added
- Every price a student sees now says **when** it is due. Lesson types in the booking form read `50.00 CAD at booking`, and group-class cards read `120.00 CAD up front`, `40.00 CAD weekly` or `40.00 CAD monthly` — the offering's billing mode, in the student's words. A free offering still just reads **Free**. - Every price a student sees now says **when** it is due. Lesson types in the booking form read `50.00 CAD at booking`, and group-class cards read `120.00 CAD up front`, `40.00 CAD weekly` or `40.00 CAD monthly` — the offering's billing mode, in the student's words. A monthly **private lesson** is quoted per lesson (`50.00 CAD per lesson monthly`), since its monthly charge covers every lesson booked that month; a monthly group class is quoted as the monthly figure it is. A free offering still just reads **Free**.
- Booking a lesson and enrolling in a class now take a **second confirmation that the student agrees to pay**. Above the Confirm button the form restates the price with its cadence, spells out how it is collected ("Charged on the 1st of each month, for that month's lessons"), adds the studio's HST so the figure matches the total actually billed, and requires a tick on "I agree to pay 56.50 CAD at booking." before it will submit — separate from, and in addition to, the studio policies the student accepts above it. Reserving a time weekly quotes the per-lesson fee and the most it can add up to ("up to 12 lessons, 678.00 CAD in total"), since a week another student takes first is simply not booked. Free offerings have nothing to agree to and show no price block. - Booking a lesson and enrolling in a class now take a **second confirmation that the student agrees to pay**. Above the Confirm button the form restates the price with its cadence, spells out how it is collected ("Charged on the 1st of each month, for that month's lessons"), adds the studio's HST so the figure matches the total actually billed, and requires a tick on "I agree to pay 56.50 CAD at booking." before it will submit — separate from, and in addition to, the studio policies the student accepts above it. Reserving a time weekly quotes the per-lesson fee and the most it can add up to ("up to 12 lessons, 678.00 CAD in total"), since a week another student takes first is simply not booked. Free offerings have nothing to agree to and show no price block.
## [1.2.2] ## [1.2.2]
+1
View File
@@ -489,6 +489,7 @@
price: offering.price, price: offering.price,
currency: offering.currency, currency: offering.currency,
billing_mode: offering.billing_mode, billing_mode: offering.billing_mode,
kind: offering.kind,
occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1, occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1,
}) })
: ''; : '';
+33 -8
View File
@@ -36,6 +36,14 @@
return CADENCE[billingMode] ? billingMode : 'one_time'; return CADENCE[billingMode] ? billingMode : 'one_time';
} }
// A monthly charge rolls up every lesson that falls in the month, so a
// private lesson's monthly price is quoted *per lesson* — the fee is
// multiplied by the lessons booked that month. A group class is enrolled in
// once, as one schedule, so its monthly figure is quoted as it stands.
function isPerLessonMonthly(billingMode, kind) {
return 'monthly' === billingMode && 'group_class' !== kind;
}
// "50.00 CAD" — amount then currency code, the format used throughout the // "50.00 CAD" — amount then currency code, the format used throughout the
// ledger, receipts and payment notices. // ledger, receipts and payment notices.
function money(amount, currency) { function money(amount, currency) {
@@ -57,22 +65,26 @@
return (Number(amount) || 0) + tax(amount); return (Number(amount) || 0) + tax(amount);
} }
// "50.00 CAD at booking" / "Free" — the catalogue label, always carrying the // "50.00 CAD at booking" / "50.00 CAD per lesson monthly" / "Free" — the
// cadence so a price is never shown without saying when it is due. // catalogue label, always carrying the cadence so a price is never shown
// without saying when it is due.
function priceLabel(offering) { function priceLabel(offering) {
const price = Number(offering.price) || 0; const price = Number(offering.price) || 0;
if (price <= 0) { if (price <= 0) {
return 'Free'; return 'Free';
} }
return `${money(price, offering.currency)} ${CADENCE[mode(offering.billing_mode)]}`; const billingMode = mode(offering.billing_mode);
const perLesson = isPerLessonMonthly(billingMode, offering.kind) ? 'per lesson ' : '';
return `${money(price, offering.currency)} ${perLesson}${CADENCE[billingMode]}`;
} }
// The price block shown on a booking/enrolment form, followed by the // The price block shown on a booking/enrolment form, followed by the
// agreement the student must tick to confirm they will pay it. A free // agreement the student must tick to confirm they will pay it. A free
// offering has nothing to agree to, so it renders nothing at all. // offering has nothing to agree to, so it renders nothing at all.
// //
// opts: { price, currency, billing_mode, occurrences } // opts: { price, currency, billing_mode, kind, occurrences }
// `occurrences` is how many lessons a one-time price is charged for in this // `occurrences` is how many lessons a one-time price is charged for in this
// one registration (a weekly reservation claims several at once); it is // one registration (a weekly reservation claims several at once); it is
// ignored for the other modes, whose price is charged per period regardless. // ignored for the other modes, whose price is charged per period regardless.
@@ -96,7 +108,7 @@
<h4>Price</h4> <h4>Price</h4>
<p class="us-price-amount"> <p class="us-price-amount">
<strong>${escHtml(money(price, currency))}</strong> <strong>${escHtml(money(price, currency))}</strong>
<span class="us-price-cadence">${escHtml(CADENCE[billingMode])}</span> <span class="us-price-cadence">${escHtml(cadenceLabel(billingMode, opts.kind))}</span>
</p> </p>
${taxLine} ${taxLine}
<p class="us-price-note">${escHtml(count > 1 <p class="us-price-note">${escHtml(count > 1
@@ -104,18 +116,31 @@
: CADENCE_NOTE[billingMode])}</p> : CADENCE_NOTE[billingMode])}</p>
<label class="us-price-agree"> <label class="us-price-agree">
<input type="checkbox" class="us-price-accept" required> <input type="checkbox" class="us-price-accept" required>
${escHtml(agreeText(each, currency, billingMode, count))} ${escHtml(agreeText(each, currency, billingMode, count, opts.kind))}
</label> </label>
</div>`; </div>`;
} }
// The cadence as it reads beside an amount: a private lesson billed monthly
// adds "per lesson", since the month's charge is that fee times the lessons
// it covers.
function cadenceLabel(billingMode, kind) {
return isPerLessonMonthly(billingMode, kind)
? `per lesson ${CADENCE[billingMode]}`
: CADENCE[billingMode];
}
// What the student is ticking: the amount actually billed (tax included), // What the student is ticking: the amount actually billed (tax included),
// and when. A weekly reservation is charged per lesson for every week it // and when. A weekly reservation is charged per lesson for every week it
// claims, and the claim can come up short when another student takes one of // claims, and the claim can come up short when another student takes one of
// the times first — so its total is stated as a ceiling, never a promise. // the times first — so its total is stated as a ceiling, never a promise.
function agreeText(each, currency, billingMode, count) { function agreeText(each, currency, billingMode, count, kind) {
if (RECURRING.indexOf(billingMode) !== -1) { if (RECURRING.indexOf(billingMode) !== -1) {
return `I agree to pay ${money(each, currency)} per lesson, billed ${CADENCE[billingMode]}.`; // A monthly group class is enrolled in once and quoted as it stands;
// everything else recurring is a per-lesson fee.
return 'monthly' === billingMode && !isPerLessonMonthly(billingMode, kind)
? `I agree to pay ${money(each, currency)} monthly.`
: `I agree to pay ${money(each, currency)} per lesson, billed ${CADENCE[billingMode]}.`;
} }
if (count > 1) { if (count > 1) {
+22 -6
View File
@@ -102,16 +102,30 @@ After booking, the destination on a payment can be corrected per booking:
Every price a student is shown on the front end carries its **cadence** — the Every price a student is shown on the front end carries its **cadence** — the
offering's `billing_mode` in the words the student needs: offering's `billing_mode` in the words the student needs:
| `billing_mode` | Shown as | Explained beneath as | | `billing_mode` | Shown as | Explained beneath as |
|----------------|--------------|------------------------------------------------------------| |----------------|-----------------------------------|------------------------------------------------------------|
| `one_time` | `at booking` | Charged once, when you book. | | `one_time` | `at booking` | Charged once, when you book. |
| `full_term` | `up front` | Charged once, up front, for the whole term. | | `full_term` | `up front` | Charged once, up front, for the whole term. |
| `weekly` | `weekly` | Charged for each lesson, 24 hours before it starts. | | `weekly` | `weekly` | Charged for each lesson, 24 hours before it starts. |
| `monthly` | `monthly` | Charged on the 1st of each month, for that month's lessons.| | `monthly` | `per lesson monthly` / `monthly` | Charged on the 1st of each month, for that month's lessons.|
So a lesson type reads `50.00 CAD at booking` in the booking form's type picker, So a lesson type reads `50.00 CAD at booking` in the booking form's type picker,
and a group class card reads `120.00 CAD up front`. A free offering shows `Free`. and a group class card reads `120.00 CAD up front`. A free offering shows `Free`.
**`monthly` reads differently per offering kind.** A private lesson's monthly
charge is that month's lessons × the fee, so the fee is quoted **per lesson**
(`50.00 CAD per lesson monthly`). A group class is enrolled in once, as one
schedule, so its figure is quoted as it stands (`120.00 CAD monthly`) — see
`isPerLessonMonthly()` in `assets/js/pricing.js`.
> **Caveat.** `ScheduledBillingRunner::billGroupMonthly()` bills a monthly group
> class `price × sessions in the month`, the same per-session multiplication used
> for private lessons — so a class meeting weekly is charged roughly four times
> the quoted `120.00 CAD monthly` figure. The display deliberately quotes the
> price as a monthly figure; closing the gap means either billing a monthly group
> class once per month regardless of session count, or restoring the per-lesson
> wording for group classes too.
Before a booking or enrolment can be submitted, the form shows the price again as Before a booking or enrolment can be submitted, the form shows the price again as
a summary block with a **required agreement checkbox** — the second confirmation, a summary block with a **required agreement checkbox** — the second confirmation,
distinct from the policy acceptances above it: distinct from the policy acceptances above it:
@@ -133,6 +147,8 @@ Cadence-specific wording:
simply not claimed, so the real charge can come in under it. simply not claimed, so the real charge can come in under it.
- **`weekly` / `monthly`** — nothing is taken at registration, so the agreement is - **`weekly` / `monthly`** — nothing is taken at registration, so the agreement is
to the recurring charge: "I agree to pay 56.50 CAD per lesson, billed monthly." to the recurring charge: "I agree to pay 56.50 CAD per lesson, billed monthly."
A monthly **group class** agrees to its monthly figure instead ("I agree to pay
138.00 CAD monthly."), matching how its price is quoted on the card.
All of this lives in `assets/js/pricing.js` (`window.usPricing`), shared by the All of this lives in `assets/js/pricing.js` (`window.usPricing`), shared by the
booking and group-class flows so a price reads the same wherever it is met. The booking and group-class flows so a price reads the same wherever it is met. The