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

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:
2026-07-28 14:43:38 -03:00
co-authored by Claude Opus 5
parent d6a515cc93
commit 9344ab7193
12 changed files with 380 additions and 18 deletions
+145
View File
@@ -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 months 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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.',
};
}());