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/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