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]>
171 lines
7.0 KiB
JavaScript
171 lines
7.0 KiB
JavaScript
/* 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';
|
||
}
|
||
|
||
// 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
|
||
// 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" / "50.00 CAD per lesson monthly" / "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';
|
||
}
|
||
|
||
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
|
||
// 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, kind, 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(cadenceLabel(billingMode, opts.kind))}</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, opts.kind))}
|
||
</label>
|
||
</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),
|
||
// 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, kind) {
|
||
if (RECURRING.indexOf(billingMode) !== -1) {
|
||
// 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) {
|
||
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.',
|
||
};
|
||
}());
|