Show price cadence and require a pay agreement at booking
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Coding Standards (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Coding Standards (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Every price a student meets on the front end now carries the cadence it is
billed on — at booking, up front, weekly, monthly — so a bare amount can no
longer read as a one-off when it is a recurring charge.
Both registration forms then restate the price and require a second, separate
tick agreeing to pay it, distinct from the policy acceptances above it. The
agreed figure includes the studio HST so it matches Payment::total(), the amount
actually billed; the rate reaches the browser as a new localized `taxRate`.
A weekly reservation is charged per lesson for every week it claims, and a week
another student takes first is simply not claimed, so its total is quoted as a
ceiling ("up to 12 lessons") rather than a promise. Free offerings have nothing
to agree to and show no price block at all.
The formatting and the agreement live in one shared helper (`window.usPricing`,
registered as `us-scheduler-pricing`) so a price reads the same in the booking
form, the class catalogue and the editor preview.
Closes #124
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -13,6 +13,10 @@ each change under the current top section as you work.
|
||||
|
||||
## [1.2.3]
|
||||
|
||||
### 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**.
|
||||
- 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]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -241,6 +241,47 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* The price and pay agreement on a booking / enrolment form. */
|
||||
.us-price {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.us-price h4 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.us-price p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.us-price-amount strong {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.us-price-cadence {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.us-price-tax,
|
||||
.us-price-note {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.us-price-agree {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The cadence-carrying price on a group-class card. */
|
||||
.us-class-price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.us-week-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
+48
-5
@@ -360,13 +360,27 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
|
||||
// "Piano Lesson (60 min — 50.00 CAD at booking)" / "Trial Lesson (Free)"
|
||||
function offeringLabel(o) {
|
||||
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
||||
const price = Number(o.price) > 0
|
||||
? `$${Number(o.price).toFixed(2)} ${o.currency}`
|
||||
: 'Free';
|
||||
return `${o.title} (${duration}${price})`;
|
||||
return `${o.title} (${duration}${window.usPricing.priceLabel(o)})`;
|
||||
}
|
||||
|
||||
// How many lessons a weekly reservation can claim, mirroring
|
||||
// BookingEndpoint::MAX_WEEKLY_OCCURRENCES so the quoted total is never
|
||||
// higher than the server will actually charge for.
|
||||
const MAX_WEEKLY_OCCURRENCES = 12;
|
||||
|
||||
// The open times a weekly reservation of this slot would claim: every
|
||||
// still-unbooked slot of its recurring group, capped the way the server
|
||||
// caps it. Some may be taken by another student first, so this is the
|
||||
// upper bound on what will be booked, not a guarantee.
|
||||
function weeklyOccurrences(slot) {
|
||||
if (!slot.recurrence_group) return 1;
|
||||
|
||||
const inGroup = allSlots.filter((s) => s.recurrence_group === slot.recurrence_group).length;
|
||||
|
||||
return Math.min(Math.max(inGroup, 1), MAX_WEEKLY_OCCURRENCES);
|
||||
}
|
||||
|
||||
function openRegistration(slot) {
|
||||
@@ -443,6 +457,7 @@
|
||||
<div id="us-questions"></div>
|
||||
${policies.map(policyField).join('')}
|
||||
${weekly}
|
||||
<div id="us-price-summary"></div>
|
||||
<p>
|
||||
<button type="submit" class="us-book-btn">Confirm Booking</button>
|
||||
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
|
||||
@@ -458,6 +473,26 @@
|
||||
let questions = [];
|
||||
|
||||
const questionsBox = document.getElementById('us-questions');
|
||||
const priceBox = document.getElementById('us-price-summary');
|
||||
const weeklyEl = document.getElementById('us-weekly');
|
||||
|
||||
// What the booking will cost and the agreement to pay it, restated
|
||||
// whenever the choices that decide the amount change: the lesson type
|
||||
// carries the price, and a weekly reservation multiplies a per-lesson
|
||||
// one-time price by every week it claims. A slot tied to a type the
|
||||
// catalog no longer carries has no price to quote, so it shows nothing
|
||||
// rather than a figure it cannot stand behind.
|
||||
function renderPrice() {
|
||||
const offering = selectedId ? catalog.find((o) => Number(o.id) === selectedId) : null;
|
||||
priceBox.innerHTML = offering
|
||||
? window.usPricing.summaryHtml({
|
||||
price: offering.price,
|
||||
currency: offering.currency,
|
||||
billing_mode: offering.billing_mode,
|
||||
occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1,
|
||||
})
|
||||
: '';
|
||||
}
|
||||
|
||||
function loadQuestions() {
|
||||
questions = [];
|
||||
@@ -475,10 +510,14 @@
|
||||
document.getElementById('us-offering').addEventListener('change', (e) => {
|
||||
selectedId = Number(e.target.value) || 0;
|
||||
loadQuestions();
|
||||
renderPrice();
|
||||
});
|
||||
}
|
||||
|
||||
if (weeklyEl) weeklyEl.addEventListener('change', renderPrice);
|
||||
|
||||
loadQuestions();
|
||||
renderPrice();
|
||||
|
||||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||||
@@ -487,6 +526,10 @@
|
||||
showError('Please choose a lesson type.');
|
||||
return;
|
||||
}
|
||||
if (!window.usPricing.agreed(e.target)) {
|
||||
showError(window.usPricing.AGREE_REQUIRED);
|
||||
return;
|
||||
}
|
||||
submitBooking(e.target, slot, selectedId, questions);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
${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>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</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>`
|
||||
: ''}
|
||||
@@ -204,6 +204,7 @@
|
||||
<form id="us-enrol-form">
|
||||
${questions.map(questionField).join('')}
|
||||
${policies.map(policyField).join('')}
|
||||
${window.usPricing.summaryHtml(offering)}
|
||||
<p>
|
||||
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
|
||||
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
|
||||
@@ -214,6 +215,10 @@
|
||||
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
|
||||
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (!window.usPricing.agreed(e.target)) {
|
||||
showError(window.usPricing.AGREE_REQUIRED);
|
||||
return;
|
||||
}
|
||||
submitEnrolment(e.target, offering, questions);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/* global usScheduler */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Cadence wording for each offering billing mode, in the phrasing a student
|
||||
// sees beside a price. Mirrors Offering::VALID_BILLING_MODES.
|
||||
const CADENCE = {
|
||||
one_time: 'at booking',
|
||||
full_term: 'up front',
|
||||
weekly: 'weekly',
|
||||
monthly: 'monthly',
|
||||
};
|
||||
|
||||
// How each cadence is actually collected, spelled out beneath the price so
|
||||
// the one-word cadence is never the only thing a student has to go on.
|
||||
const CADENCE_NOTE = {
|
||||
one_time: 'Charged once, when you book.',
|
||||
full_term: 'Charged once, up front, for the whole term.',
|
||||
weekly: 'Charged for each lesson, 24 hours before it starts.',
|
||||
monthly: 'Charged on the 1st of each month, for that month’s lessons.',
|
||||
};
|
||||
|
||||
// The billing modes whose price is a per-lesson fee billed again and again,
|
||||
// rather than a single charge. Mirrors Offering::SCHEDULED_BILLING_MODES.
|
||||
const RECURRING = ['weekly', 'monthly'];
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function mode(billingMode) {
|
||||
return CADENCE[billingMode] ? billingMode : 'one_time';
|
||||
}
|
||||
|
||||
// "50.00 CAD" — amount then currency code, the format used throughout the
|
||||
// ledger, receipts and payment notices.
|
||||
function money(amount, currency) {
|
||||
return `${(Number(amount) || 0).toFixed(2)} ${String(currency || '')}`.trim();
|
||||
}
|
||||
|
||||
// The studio's HST rate as a percentage, frozen onto every payment at
|
||||
// booking time (comped students are the one exception — they are not taxed).
|
||||
function taxRate() {
|
||||
return Number(usScheduler.taxRate) || 0;
|
||||
}
|
||||
|
||||
// Tax on a pre-tax amount, rounded the same way PaymentService does.
|
||||
function tax(amount) {
|
||||
return Math.round((Number(amount) || 0) * taxRate()) / 100;
|
||||
}
|
||||
|
||||
function total(amount) {
|
||||
return (Number(amount) || 0) + tax(amount);
|
||||
}
|
||||
|
||||
// "50.00 CAD at booking" / "Free" — the catalogue label, always carrying the
|
||||
// cadence so a price is never shown without saying when it is due.
|
||||
function priceLabel(offering) {
|
||||
const price = Number(offering.price) || 0;
|
||||
if (price <= 0) {
|
||||
return 'Free';
|
||||
}
|
||||
|
||||
return `${money(price, offering.currency)} ${CADENCE[mode(offering.billing_mode)]}`;
|
||||
}
|
||||
|
||||
// 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
|
||||
// offering has nothing to agree to, so it renders nothing at all.
|
||||
//
|
||||
// opts: { price, currency, billing_mode, occurrences }
|
||||
// `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
|
||||
// ignored for the other modes, whose price is charged per period regardless.
|
||||
function summaryHtml(opts) {
|
||||
const price = Number(opts.price) || 0;
|
||||
if (price <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const billingMode = mode(opts.billing_mode);
|
||||
const currency = opts.currency;
|
||||
const each = total(price);
|
||||
const count = 'one_time' === billingMode ? Math.max(1, Number(opts.occurrences) || 1) : 1;
|
||||
|
||||
const taxLine = taxRate() > 0
|
||||
? `<p class="us-price-tax">${escHtml(`Plus ${taxRate()}% HST — ${money(each, currency)}${count > 1 ? ' per lesson' : ''}.`)}</p>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="us-price">
|
||||
<h4>Price</h4>
|
||||
<p class="us-price-amount">
|
||||
<strong>${escHtml(money(price, currency))}</strong>
|
||||
<span class="us-price-cadence">${escHtml(CADENCE[billingMode])}</span>
|
||||
</p>
|
||||
${taxLine}
|
||||
<p class="us-price-note">${escHtml(count > 1
|
||||
? 'Charged once, when you book — for every week reserved.'
|
||||
: CADENCE_NOTE[billingMode])}</p>
|
||||
<label class="us-price-agree">
|
||||
<input type="checkbox" class="us-price-accept" required>
|
||||
${escHtml(agreeText(each, currency, billingMode, count))}
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// What the student is ticking: the amount actually billed (tax included),
|
||||
// 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
|
||||
// the times first — so its total is stated as a ceiling, never a promise.
|
||||
function agreeText(each, currency, billingMode, count) {
|
||||
if (RECURRING.indexOf(billingMode) !== -1) {
|
||||
return `I agree to pay ${money(each, currency)} per lesson, billed ${CADENCE[billingMode]}.`;
|
||||
}
|
||||
|
||||
if (count > 1) {
|
||||
return `I agree to pay ${money(each, currency)} per lesson at booking — `
|
||||
+ `up to ${count} lessons, ${money(each * count, currency)} in total.`;
|
||||
}
|
||||
|
||||
return `I agree to pay ${money(each, currency)} ${CADENCE[billingMode]}.`;
|
||||
}
|
||||
|
||||
// Whether the payment agreement has been ticked. A form without one (a free
|
||||
// offering) has nothing outstanding, so it counts as agreed.
|
||||
function agreed(root) {
|
||||
const box = root.querySelector('.us-price-accept');
|
||||
|
||||
return !box || box.checked;
|
||||
}
|
||||
|
||||
// Shared by the booking and group-class flows so a price reads the same
|
||||
// wherever a student meets it.
|
||||
window.usPricing = {
|
||||
priceLabel,
|
||||
summaryHtml,
|
||||
agreed,
|
||||
AGREE_REQUIRED: 'Please confirm you agree to pay the amount shown.',
|
||||
};
|
||||
}());
|
||||
@@ -38,12 +38,13 @@ shows "You are enrolled in this class." instead of the Enrol button (the
|
||||
server would reject the duplicate with `409 already_enrolled` regardless — a
|
||||
cancelled enrolment does not block re-enrolling).
|
||||
|
||||
1. Student opens a group class from the offering catalog.
|
||||
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`).
|
||||
3. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||
4. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
5. `POST /enrollments` creates the enrolment (`status = active`), records answers and policy acceptances, and links the payment — but only if the offering's `capacity` has not been reached.
|
||||
6. On successful payment (or comp) a receipt is emailed.
|
||||
4. The enrolment form restates the price (with HST) and requires a second, separate agreement to pay that amount before it will submit. See **Price Display and the Pay Agreement** in `payments.md`.
|
||||
5. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
6. `POST /enrollments` creates the enrolment (`status = active`), records answers and policy acceptances, and links the payment — but only if the offering's `capacity` has not been reached.
|
||||
7. On successful payment (or comp) a receipt is emailed.
|
||||
|
||||
Capacity is enforced at enrolment time by counting `active` rows for the offering;
|
||||
a class at capacity rejects further enrolments.
|
||||
|
||||
@@ -25,11 +25,12 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
||||
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
6. Student is shown what the booking costs — the offering's price with its **cadence** (at booking / up front / weekly / monthly), plus HST — and must tick a second, separate agreement to pay that amount before the form will submit. A weekly reservation quotes the per-lesson fee and the ceiling on the total it can claim. A free offering shows no price block. See **Price Display and the Pay Agreement** in `payments.md`.
|
||||
7. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
11. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
|
||||
## Lesson-Type Filter
|
||||
Not every open slot can be booked as every private-lesson type — a slot tied to
|
||||
|
||||
@@ -35,6 +35,11 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
||||
- `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).
|
||||
|
||||
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 or enrolment goes through. See **Price Display and the Pay Agreement** in
|
||||
`payments.md`.
|
||||
|
||||
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
|
||||
booking/enrolment succeeds with no payment step, and payments are created later by the
|
||||
daily `us_generate_due_payments` cron scan. See `scheduled-billing.md` and `payments.md`.
|
||||
|
||||
@@ -98,6 +98,47 @@ After booking, the destination on a payment can be corrected per booking:
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
||||
|
||||
## Price Display and the Pay Agreement
|
||||
Every price a student is shown on the front end carries its **cadence** — the
|
||||
offering's `billing_mode` in the words the student needs:
|
||||
|
||||
| `billing_mode` | Shown as | Explained beneath as |
|
||||
|----------------|--------------|------------------------------------------------------------|
|
||||
| `one_time` | `at booking` | Charged once, when you book. |
|
||||
| `full_term` | `up front` | Charged once, up front, for the whole term. |
|
||||
| `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.|
|
||||
|
||||
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`.
|
||||
|
||||
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,
|
||||
distinct from the policy acceptances above it:
|
||||
|
||||
> ☐ I agree to pay 56.50 CAD at booking.
|
||||
|
||||
The agreed figure is the amount actually billed, so the studio **HST rate** is
|
||||
added to it (`usScheduler.taxRate`, localized from `us_hst_rate`) and broken out
|
||||
above the checkbox — matching the total `Payment::total()` charges. A comped
|
||||
student is not taxed and is not charged at all, so for them the quoted figure is
|
||||
an upper bound. A free offering has nothing to agree to and shows no block.
|
||||
|
||||
Cadence-specific wording:
|
||||
|
||||
- **Weekly reservation of a `one_time` lesson type** — the fee is charged once per
|
||||
week claimed, so the agreement states the per-lesson amount and the total as a
|
||||
ceiling ("up to 12 lessons, 678.00 CAD in total"). The occurrence count mirrors
|
||||
`BookingEndpoint::MAX_WEEKLY_OCCURRENCES`; a slot another student takes first is
|
||||
simply not claimed, so the real charge can come in under it.
|
||||
- **`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."
|
||||
|
||||
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
|
||||
script is registered as `us-scheduler-pricing` and is a dependency of both
|
||||
`us-scheduler` and `us-scheduler-group`.
|
||||
|
||||
## Payment Flow
|
||||
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
|
||||
2. The browser confirms the card payment with Stripe.
|
||||
@@ -139,8 +180,10 @@ See `payment-reporting.md` for the monthly report and CSV export endpoints.
|
||||
- Receipts: `Unsupervised\Schedular\Payment\ReceiptMailer`
|
||||
- Settings page: `Unsupervised\Schedular\Payment\StudioSettings`
|
||||
- REST endpoint: `Unsupervised\Schedular\Payment\PaymentEndpoint`
|
||||
- Front-end price display + pay agreement: `assets/js/pricing.js` (`window.usPricing`), registered and localized with `taxRate` by `Unsupervised\Schedular\ShortcodeRegistrar`
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/ShortcodeRegistrarTest.php` (pricing helper registration + localized `taxRate`)
|
||||
- `tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||
- `tests/Unit/Payment/PaymentTest.php`
|
||||
- `tests/Unit/Payment/StripeGatewayTest.php`
|
||||
|
||||
@@ -118,11 +118,13 @@ class BlockPreview {
|
||||
: '<p>' . esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ) . '</p>';
|
||||
|
||||
return sprintf(
|
||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p>25.00 CAD</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p class="us-class-price">%s</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||
self::note( $note ),
|
||||
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ),
|
||||
$description,
|
||||
// Prices on the live page always carry their cadence, so the sample does too.
|
||||
esc_html__( '25.00 CAD up front', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
||||
);
|
||||
|
||||
@@ -64,14 +64,20 @@ class ShortcodeRegistrar {
|
||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||
'stripeKey' => $settings->publishableKey(),
|
||||
'startOfWeek' => Val::int( get_option( 'start_of_week', 1 ) ),
|
||||
// The studio HST rate, so a price quoted to a student on the way in
|
||||
// matches the total they are actually billed.
|
||||
'taxRate' => $settings->hstRate(),
|
||||
];
|
||||
|
||||
// Attach the shared config to the payment helper so it is defined before the
|
||||
// booking/group scripts (which depend on it) run.
|
||||
wp_localize_script( 'us-scheduler-payment', 'usScheduler', $data );
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
// Price formatting and the pay agreement, shared by booking and enrolment.
|
||||
wp_register_script( 'us-scheduler-pricing', USC_PLUGIN_URL . 'assets/js/pricing.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing' ], USC_VERSION, true );
|
||||
|
||||
// Progressive enhancement for the two-step registration form (no dependencies).
|
||||
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
||||
|
||||
@@ -23,6 +23,9 @@ class ShortcodeRegistrarTest extends TestCase
|
||||
/** @var array<string, callable> */
|
||||
private array $shortcodes = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $localized = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -84,6 +87,69 @@ class ShortcodeRegistrarTest extends TestCase
|
||||
self::assertSame('group', $this->shortcodes['us_group_classes'](''));
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking and group-class scripts both read prices through the shared
|
||||
* pricing helper, so it must be registered ahead of them (and behind the
|
||||
* payment helper, which carries the localized config it reads).
|
||||
*/
|
||||
public function testPricingHelperIsRegisteredAheadOfTheBookingAndGroupScripts(): void
|
||||
{
|
||||
$scripts = $this->captureEnqueuedAssets();
|
||||
|
||||
self::assertSame(['us-scheduler-payment'], $scripts['us-scheduler-pricing']);
|
||||
self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler']);
|
||||
self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler-group']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The studio HST rate reaches the front end so a price quoted on a booking
|
||||
* form matches the total the student is actually billed.
|
||||
*/
|
||||
public function testStudioTaxRateIsLocalizedToTheFrontEnd(): void
|
||||
{
|
||||
$this->captureEnqueuedAssets();
|
||||
|
||||
self::assertSame(13.0, $this->localized['taxRate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> Registered script handle => dependencies.
|
||||
*/
|
||||
private function captureEnqueuedAssets(): array
|
||||
{
|
||||
$scripts = [];
|
||||
$localized = &$this->localized;
|
||||
|
||||
Functions\when('wp_register_style')->justReturn(true);
|
||||
Functions\when('rest_url')->justReturn('https://example.test/wp-json/us-scheduler/v1/');
|
||||
Functions\when('wp_create_nonce')->justReturn('nonce');
|
||||
Functions\when('get_option')->alias(
|
||||
static fn (string $name, mixed $default = false): mixed => match ($name) {
|
||||
'us_hst_rate' => '13',
|
||||
'start_of_week' => 1,
|
||||
default => $default,
|
||||
}
|
||||
);
|
||||
Functions\when('wp_register_script')->alias(
|
||||
static function (string $handle, string $src, array $deps = []) use (&$scripts): bool {
|
||||
$scripts[$handle] = $deps;
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when('wp_localize_script')->alias(
|
||||
static function (string $handle, string $object, array $data) use (&$localized): bool {
|
||||
$localized = $data;
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
$this->registrar->enqueueAssets();
|
||||
|
||||
return $scripts;
|
||||
}
|
||||
|
||||
public function testShortcodeAttributesArePassedThroughUnchanged(): void
|
||||
{
|
||||
$this->registrar->register();
|
||||
|
||||
Reference in New Issue
Block a user