Files
unsupervised-scheduler/assets/js/pricing.js
T
thatguygriffandClaude Opus 5 9344ab7193
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
Show price cadence and require a pay agreement at booking
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]>
2026-07-28 14:43:38 -03:00

146 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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.',
};
}());