placementt-core 1.400.1235 → 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.
@@ -92,6 +92,73 @@ export const eventsCallables = {
92
92
  // Public, same credential as above. The changes an email-only school makes on its invite page.
93
93
  // Slot bookings go through events-bookEventSlots and tasks through the same callables employers
94
94
  // use (events-submitExternalForm, events-updateFileView, events-uploadRequiredFile).
95
+ // Public: an event's parent sign-up link. Without eventParentId, the event and the places left, for
96
+ // a parent signing up (notAccepting once the organiser closes sign-ups or the places run out). With it,
97
+ // that parent's booking too: the externalEventParents id is the credential, as for non-user schools.
98
+ // details: {event, eventParent?, organiserName, branding, forms, files, schools (hub events: names of
99
+ // schools on the event, for the parent to pick), placesLeft: {adults?, children?}}.
100
+ "events-getParentSignUp": {
101
+ request: z.object({
102
+ eventId: z.string().min(1),
103
+ eventParentId: z.string().min(1).optional(),
104
+ }),
105
+ response: z.object({
106
+ code: z.enum(["found", "notFound", "notAccepting"]),
107
+ details: z.any().optional(),
108
+ }),
109
+ },
110
+ // Public, same credential. "signUp" makes the externalEventParents doc and emails the parent their
111
+ // booking link; an email already signed up is emailed its link instead ("emailed"). Tasks are the
112
+ // event's parentInvites: uploads go to storage at instituteEvents/:oId/:eventId/parent/:eventParentId/:index
113
+ // first, then "upload" records them.
114
+ "events-updateParentSignUp": {
115
+ request: z.discriminatedUnion("action", [
116
+ z.object({
117
+ action: z.literal("signUp"),
118
+ eventId: z.string().min(1),
119
+ forename: z.string().trim().min(1),
120
+ surname: z.string().trim().min(1),
121
+ email: z.string().trim().email(),
122
+ schoolName: optionalField(z.string().trim()),
123
+ adults: z.number().int().min(0),
124
+ children: z.number().int().min(0),
125
+ }),
126
+ z.object({
127
+ action: z.literal("update"),
128
+ eventParentId: z.string().min(1),
129
+ adults: z.number().int().min(0),
130
+ children: z.number().int().min(0),
131
+ schoolName: optionalField(z.string().trim()),
132
+ }),
133
+ z.object({
134
+ action: z.literal("cancel"),
135
+ eventParentId: z.string().min(1),
136
+ }),
137
+ z.object({
138
+ action: z.literal("submitForm"),
139
+ eventParentId: z.string().min(1),
140
+ formId: z.string().min(1),
141
+ formData: z.record(z.unknown()),
142
+ }),
143
+ z.object({
144
+ action: z.literal("viewFile"),
145
+ eventParentId: z.string().min(1),
146
+ fileId: z.string().min(1),
147
+ }),
148
+ z.object({
149
+ action: z.literal("upload"),
150
+ eventParentId: z.string().min(1),
151
+ index: z.number().int().min(0),
152
+ fileName: z.string().min(1),
153
+ }),
154
+ ]),
155
+ response: z.object({
156
+ code: z.enum(["updated", "emailed", "full"]),
157
+ eventParentId: z.string().optional(),
158
+ // "upload": the uploaded file's download URL.
159
+ url: z.string().optional(),
160
+ }),
161
+ },
95
162
  "events-updateNonUserSchoolInvite": {
96
163
  request: z.discriminatedUnion("action", [
97
164
  z.object({
@@ -1,4 +1,4 @@
1
- import {invoiceYearLabel, addDays, calculateInvoice, discountAmount, invoiceFromQuote} from "./invoicing";
1
+ import {invoiceYearLabel, addDays, calculateInvoice, discountAmount, invoiceFromQuote, isLastYearCohort} from "./invoicing";
2
2
  import {instituteQuote} from "./pricing";
3
3
 
4
4
  /**
@@ -147,3 +147,30 @@ describe("dates", () => {
147
147
  expect(addDays("2026-01-30", 3)).toBe("2026-02-02");
148
148
  });
149
149
  });
150
+
151
+ describe("isLastYearCohort", () => {
152
+ const today = new Date("2026-11-03T12:00:00Z");
153
+
154
+ it("reads a cohort's academic year in either form", () => {
155
+ expect(isLastYearCohort({academicYear: "2025/26"}, today)).toBe(true);
156
+ expect(isLastYearCohort({academicYear: "2025-26"}, today)).toBe(true);
157
+ expect(isLastYearCohort({academicYear: "2026/27"}, today)).toBe(false);
158
+ });
159
+
160
+ it("falls back to when the placements ended", () => {
161
+ expect(isLastYearCohort({endPlacements: "2026-07-10"}, today)).toBe(true);
162
+ expect(isLastYearCohort({endPlacements: "2025-07-10"}, today)).toBe(false);
163
+ expect(isLastYearCohort({}, today)).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe("returning school invoice rows", () => {
168
+ it("shows the returning discount as the row's own discount", () => {
169
+ const quote = instituteQuote({products: {wex: true}, students: 800, pricing: {wexY10: 40, returningUnplaced: 10}});
170
+ const draft = invoiceFromQuote(quote, {instituteName: "Test School", today: new Date("2026-11-03")});
171
+ const row = draft.lines.find((l) => l.key === "wexY10");
172
+ expect(row?.description).toContain("10 placements discounted");
173
+ expect(row?.discount?.type).toBe("amount");
174
+ expect(calculateInvoice(draft).net).toBeCloseTo(quote.total, 2);
175
+ });
176
+ });
package/src/invoicing.ts CHANGED
@@ -192,9 +192,12 @@ export function invoiceFromQuote(
192
192
  // was never discounted, and a percentage would take a slice of it.
193
193
  const saved = line.undiscounted === undefined ? 0 : round((line.undiscounted - line.amount) * schools);
194
194
 
195
+ const returning = line.returningPlacements ?
196
+ ` (returning school: ${line.returningPlacements} placement${line.returningPlacements === 1 ? "" : "s"} discounted for students without a placement last year)` : "";
197
+
195
198
  return {
196
199
  key: line.key,
197
- description: `${line.label} — CareerThread ${invoiceYearLabel(today)}`,
200
+ description: `${line.label} — CareerThread ${invoiceYearLabel(today)}${returning}`,
198
201
  quantity: perUnit ? perUnit.quantity : schools,
199
202
  unitAmount: round(perUnit ? perUnit.rate : listPrice),
200
203
  ...(saved > 0 ? {discount: {type: "amount" as const, value: saved}} : {}),
@@ -262,6 +265,14 @@ export type InvoiceRecord = {
262
265
  draft?: InvoiceDraft,
263
266
  /** Admin uid, when created from the admin panel. */
264
267
  createdBy?: string,
268
+ /** The latest draft emailed to an admin to check, before it went to the customer. */
269
+ draftSentTo?: string,
270
+ /** ISO timestamp. */
271
+ draftSentAt?: string,
272
+ /** Who the approved invoice was emailed to, by us rather than by Xero. */
273
+ sentTo?: string,
274
+ /** ISO timestamp. */
275
+ sentAt?: string,
265
276
  /** ISO timestamps. */
266
277
  createdAt: string,
267
278
  updatedAt: string,
@@ -274,3 +285,24 @@ export const CUSTOMER_VISIBLE_INVOICE_STATUSES: XeroInvoiceStatus[] = ["AUTHORIS
274
285
 
275
286
  /** Where the sync keeps its high-water mark. */
276
287
  export const BILLING_SYNC_PATH = ["adminConfig", "billingSync"];
288
+
289
+ /**
290
+ * Whether a work experience cohort ran in the academic year before `today`'s,
291
+ * which is the year a returning school's unplaced students are counted from.
292
+ *
293
+ * Cohorts made by the recurring set-up carry their academic year ("2025/26");
294
+ * older ones are placed by when their placements ended.
295
+ *
296
+ * @param {object} cohort the cohort's year and placement dates.
297
+ * @param {Date} [today] the invoice date. Now when absent.
298
+ * @return {boolean} true for last year's cohorts.
299
+ */
300
+ export function isLastYearCohort(
301
+ cohort: {academicYear?: string, endPlacements?: string, startPlacements?: string},
302
+ today: Date = new Date(),
303
+ ): boolean {
304
+ const lastStart = currentAcademicStartYear(today) - 1;
305
+ if (cohort.academicYear) return cohort.academicYear.replace("/", "-") === academicYearLabel(lastStart);
306
+ const ended = (cohort.endPlacements || cohort.startPlacements || "").split("T")[0];
307
+ return Boolean(ended) && ended >= `${lastStart}-09-01` && ended < `${lastStart + 1}-09-01`;
308
+ }
@@ -336,3 +336,33 @@ describe("quoteSnapshot", () => {
336
336
  expect(snapshot.discountTotal).toBe(0);
337
337
  });
338
338
  });
339
+
340
+ describe("returning school discount", () => {
341
+ const base: SchoolPricingInput = {students: 800, wexY10: 30, wexY12: 10};
342
+
343
+ it("charges last year's unplaced students' placements at half their price", () => {
344
+ const full = calculateSchoolPrice(base);
345
+ const returning = calculateSchoolPrice({...base, returningUnplaced: 8});
346
+ const wex = (r: typeof full) => r.lines.filter((l) => l.key.startsWith("wex"));
347
+
348
+ const fullY10 = wex(full)[0].amount;
349
+ const fullY12 = wex(full)[1].amount;
350
+ // 8 of 40 placements: 6 in Y10, 2 in Y12, each at half its average rate.
351
+ expect(wex(returning)[0].returningPlacements).toBe(6);
352
+ expect(wex(returning)[1].returningPlacements).toBe(2);
353
+ expect(wex(returning)[0].amount).toBeCloseTo(fullY10 - 6 * (fullY10 / 30) * 0.5, 6);
354
+ expect(wex(returning)[1].amount).toBeCloseTo(fullY12 - 2 * (fullY12 / 10) * 0.5, 6);
355
+ expect(wex(returning)[0].undiscounted).toBeCloseTo(fullY10, 6);
356
+ });
357
+
358
+ it("never discounts more placements than are quoted", () => {
359
+ const all = calculateSchoolPrice({...base, returningUnplaced: 500});
360
+ const wex = all.lines.filter((l) => l.key.startsWith("wex"));
361
+ expect(wex.reduce((t, l) => t + (l.returningPlacements || 0), 0)).toBe(40);
362
+ });
363
+
364
+ it("does nothing without work experience", () => {
365
+ const quote = instituteQuote({products: {events: true}, students: 800, pricing: {returningUnplaced: 10}});
366
+ expect(quote.lines.some((l) => l.returningPlacements)).toBe(false);
367
+ });
368
+ });
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
 
@@ -2564,6 +2564,16 @@ export type ExternalEvent = {
2564
2564
  exists?: boolean,
2565
2565
  perMatchTasks?: boolean,
2566
2566
  },
2567
+ // Parents signing up to an event with parentAttendance set, from its public link
2568
+ // (/event/parents/:oId/:eventId). Each sign-up is an externalEventParents doc.
2569
+ acceptingParents?: boolean,
2570
+ maxParentAdults?: number, // Adult places. Sign-ups close once they're taken.
2571
+ maxParentChildren?: number, // Child places, as above.
2572
+ parentInvites?: {
2573
+ forms?: {form: string, relatedToUpload?: string, visibleTo?: ("employers"|"students"|"parents"|"school")[]}[],
2574
+ files?: string[],
2575
+ requiredFiles?: {fileName: string, fileType: string, visibleTo?: ("employers"|"students"|"parents"|"school")[]}[],
2576
+ },
2567
2577
  employerInvites: {
2568
2578
  forms?: {form: string, relatedToUpload?: string, visibleTo?: ("employers"|"students"|"parents"|"school")[]}[],
2569
2579
  files?: string[],
@@ -2609,7 +2619,7 @@ export type ExternalEvent = {
2609
2619
  provider?: string|false,
2610
2620
  school?: string|false,
2611
2621
  },
2612
- completedStages: ("basicDetails"|"eventConfig"|"selectEmployers"|"employerInviteForms"|"sendEmployerInvites"|"setupStudentInvites"|"setupSchoolInvites"|"review"|"created"|"launched")[]
2622
+ completedStages: ("basicDetails"|"eventConfig"|"selectEmployers"|"employerInviteForms"|"sendEmployerInvites"|"setupStudentInvites"|"setupSchoolInvites"|"setupParentInvites"|"review"|"created"|"launched")[]
2613
2623
  }&Address
2614
2624
 
2615
2625
 
@@ -3589,6 +3599,29 @@ export type ExternalEventCareersSchool = {
3589
3599
  }
3590
3600
 
3591
3601
 
3602
+ // A parent or carer signed up to an event from its public link, with how many adults and children
3603
+ // they're bringing. They have no account: the doc id is the credential for their booking page
3604
+ // (/event/parents/:oId/:eventId/:eventParentId), as for a school invited by email.
3605
+ export type ExternalEventParent = {
3606
+ oId: string,
3607
+ eventId: string,
3608
+ visibleTo: string[],
3609
+ created: string,
3610
+ status: "confirmed"|"cancelled"|"removed", // "cancelled" by the parent, "removed" by the organiser
3611
+ forename: string,
3612
+ surname: string,
3613
+ email: string,
3614
+ adults: number,
3615
+ children: number,
3616
+ // Careers hub events only: the school their children go to, as they gave it.
3617
+ schoolName?: string,
3618
+ completedInvite?: {
3619
+ filesViewed?: string[],
3620
+ formsCompleted?: {[key: string]: unknown},
3621
+ filesUploaded?: {[key: number]: string},
3622
+ },
3623
+ }
3624
+
3592
3625
  export type ExternalEventStudent = {
3593
3626
  oId: string,
3594
3627
  uid: string,