placementt-core 1.400.1236 → 1.400.1237
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/callables/billing.d.ts +190 -0
- package/lib/callables/billing.js +30 -0
- package/lib/callables/billing.js.map +1 -1
- package/lib/callables/registry.d.ts +364 -0
- package/lib/invoicing.d.ts +24 -0
- package/lib/invoicing.js +22 -1
- package/lib/invoicing.js.map +1 -1
- package/lib/invoicing.test.js +23 -0
- package/lib/invoicing.test.js.map +1 -1
- package/lib/pricing.d.ts +22 -1
- package/lib/pricing.js +43 -0
- package/lib/pricing.js.map +1 -1
- package/lib/pricing.test.js +25 -0
- package/lib/pricing.test.js.map +1 -1
- package/package.json +1 -1
- package/src/callables/billing.ts +30 -0
- package/src/invoicing.test.ts +28 -1
- package/src/invoicing.ts +33 -1
- package/src/pricing.test.ts +30 -0
- package/src/pricing.ts +59 -1
package/src/pricing.ts
CHANGED
|
@@ -59,6 +59,10 @@ export type PricingConfig = {
|
|
|
59
59
|
},
|
|
60
60
|
/** Careers hubs are a flat platform fee plus a fee for each school. */
|
|
61
61
|
hub: {baseFee: number, perSchoolFee: number},
|
|
62
|
+
/** Taken off a returning school's placement fee for each student who was on
|
|
63
|
+
* work experience last year and didn't get a placement, as a fraction of
|
|
64
|
+
* that placement's price (0.5 = those placements are half price). */
|
|
65
|
+
returningUnplacedDiscount: number,
|
|
62
66
|
};
|
|
63
67
|
|
|
64
68
|
/** The pricing sheet's figures, as agreed for 2026. */
|
|
@@ -74,6 +78,7 @@ export const DEFAULT_PRICING_CONFIG: PricingConfig = {
|
|
|
74
78
|
fullPlatformDiscount: 0.1,
|
|
75
79
|
trust: {threshold: 10, discount: 0.05, largeDiscount: 0.1},
|
|
76
80
|
hub: {baseFee: 2000, perSchoolFee: 150},
|
|
81
|
+
returningUnplacedDiscount: 0.5,
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
/** Where the adjustable copy of the config lives. */
|
|
@@ -103,6 +108,9 @@ export type SchoolPricingInput = {
|
|
|
103
108
|
/** A price agreed with the customer, which replaces everything above it —
|
|
104
109
|
* the discounts, the minimum fee and the total the products came to. */
|
|
105
110
|
quoteOverride?: number,
|
|
111
|
+
/** Students on work experience last year who never got a placement. That
|
|
112
|
+
* many of this year's placements are charged at the returning discount. */
|
|
113
|
+
returningUnplaced?: number,
|
|
106
114
|
};
|
|
107
115
|
|
|
108
116
|
export type PricingLineKey =
|
|
@@ -113,8 +121,10 @@ export type PricingLine = {
|
|
|
113
121
|
label: string,
|
|
114
122
|
/** What the product costs after any partial-product discount. */
|
|
115
123
|
amount: number,
|
|
116
|
-
/** The fee before its partial-product discount, when one was applied. */
|
|
124
|
+
/** The fee before its partial-product or returning discount, when one was applied. */
|
|
117
125
|
undiscounted?: number,
|
|
126
|
+
/** Placements on this line charged at the returning discount. */
|
|
127
|
+
returningPlacements?: number,
|
|
118
128
|
/** How the amount was reached, band by band. Empty for the base fee. */
|
|
119
129
|
bands: PricingBand[],
|
|
120
130
|
};
|
|
@@ -224,6 +234,44 @@ export function trustDiscountRate(
|
|
|
224
234
|
return count >= config.trust.threshold ? config.trust.largeDiscount : config.trust.discount;
|
|
225
235
|
}
|
|
226
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Charge last year's unplaced students' placements at the returning discount.
|
|
239
|
+
*
|
|
240
|
+
* A school that paid for students who never got a placement shouldn't pay full
|
|
241
|
+
* price for them again. Those placements are spread across the year groups in
|
|
242
|
+
* proportion to each one's placements, and priced at that year group's average
|
|
243
|
+
* rate per placement, so the graduated bands still decide what a placement is
|
|
244
|
+
* worth. Never more placements than this year's quote has.
|
|
245
|
+
*
|
|
246
|
+
* @param {Array} lines the fees so far; the work experience ones are changed in place.
|
|
247
|
+
* @param {object} input the quote's inputs.
|
|
248
|
+
* @param {object} config the figures to price with.
|
|
249
|
+
*/
|
|
250
|
+
function applyReturningDiscount(lines: PricingLine[], input: SchoolPricingInput, config: PricingConfig) {
|
|
251
|
+
const wexLines = lines.filter((l) => l.key === "wexY10" || l.key === "wexY12");
|
|
252
|
+
const placements = sum(wexLines.map((l) => sum(l.bands.map((b) => b.quantity))));
|
|
253
|
+
const unplaced = Math.min(Math.max(Math.floor(input.returningUnplaced || 0), 0), placements);
|
|
254
|
+
const rate = clampFraction(config.returningUnplacedDiscount);
|
|
255
|
+
if (!unplaced || !rate) return;
|
|
256
|
+
|
|
257
|
+
let remaining = unplaced;
|
|
258
|
+
wexLines.forEach((line, index) => {
|
|
259
|
+
const linePlacements = sum(line.bands.map((b) => b.quantity));
|
|
260
|
+
if (!linePlacements || !line.amount) return;
|
|
261
|
+
// The last year group takes whatever rounding left over, so the
|
|
262
|
+
// placements discounted always add up to the unplaced count.
|
|
263
|
+
const share = index === wexLines.length - 1 ?
|
|
264
|
+
Math.min(remaining, linePlacements) :
|
|
265
|
+
Math.min(Math.round(unplaced * linePlacements / placements), linePlacements, remaining);
|
|
266
|
+
if (!share) return;
|
|
267
|
+
remaining -= share;
|
|
268
|
+
const undiscounted = line.amount;
|
|
269
|
+
line.amount = undiscounted - share * (undiscounted / linePlacements) * rate;
|
|
270
|
+
line.undiscounted = undiscounted;
|
|
271
|
+
line.returningPlacements = share;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
227
275
|
/**
|
|
228
276
|
* Price a school, trust or hub-school quote.
|
|
229
277
|
*
|
|
@@ -257,6 +305,7 @@ export function calculateSchoolPrice(
|
|
|
257
305
|
};
|
|
258
306
|
addWex("wexY10", "Work experience: Y10", input.wexY10);
|
|
259
307
|
addWex("wexY12", "Work experience: Y12", input.wexY12);
|
|
308
|
+
applyReturningDiscount(lines, input, config);
|
|
260
309
|
|
|
261
310
|
const addPerStudent = (key: "events"|"destinations", label: string, taken?: boolean, discounted?: boolean) => {
|
|
262
311
|
if (!taken) return;
|
|
@@ -339,6 +388,7 @@ export function mergePricingConfig(stored?: Partial<PricingConfig>): PricingConf
|
|
|
339
388
|
tiers: stored.tiers?.length ? stored.tiers.map((t, i) => ({...(d.tiers[i] || d.tiers[d.tiers.length - 1]), ...t})) : d.tiers,
|
|
340
389
|
partialProductDiscount: {...d.partialProductDiscount, ...stored.partialProductDiscount},
|
|
341
390
|
fullPlatformDiscount: stored.fullPlatformDiscount ?? d.fullPlatformDiscount,
|
|
391
|
+
returningUnplacedDiscount: stored.returningUnplacedDiscount ?? d.returningUnplacedDiscount,
|
|
342
392
|
trust: {...d.trust, ...stored.trust},
|
|
343
393
|
hub: {...d.hub, ...stored.hub},
|
|
344
394
|
};
|
|
@@ -379,12 +429,19 @@ export type InstitutePricing = {
|
|
|
379
429
|
discountReason?: string,
|
|
380
430
|
/** A price agreed with the customer, which replaces the calculated one. */
|
|
381
431
|
quoteOverride?: number,
|
|
432
|
+
/** Students on last year's work experience cohorts who never got a placement,
|
|
433
|
+
* charged at the returning discount this year. One school's, for a trust. */
|
|
434
|
+
returningUnplaced?: number,
|
|
382
435
|
/** Schools in the trust, which set its discount. Trust accounts only. */
|
|
383
436
|
schools?: number,
|
|
384
437
|
/** Schools on the hub. Hub accounts only. */
|
|
385
438
|
hubSchools?: number,
|
|
386
439
|
/** The quote as last saved, for the customer to see. */
|
|
387
440
|
quote?: InstituteQuoteSnapshot,
|
|
441
|
+
/** Academic years ("2026-27") an admin marked as invoiced outside the panel,
|
|
442
|
+
* for a customer billed some other way (old pricing, an invoice that can't
|
|
443
|
+
* be matched to them in Xero). Admin-only. */
|
|
444
|
+
markedInvoiced?: {[year: string]: {at: string, note?: string}},
|
|
388
445
|
};
|
|
389
446
|
|
|
390
447
|
/** A priced institute: the same shape for a school, a trust and a hub. */
|
|
@@ -550,6 +607,7 @@ export function instituteQuote(
|
|
|
550
607
|
fullPlatformDiscount: pricing?.fullPlatformDiscount,
|
|
551
608
|
bespokeDiscount: pricing?.bespokeDiscount,
|
|
552
609
|
quoteOverride: pricing?.quoteOverride,
|
|
610
|
+
returningUnplaced: wex ? pricing?.returningUnplaced : undefined,
|
|
553
611
|
}, config);
|
|
554
612
|
}
|
|
555
613
|
|