Show price cadence on the front end and require a pay agreement at booking #125
@@ -13,6 +13,9 @@ each change under the current top section as you work.
|
|||||||
|
|
||||||
## [1.2.3]
|
## [1.2.3]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A **monthly group class is now billed its price once per month**, however many times the class meets in that month. Previously the monthly charge multiplied the price by the number of sessions in the month — a class priced at `40.00 CAD` meeting weekly was billed `160.00 CAD` on the 1st — which no studio could quote honestly on a class card. A monthly **private lesson** is unchanged: its price is a per-lesson fee and the month is still billed one fee per lesson, which is why it is quoted per lesson. Studios running a monthly group class should check the class price now reads as the monthly fee they intend to charge.
|
||||||
|
|
||||||
### 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 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**.
|
- 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.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
|||||||
- `one_time` — charged once at booking (a single private lesson).
|
- `one_time` — charged once at booking (a single private lesson).
|
||||||
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
||||||
- `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
|
- `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
|
||||||
- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee).
|
- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for that month. A **private lesson**'s price is a per-lesson fee, so the month is billed (#lessons in the month) × fee; a **group class**'s price is the monthly fee itself, billed once for the month however many times the class meets in it.
|
||||||
|
|
||||||
Students see the mode as a **cadence** beside every price on the front end — *at
|
Students see the mode as a **cadence** beside every price on the front end — *at
|
||||||
booking*, *up front*, *weekly*, *monthly* — and confirm it explicitly before a
|
booking*, *up front*, *weekly*, *monthly* — and confirm it explicitly before a
|
||||||
|
|||||||
@@ -112,19 +112,15 @@ offering's `billing_mode` in the words the student needs:
|
|||||||
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
|
**`monthly` reads differently per offering kind, because it *bills* differently.**
|
||||||
charge is that month's lessons × the fee, so the fee is quoted **per lesson**
|
A private lesson's price is a per-lesson fee and its monthly charge is that
|
||||||
(`50.00 CAD per lesson monthly`). A group class is enrolled in once, as one
|
month's lessons × the fee, so the fee is quoted **per lesson**
|
||||||
schedule, so its figure is quoted as it stands (`120.00 CAD monthly`) — see
|
(`50.00 CAD per lesson monthly`). A monthly group class is priced **per month** —
|
||||||
`isPerLessonMonthly()` in `assets/js/pricing.js`.
|
`ScheduledBillingRunner::billGroupMonthly()` charges the fee once for the month
|
||||||
|
however many times the class meets in it — so its figure is quoted as it stands
|
||||||
> **Caveat.** `ScheduledBillingRunner::billGroupMonthly()` bills a monthly group
|
(`120.00 CAD monthly`). The display split is `isPerLessonMonthly()` in
|
||||||
> class `price × sessions in the month`, the same per-session multiplication used
|
`assets/js/pricing.js`; the billing split is the one place the monthly rule
|
||||||
> for private lessons — so a class meeting weekly is charged roughly four times
|
differs between the two kinds.
|
||||||
> 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,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ payments on a recurring schedule:
|
|||||||
|
|
||||||
- **`weekly`** — one payment per lesson, due **24 hours before** that lesson.
|
- **`weekly`** — one payment per lesson, due **24 hours before** that lesson.
|
||||||
- **`monthly`** — one payment per calendar month, due on the **1st**, covering every
|
- **`monthly`** — one payment per calendar month, due on the **1st**, covering every
|
||||||
lesson that falls in the month (4 lessons ⇒ 4 × fee).
|
lesson that falls in the month. A **private lesson**'s fee is per lesson, so the
|
||||||
|
month costs (#lessons) × fee. A **group class**'s fee is per month: the class is
|
||||||
|
billed that fee once for the month, however many times it meets in it.
|
||||||
|
|
||||||
Both apply to **private lessons** and **group classes**. At registration the
|
Both apply to **private lessons** and **group classes**. At registration the
|
||||||
booking/enrolment succeeds with `payment: null` (no payment step); the lesson is
|
booking/enrolment succeeds with `payment: null` (no payment step); the lesson is
|
||||||
@@ -29,7 +31,7 @@ method resolution, e-transfer freezing, comp auto-pay reused) with a `due_date`
|
|||||||
| **Private weekly** | lesson `start_dt` ≤ now + 24h | 1 × fee | `us_lessons.payment_id` set on the lesson |
|
| **Private weekly** | lesson `start_dt` ≤ now + 24h | 1 × fee | `us_lessons.payment_id` set on the lesson |
|
||||||
| **Private monthly** | the lesson's month's 1st ≤ today | (#lessons in month) × fee | `payment_id` set on every lesson in the month |
|
| **Private monthly** | the lesson's month's 1st ≤ today | (#lessons in month) × fee | `payment_id` set on every lesson in the month |
|
||||||
| **Group weekly** | session (from `Offering::sessionWindows()`) − 1 day ≤ now | 1 × fee | `us_payments.period_key` = session date |
|
| **Group weekly** | session (from `Offering::sessionWindows()`) − 1 day ≤ now | 1 × fee | `us_payments.period_key` = session date |
|
||||||
| **Group monthly** | the month's 1st ≤ today | (#sessions in month) × fee | `period_key` = `YYYY-MM` |
|
| **Group monthly** | the month's 1st ≤ today | 1 × fee (a monthly class is priced per month, not per session) | `period_key` = `YYYY-MM` |
|
||||||
|
|
||||||
- Private lessons dedup on `us_lessons.payment_id IS NULL` — a lesson with no payment is
|
- Private lessons dedup on `us_lessons.payment_id IS NULL` — a lesson with no payment is
|
||||||
unbilled. A monthly group links its earliest lesson via `createForRegistration` and the
|
unbilled. A monthly group links its earliest lesson via `createForRegistration` and the
|
||||||
|
|||||||
@@ -251,11 +251,18 @@ class ScheduledBillingRunner {
|
|||||||
/**
|
/**
|
||||||
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
||||||
*
|
*
|
||||||
|
* A monthly group class is priced **per month**, not per session: the fee is
|
||||||
|
* charged once for the month however many times the class meets in it. This is
|
||||||
|
* what the student is quoted and agrees to on the way in ("40.00 CAD monthly"),
|
||||||
|
* and it is the one place the monthly rule differs from private lessons, whose
|
||||||
|
* per-lesson fee is multiplied by the lessons that fall in the month.
|
||||||
|
*
|
||||||
* @param list<array{start: string, end: string}> $windows
|
* @param list<array{start: string, end: string}> $windows
|
||||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||||
*/
|
*/
|
||||||
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||||
// Count this enrolment's sessions per calendar month.
|
// Count this enrolment's sessions per calendar month. The count does not
|
||||||
|
// price the month — it names it on the student's notice ("3 sessions").
|
||||||
$months = [];
|
$months = [];
|
||||||
foreach ( $windows as $window ) {
|
foreach ( $windows as $window ) {
|
||||||
$start = new \DateTimeImmutable( $window['start'] );
|
$start = new \DateTimeImmutable( $window['start'] );
|
||||||
@@ -278,7 +285,7 @@ class ScheduledBillingRunner {
|
|||||||
(int) $enrollment->id,
|
(int) $enrollment->id,
|
||||||
$enrollment->studentId,
|
$enrollment->studentId,
|
||||||
$enrollment->instructorId,
|
$enrollment->instructorId,
|
||||||
$offering->price * $count,
|
$offering->price,
|
||||||
$offering->currency,
|
$offering->currency,
|
||||||
$offering->etransferEmail,
|
$offering->etransferEmail,
|
||||||
$monthStart,
|
$monthStart,
|
||||||
|
|||||||
@@ -187,7 +187,12 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
$this->runner->run();
|
$this->runner->run();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGroupMonthlyBillsMonthTotal(): void
|
/**
|
||||||
|
* A monthly group class is priced per month, not per session: the same fee
|
||||||
|
* is charged whether the class meets four times in the month or once. This
|
||||||
|
* is what the class card quotes and what the student agrees to pay.
|
||||||
|
*/
|
||||||
|
public function testGroupMonthlyBillsTheMonthlyFeeOnceHoweverManySessions(): void
|
||||||
{
|
{
|
||||||
$this->now('2026-07-15 09:00:00');
|
$this->now('2026-07-15 09:00:00');
|
||||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||||
@@ -197,10 +202,32 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
|
|
||||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
||||||
|
|
||||||
// One payment: 4 sessions x 20, due on the 1st.
|
// One payment of the monthly fee — not 4 x 20 — due on the 1st.
|
||||||
$this->payments->shouldReceive('createForRegistration')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->once()
|
||||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 80.0, 'CAD', null, '2026-07-01', '2026-07')
|
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
|
||||||
|
->andReturn($this->pending(800, '2026-07-01'));
|
||||||
|
|
||||||
|
$this->runner->run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-month fee does not shrink for a short month either — a month with
|
||||||
|
* a single session is billed the same as a month with four.
|
||||||
|
*/
|
||||||
|
public function testGroupMonthlyBillsTheSameFeeForAMonthWithOneSession(): void
|
||||||
|
{
|
||||||
|
$this->now('2026-07-15 09:00:00');
|
||||||
|
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||||
|
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||||
|
// A single July session.
|
||||||
|
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_MONTHLY, '2026-07-07', '2026-07-07'));
|
||||||
|
|
||||||
|
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
||||||
|
|
||||||
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
|
->once()
|
||||||
|
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
|
||||||
->andReturn($this->pending(800, '2026-07-01'));
|
->andReturn($this->pending(800, '2026-07-01'));
|
||||||
|
|
||||||
$this->runner->run();
|
$this->runner->run();
|
||||||
|
|||||||
Reference in New Issue
Block a user