placementt-core 1.400.1043 → 1.400.1044

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/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export * from "./skillsResolver";
17
17
  export * from "./activities";
18
18
  export * from "./lmi";
19
19
  export * from "./productResolver";
20
+ export * from "./pricing";
20
21
  export * from "./groups";
21
22
  export * from "./emailSections";
22
23
  export * from "./exportTransformers";
package/lib/index.js CHANGED
@@ -37,6 +37,7 @@ __exportStar(require("./skillsResolver"), exports);
37
37
  __exportStar(require("./activities"), exports);
38
38
  __exportStar(require("./lmi"), exports);
39
39
  __exportStar(require("./productResolver"), exports);
40
+ __exportStar(require("./pricing"), exports);
40
41
  __exportStar(require("./groups"), exports);
41
42
  __exportStar(require("./emailSections"), exports);
42
43
  __exportStar(require("./exportTransformers"), exports);
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AACA,sDAAoC;AACpC,gEAA8C;AAC9C,0DAAwC;AACxC,2DAAyC;AACzC,kDAAgC;AAChC,uDAAqC;AACrC,4DAA0C;AAC1C,2DAAyC;AAEzC,oDAAkC;AAClC,8CAA4B;AAC5B,kDAAgC;AAChC,kDAAgC;AAChC,qDAAmC;AACnC,mDAAiC;AACjC,qDAAmC;AACnC,mDAAiC;AACjC,+CAA6B;AAC7B,wCAAsB;AACtB,oDAAkC;AAClC,2CAAyB;AACzB,kDAAgC;AAChC,uDAAqC;AACrC,kDAAgC;AAChC,iDAA+B;AAC/B,2DAAyC;AACzC,uDAAqC;AACrC,iDAA+B;AAC/B,8CAA4B;AAC5B,qDAAmC;AACnC,0CAAwB;AACxB,yCAAuB;AACvB,4DAA0C;AAC1C,kDAAgC;AAChC,iDAA+B;AAC/B,2CAAyB;AACzB,4DAA0C;AAC1C,oEAAiD;AACjD,yEAAuD;AACvD,kEAAgD;AAChD,kEAAgD;AAChD,oEAAkD;AAClD,yGAAuF;AACvF,0GAAwF;AACxF,+FAA6E;AAC7E,4DAA0C;AAC1C,oEAAkD;AAClD,iEAA+C;AAC/C,gEAA8C;AAC9C,2EAAyD;AACzD,6CAA2B;AAC3B,+CAA6B;AAC7B,8CAA4B;AAC5B,6DAAuE;AAA9D,2IAAA,OAAO,OAAuB"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AACA,sDAAoC;AACpC,gEAA8C;AAC9C,0DAAwC;AACxC,2DAAyC;AACzC,kDAAgC;AAChC,uDAAqC;AACrC,4DAA0C;AAC1C,2DAAyC;AAEzC,oDAAkC;AAClC,8CAA4B;AAC5B,kDAAgC;AAChC,kDAAgC;AAChC,qDAAmC;AACnC,mDAAiC;AACjC,qDAAmC;AACnC,mDAAiC;AACjC,+CAA6B;AAC7B,wCAAsB;AACtB,oDAAkC;AAClC,4CAA0B;AAC1B,2CAAyB;AACzB,kDAAgC;AAChC,uDAAqC;AACrC,kDAAgC;AAChC,iDAA+B;AAC/B,2DAAyC;AACzC,uDAAqC;AACrC,iDAA+B;AAC/B,8CAA4B;AAC5B,qDAAmC;AACnC,0CAAwB;AACxB,yCAAuB;AACvB,4DAA0C;AAC1C,kDAAgC;AAChC,iDAA+B;AAC/B,2CAAyB;AACzB,4DAA0C;AAC1C,oEAAiD;AACjD,yEAAuD;AACvD,kEAAgD;AAChD,kEAAgD;AAChD,oEAAkD;AAClD,yGAAuF;AACvF,0GAAwF;AACxF,+FAA6E;AAC7E,4DAA0C;AAC1C,oEAAkD;AAClD,iEAA+C;AAC/C,gEAA8C;AAC9C,2EAAyD;AACzD,6CAA2B;AAC3B,+CAA6B;AAC7B,8CAA4B;AAC5B,6DAAuE;AAA9D,2IAAA,OAAO,OAAuB"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The 2026 pricing model.
3
+ *
4
+ * A quote is a base fee plus a per-product fee, and every per-product fee is
5
+ * graduated: the tiers are bands, not lookups, so a school with 1,250 students
6
+ * pays the tier 1 rate on its first 1,000 and the tier 2 rate on the next 250.
7
+ * Anything above the final threshold keeps paying the final tier's rate.
8
+ *
9
+ * Work experience is charged per placement (Y10 and Y12 are quoted separately,
10
+ * and a year group left blank is a year group not taking the product); events
11
+ * and destinations are charged per student. Only events and destinations carry
12
+ * a partial-product discount — that is how the pricing sheet has it, and it is
13
+ * why `partialProductDiscount` names those two rather than being one number.
14
+ *
15
+ * Every figure lives in `PricingConfig` so it can be stored in Firestore and
16
+ * adjusted without a deploy. `DEFAULT_PRICING_CONFIG` is the fallback and the
17
+ * seed for that document.
18
+ */
19
+ /** One band of the graduated fee, with the rate each product charges in it. */
20
+ export type PricingTier = {
21
+ /** Upper bound of the band, in students (events and destinations). */
22
+ students: number;
23
+ /** Upper bound of the band, in placements (work experience). */
24
+ placements: number;
25
+ /** £ per placement. */
26
+ wex: number;
27
+ /** £ per student. */
28
+ events: number;
29
+ /** £ per student. */
30
+ destinations: number;
31
+ };
32
+ export type PricingConfig = {
33
+ /** No school is quoted below this, however little the products come to. */
34
+ minFee: number;
35
+ /** Charged on every quote. Not itemised to the customer. */
36
+ baseFee: number;
37
+ /** In ascending order of threshold. The last band's rate runs to infinity. */
38
+ tiers: PricingTier[];
39
+ /** Taken off a single product's fee, as a fraction (0.2 = 20% off). */
40
+ partialProductDiscount: {
41
+ events: number;
42
+ destinations: number;
43
+ };
44
+ /** Taken off the whole quote when a school takes the full platform. */
45
+ fullPlatformDiscount: number;
46
+ /** Careers hubs are a flat platform fee plus a fee for each school. */
47
+ hub: {
48
+ baseFee: number;
49
+ perSchoolFee: number;
50
+ };
51
+ };
52
+ /** The pricing sheet's figures, as agreed for 2026. */
53
+ export declare const DEFAULT_PRICING_CONFIG: PricingConfig;
54
+ /** Where the adjustable copy of the config lives. */
55
+ export declare const PRICING_CONFIG_PATH: string[];
56
+ export type SchoolPricingInput = {
57
+ /** Roll size. Drives the events and destinations fees. */
58
+ students?: number;
59
+ /** Y10 placements. Undefined means the year group isn't taking work experience. */
60
+ wexY10?: number;
61
+ /** Y12 placements. Undefined means the year group isn't taking work experience. */
62
+ wexY12?: number;
63
+ events?: boolean;
64
+ /** Discount the events fee on its own. */
65
+ eventsDiscount?: boolean;
66
+ destinations?: boolean;
67
+ /** Discount the destinations fee on its own. */
68
+ destinationsDiscount?: boolean;
69
+ /** Discount the whole quote, for a school taking the full platform. */
70
+ fullPlatformDiscount?: boolean;
71
+ };
72
+ export type PricingLineKey = "base" | "wexY10" | "wexY12" | "events" | "destinations";
73
+ export type PricingLine = {
74
+ key: PricingLineKey;
75
+ label: string;
76
+ /** What the product costs after any partial-product discount. */
77
+ amount: number;
78
+ /** The fee before its partial-product discount, when one was applied. */
79
+ undiscounted?: number;
80
+ /** How the amount was reached, band by band. Empty for the base fee. */
81
+ bands: PricingBand[];
82
+ };
83
+ /** One band's contribution to a product's fee, for showing the working. */
84
+ export type PricingBand = {
85
+ /** 1-indexed, matching the tier's position in the config. */
86
+ tier: number;
87
+ /** Units charged in this band. */
88
+ quantity: number;
89
+ rate: number;
90
+ amount: number;
91
+ };
92
+ export type SchoolPricingResult = {
93
+ lines: PricingLine[];
94
+ /** The lines added up, before the full-platform discount. */
95
+ subtotal: number;
96
+ /** £ taken off the subtotal by the full-platform discount. */
97
+ fullPlatformDiscountApplied: number;
98
+ /** True when the minimum fee, not the products, set the price. */
99
+ minFeeApplied: boolean;
100
+ total: number;
101
+ };
102
+ export type HubPricingResult = {
103
+ baseFee: number;
104
+ /** The per-school fee multiplied by the number of schools. */
105
+ schoolFees: number;
106
+ total: number;
107
+ };
108
+ /**
109
+ * Price a school, trust or hub-school quote.
110
+ *
111
+ * Products the school isn't taking are left out of `lines` entirely, so the
112
+ * breakdown only ever shows what is being charged for.
113
+ *
114
+ * @param {object} input the roll, the year groups and the products taken.
115
+ * @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
116
+ * @return {object} the total, and the working that reached it.
117
+ */
118
+ export declare function calculateSchoolPrice(input: SchoolPricingInput, config?: PricingConfig): SchoolPricingResult;
119
+ /**
120
+ * Price a careers hub: the platform fee, plus a fee for every school on it.
121
+ *
122
+ * @param {number} schools how many schools the hub brings on.
123
+ * @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
124
+ * @return {object} the platform fee, the school fees and the total.
125
+ */
126
+ export declare function calculateHubPrice(schools: number, config?: PricingConfig): HubPricingResult;
127
+ /**
128
+ * Fill a stored config out with the defaults.
129
+ *
130
+ * The Firestore document is hand-edited, so treat every branch of it as
131
+ * possibly absent — a config saved before a tier was added should still price,
132
+ * rather than throwing on a missing rate.
133
+ *
134
+ * @param {object} [stored] whatever the Firestore document holds.
135
+ * @return {object} a complete config, safe to price with.
136
+ */
137
+ export declare function mergePricingConfig(stored?: Partial<PricingConfig>): PricingConfig;
package/lib/pricing.js ADDED
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ /**
3
+ * The 2026 pricing model.
4
+ *
5
+ * A quote is a base fee plus a per-product fee, and every per-product fee is
6
+ * graduated: the tiers are bands, not lookups, so a school with 1,250 students
7
+ * pays the tier 1 rate on its first 1,000 and the tier 2 rate on the next 250.
8
+ * Anything above the final threshold keeps paying the final tier's rate.
9
+ *
10
+ * Work experience is charged per placement (Y10 and Y12 are quoted separately,
11
+ * and a year group left blank is a year group not taking the product); events
12
+ * and destinations are charged per student. Only events and destinations carry
13
+ * a partial-product discount — that is how the pricing sheet has it, and it is
14
+ * why `partialProductDiscount` names those two rather than being one number.
15
+ *
16
+ * Every figure lives in `PricingConfig` so it can be stored in Firestore and
17
+ * adjusted without a deploy. `DEFAULT_PRICING_CONFIG` is the fallback and the
18
+ * seed for that document.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.PRICING_CONFIG_PATH = exports.DEFAULT_PRICING_CONFIG = void 0;
22
+ exports.calculateSchoolPrice = calculateSchoolPrice;
23
+ exports.calculateHubPrice = calculateHubPrice;
24
+ exports.mergePricingConfig = mergePricingConfig;
25
+ /** The pricing sheet's figures, as agreed for 2026. */
26
+ exports.DEFAULT_PRICING_CONFIG = {
27
+ minFee: 500,
28
+ baseFee: 250,
29
+ tiers: [
30
+ { students: 1000, placements: 20, wex: 3, events: 0.6, destinations: 0.6 },
31
+ { students: 1500, placements: 150, wex: 3, events: 0.5, destinations: 0.5 },
32
+ { students: 2000, placements: 200, wex: 1.5, events: 0.25, destinations: 0.25 },
33
+ ],
34
+ partialProductDiscount: { events: 0.2, destinations: 0.2 },
35
+ fullPlatformDiscount: 0.2,
36
+ hub: { baseFee: 2000, perSchoolFee: 150 },
37
+ };
38
+ /** Where the adjustable copy of the config lives. */
39
+ exports.PRICING_CONFIG_PATH = ["adminConfig", "pricing"];
40
+ /**
41
+ * Charge `quantity` across the bands, each band at its own rate, with anything
42
+ * above the final threshold staying on the final band's rate.
43
+ *
44
+ * @param {number} quantity students or placements, depending on the product.
45
+ * @param {Array} bands each band's upper threshold and its rate.
46
+ * @return {Array} one entry per band that charged something.
47
+ */
48
+ function graduated(quantity, bands) {
49
+ if (!(quantity > 0) || !bands.length)
50
+ return [];
51
+ const lines = [];
52
+ let previousLimit = 0;
53
+ bands.forEach((band, index) => {
54
+ const inBand = Math.max(Math.min(quantity, band.limit) - previousLimit, 0);
55
+ previousLimit = band.limit;
56
+ if (inBand > 0)
57
+ lines.push({ tier: index + 1, quantity: inBand, rate: band.rate, amount: inBand * band.rate });
58
+ });
59
+ // Everything past the last threshold keeps paying the last rate, so it belongs
60
+ // in the last band rather than as a second row at the same rate.
61
+ const last = bands[bands.length - 1];
62
+ const overflow = Math.max(quantity - last.limit, 0);
63
+ if (overflow > 0) {
64
+ const finalBand = lines.find((l) => l.tier === bands.length);
65
+ if (finalBand) {
66
+ finalBand.quantity += overflow;
67
+ finalBand.amount += overflow * last.rate;
68
+ }
69
+ else {
70
+ lines.push({ tier: bands.length, quantity: overflow, rate: last.rate, amount: overflow * last.rate });
71
+ }
72
+ }
73
+ return lines;
74
+ }
75
+ const sum = (values) => values.reduce((total, value) => total + value, 0);
76
+ const bandsTotal = (bands) => sum(bands.map((b) => b.amount));
77
+ /**
78
+ * Price a school, trust or hub-school quote.
79
+ *
80
+ * Products the school isn't taking are left out of `lines` entirely, so the
81
+ * breakdown only ever shows what is being charged for.
82
+ *
83
+ * @param {object} input the roll, the year groups and the products taken.
84
+ * @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
85
+ * @return {object} the total, and the working that reached it.
86
+ */
87
+ function calculateSchoolPrice(input, config = exports.DEFAULT_PRICING_CONFIG) {
88
+ const students = input.students || 0;
89
+ const tiers = config.tiers;
90
+ const perPlacement = tiers.map((t) => ({ limit: t.placements, rate: t.wex }));
91
+ const perStudent = (key) => tiers.map((t) => ({ limit: t.students, rate: t[key] }));
92
+ const lines = [{ key: "base", label: "Base fee", amount: config.baseFee, bands: [] }];
93
+ const addWex = (key, label, placements) => {
94
+ if (placements === undefined)
95
+ return;
96
+ const bands = graduated(placements, perPlacement);
97
+ lines.push({ key, label, amount: bandsTotal(bands), bands });
98
+ };
99
+ addWex("wexY10", "Work experience — Y10", input.wexY10);
100
+ addWex("wexY12", "Work experience — Y12", input.wexY12);
101
+ const addPerStudent = (key, label, taken, discounted) => {
102
+ if (!taken)
103
+ return;
104
+ const bands = graduated(students, perStudent(key));
105
+ const undiscounted = bandsTotal(bands);
106
+ const amount = discounted ? undiscounted * (1 - config.partialProductDiscount[key]) : undiscounted;
107
+ lines.push({ key, label, amount, bands, ...(discounted ? { undiscounted } : {}) });
108
+ };
109
+ addPerStudent("events", "Employer events", input.events, input.eventsDiscount);
110
+ addPerStudent("destinations", "Destinations", input.destinations, input.destinationsDiscount);
111
+ const subtotal = sum(lines.map((l) => l.amount));
112
+ const discounted = input.fullPlatformDiscount ? subtotal * (1 - config.fullPlatformDiscount) : subtotal;
113
+ const total = Math.max(discounted, config.minFee);
114
+ return {
115
+ lines,
116
+ subtotal,
117
+ fullPlatformDiscountApplied: subtotal - discounted,
118
+ minFeeApplied: total > discounted,
119
+ total,
120
+ };
121
+ }
122
+ /**
123
+ * Price a careers hub: the platform fee, plus a fee for every school on it.
124
+ *
125
+ * @param {number} schools how many schools the hub brings on.
126
+ * @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
127
+ * @return {object} the platform fee, the school fees and the total.
128
+ */
129
+ function calculateHubPrice(schools, config = exports.DEFAULT_PRICING_CONFIG) {
130
+ const schoolFees = Math.max(schools || 0, 0) * config.hub.perSchoolFee;
131
+ return { baseFee: config.hub.baseFee, schoolFees, total: config.hub.baseFee + schoolFees };
132
+ }
133
+ /**
134
+ * Fill a stored config out with the defaults.
135
+ *
136
+ * The Firestore document is hand-edited, so treat every branch of it as
137
+ * possibly absent — a config saved before a tier was added should still price,
138
+ * rather than throwing on a missing rate.
139
+ *
140
+ * @param {object} [stored] whatever the Firestore document holds.
141
+ * @return {object} a complete config, safe to price with.
142
+ */
143
+ function mergePricingConfig(stored) {
144
+ const d = exports.DEFAULT_PRICING_CONFIG;
145
+ if (!stored)
146
+ return d;
147
+ return {
148
+ minFee: stored.minFee ?? d.minFee,
149
+ baseFee: stored.baseFee ?? d.baseFee,
150
+ tiers: stored.tiers?.length ? stored.tiers.map((t, i) => ({ ...(d.tiers[i] || d.tiers[d.tiers.length - 1]), ...t })) : d.tiers,
151
+ partialProductDiscount: { ...d.partialProductDiscount, ...stored.partialProductDiscount },
152
+ fullPlatformDiscount: stored.fullPlatformDiscount ?? d.fullPlatformDiscount,
153
+ hub: { ...d.hub, ...stored.hub },
154
+ };
155
+ }
156
+ //# sourceMappingURL=pricing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricing.js","sourceRoot":"","sources":["../src/pricing.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const pricing_1 = require("./pricing");
4
+ /**
5
+ * The worked examples from the 2026 pricing sheet, kept here as the contract:
6
+ * if the calculation drifts from the spreadsheet, these fail. The awkward
7
+ * numbers (Teddington's 1405/7 year groups) are the point — they exercise the
8
+ * band boundaries and the overflow rate rather than landing on round tiers.
9
+ */
10
+ const price = (input) => (0, pricing_1.calculateSchoolPrice)(input).total;
11
+ const line = (input, key) => (0, pricing_1.calculateSchoolPrice)(input).lines.find((l) => l.key === key);
12
+ describe("calculateSchoolPrice - the pricing sheet's examples", () => {
13
+ it("GHS: 1,250 students, discounted events", () => {
14
+ // 1,000 @ 0.6 + 250 @ 0.5 = 725, less 20% = 580, plus the 250 base.
15
+ const input = { students: 1250, events: true, eventsDiscount: true };
16
+ expect(line(input, "events")?.amount).toBeCloseTo(580, 2);
17
+ expect(price(input)).toBeCloseTo(830, 2);
18
+ });
19
+ it("Horizon: a small school falls back to the minimum fee", () => {
20
+ // 250 + 30 + 30 + 54 = 364, which is under the 500 floor.
21
+ const input = { students: 90, wexY10: 10, wexY12: 10, destinations: true };
22
+ const result = (0, pricing_1.calculateSchoolPrice)(input);
23
+ expect(result.subtotal).toBeCloseTo(364, 2);
24
+ expect(result.minFeeApplied).toBe(true);
25
+ expect(result.total).toBe(500);
26
+ });
27
+ it("Big School: 2,500 students spills past the last tier at the last rate", () => {
28
+ // 600 + 250 + 125 + (500 over the top threshold @ 0.25) 125 = 1,100.
29
+ const input = { students: 2500, events: true };
30
+ expect(line(input, "events")?.amount).toBeCloseTo(1100, 2);
31
+ expect(price(input)).toBeCloseTo(1350, 2);
32
+ });
33
+ it("BET School: 1,500 students on events and destinations", () => {
34
+ expect(price({ students: 1500, events: true, destinations: true })).toBeCloseTo(1950, 2);
35
+ });
36
+ it("Alperton: both products discounted individually", () => {
37
+ const input = {
38
+ students: 1929,
39
+ events: true, eventsDiscount: true,
40
+ destinations: true, destinationsDiscount: true,
41
+ };
42
+ expect(line(input, "events")?.undiscounted).toBeCloseTo(957.25, 2);
43
+ expect(line(input, "events")?.amount).toBeCloseTo(765.8, 2);
44
+ expect(price(input)).toBeCloseTo(1781.6, 2);
45
+ });
46
+ it("Teddington: full platform, with year groups estimated as roll/7", () => {
47
+ const yearGroup = 1405 / 7;
48
+ const input = {
49
+ students: 1405,
50
+ wexY10: yearGroup, wexY12: yearGroup,
51
+ events: true, destinations: true,
52
+ fullPlatformDiscount: true,
53
+ };
54
+ const result = (0, pricing_1.calculateSchoolPrice)(input);
55
+ expect(line(input, "wexY10")?.amount).toBeCloseTo(526.07, 2);
56
+ expect(result.subtotal).toBeCloseTo(2907.14, 2);
57
+ expect(result.total).toBeCloseTo(2325.71, 2);
58
+ });
59
+ });
60
+ describe("calculateSchoolPrice - the breakdown", () => {
61
+ it("only itemises the products the school is taking", () => {
62
+ const keys = (0, pricing_1.calculateSchoolPrice)({ students: 1000, events: true }).lines.map((l) => l.key);
63
+ expect(keys).toEqual(["base", "events"]);
64
+ });
65
+ it("charges a year group that is taking work experience but has no placements yet", () => {
66
+ // Zero is a real answer; undefined means the year group isn't on the product.
67
+ expect(line({ students: 500, wexY10: 0 }, "wexY10")?.amount).toBe(0);
68
+ expect(line({ students: 500 }, "wexY10")).toBeUndefined();
69
+ });
70
+ it("shows the working, band by band", () => {
71
+ const bands = line({ students: 1250, events: true }, "events")?.bands;
72
+ expect(bands).toEqual([
73
+ { tier: 1, quantity: 1000, rate: 0.6, amount: 600 },
74
+ { tier: 2, quantity: 250, rate: 0.5, amount: 125 },
75
+ ]);
76
+ });
77
+ it("rolls the amount above the top threshold into the final band", () => {
78
+ // 2,500 students is 500 past the last threshold, charged at the last rate:
79
+ // one Tier 3 row of 1,000, not a 500 row and a 500 row.
80
+ const bands = line({ students: 2500, events: true }, "events")?.bands;
81
+ expect(bands).toEqual([
82
+ { tier: 1, quantity: 1000, rate: 0.6, amount: 600 },
83
+ { tier: 2, quantity: 500, rate: 0.5, amount: 250 },
84
+ { tier: 3, quantity: 1000, rate: 0.25, amount: 250 },
85
+ ]);
86
+ });
87
+ it("reports what the full-platform discount took off", () => {
88
+ const result = (0, pricing_1.calculateSchoolPrice)({ students: 2000, events: true, destinations: true, fullPlatformDiscount: true });
89
+ expect(result.fullPlatformDiscountApplied).toBeCloseTo(result.subtotal * 0.2, 6);
90
+ expect(result.total).toBeCloseTo(result.subtotal - result.fullPlatformDiscountApplied, 6);
91
+ });
92
+ });
93
+ describe("calculateHubPrice", () => {
94
+ it("prices the agreed option: 2,000 base plus 150 a school", () => {
95
+ expect((0, pricing_1.calculateHubPrice)(50).total).toBe(9500);
96
+ expect((0, pricing_1.calculateHubPrice)(100).total).toBe(17000);
97
+ });
98
+ it("is the base fee alone with no schools", () => {
99
+ expect((0, pricing_1.calculateHubPrice)(0)).toEqual({ baseFee: 2000, schoolFees: 0, total: 2000 });
100
+ });
101
+ });
102
+ describe("mergePricingConfig", () => {
103
+ it("returns the defaults when nothing is stored", () => {
104
+ expect((0, pricing_1.mergePricingConfig)()).toEqual(pricing_1.DEFAULT_PRICING_CONFIG);
105
+ expect((0, pricing_1.mergePricingConfig)({})).toEqual(pricing_1.DEFAULT_PRICING_CONFIG);
106
+ });
107
+ it("takes a stored value without losing its siblings", () => {
108
+ const merged = (0, pricing_1.mergePricingConfig)({ minFee: 750, hub: { perSchoolFee: 200 } });
109
+ expect(merged.minFee).toBe(750);
110
+ expect(merged.baseFee).toBe(pricing_1.DEFAULT_PRICING_CONFIG.baseFee);
111
+ expect(merged.hub).toEqual({ baseFee: 2000, perSchoolFee: 200 });
112
+ });
113
+ it("fills a half-written tier out from the default in its place", () => {
114
+ const merged = (0, pricing_1.mergePricingConfig)({ tiers: [{ events: 0.8 }, {}, {}] });
115
+ expect(merged.tiers[0]).toEqual({ ...pricing_1.DEFAULT_PRICING_CONFIG.tiers[0], events: 0.8 });
116
+ expect(merged.tiers[2]).toEqual(pricing_1.DEFAULT_PRICING_CONFIG.tiers[2]);
117
+ });
118
+ });
119
+ //# sourceMappingURL=pricing.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricing.test.js","sourceRoot":"","sources":["../src/pricing.test.ts"],"names":[],"mappings":"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"}
@@ -779,8 +779,9 @@ export type WorkflowStage = {
779
779
  * - "open": schools may add or remove freely. */
780
780
  export type WorkflowEditPolicy = "locked" | "add" | "open";
781
781
  /** The features a trust can impose on its schools. Work experience is absent because
782
- * it predates this and carries its policy on the workflow document itself — see
783
- * WorkflowConfig.mandatory/editPolicy, which this generalises. */
782
+ * it predates this and carries its policy on the cohort template a school applies —
783
+ * see InstituteData.cohortConfig.mandatory and WorkflowConfig.editPolicy, which this
784
+ * generalises. */
784
785
  export type TrustPolicyFeature = "regularChecks" | "aspirations" | "leaverGroups" | "skills";
785
786
  /**
786
787
  * A trust's answer for one feature.
@@ -822,10 +823,10 @@ export type WorkflowConfig = {
822
823
  /** Flattened forms/files used across the stages, mirroring CohortData.forms/files. */
823
824
  forms?: string[];
824
825
  files?: string[];
825
- /** Authored by a trust (institutes-two) for the schools beneath it. */
826
+ /** Authored by a trust (institutes-two) for the schools beneath it. A trust
827
+ * requires a workflow by putting it on a mandatory cohort template
828
+ * (InstituteData.cohortConfig), not by marking the workflow itself. */
826
829
  trust?: boolean;
827
- /** Schools in the trust must use this workflow on their work experience cohorts. */
828
- mandatory?: boolean;
829
830
  /** What a school may change on the stages of this workflow. Defaults to "open". */
830
831
  editPolicy?: WorkflowEditPolicy;
831
832
  };
@@ -1169,14 +1170,26 @@ export type InstituteData = {
1169
1170
  v: unknown;
1170
1171
  }[];
1171
1172
  };
1173
+ /**
1174
+ * Two things share this map, because they are the same thing at two levels.
1175
+ *
1176
+ * On a SCHOOL it is a recurring work experience group: a cohort is spawned from
1177
+ * it each year on `createMMDD`, carrying the dates, filters and settings below.
1178
+ *
1179
+ * On a TRUST (institutes-two) it is a settings template its schools apply when
1180
+ * they create a cohort. A trust neither runs cohorts nor knows a school's dates,
1181
+ * so the scheduling and student-filter fields are absent there — a trust template
1182
+ * only carries the things it is entitled to decide (workflow, feedback forms and,
1183
+ * in future, daily logs) plus whether its schools must use it (`mandatory`).
1184
+ */
1172
1185
  cohortConfig?: {
1173
1186
  [groupId: string]: {
1174
1187
  name: string;
1175
- placementType: "defined" | "continuous";
1188
+ placementType?: "defined" | "continuous";
1176
1189
  workflowId?: string;
1177
1190
  workflowOverrides?: WorkflowOverrides;
1178
- autoCreate: boolean;
1179
- createMMDD: string;
1191
+ autoCreate?: boolean;
1192
+ createMMDD?: string;
1180
1193
  autoAddFilter?: {
1181
1194
  k: string;
1182
1195
  e?: WhereFilterOp;
@@ -1185,6 +1198,9 @@ export type InstituteData = {
1185
1198
  feedback?: CohortData["feedback"];
1186
1199
  feedbackText?: CohortData["feedbackText"];
1187
1200
  emailParentOnStatusChange?: boolean;
1201
+ /** Trust templates only — schools must apply this template to every new
1202
+ * work experience cohort. Optional templates are offered as a choice. */
1203
+ mandatory?: boolean;
1188
1204
  };
1189
1205
  };
1190
1206
  cs?: CustomerSuccessData;
@@ -5,18 +5,15 @@ import { CohortData, InstituteData, WorkflowConfig, WorkflowEditPolicy, Workflow
5
5
  * school (or a trust, on behalf of its schools) defines a workflow once and every cohort
6
6
  * that uses it points at it with CohortData.workflowId.
7
7
  *
8
- * A trust can mark its workflow mandatory, in which case schools beneath it get that
9
- * workflowId applied to new cohorts. Schools may still layer per-stage forms/files on top
10
- * (subject to the workflow's editPolicy); those deltas are stored as WorkflowOverrides on
11
- * the cohort, and mirrored onto the recurring group template so they carry into next year.
8
+ * A trust requires a workflow by putting it on a cohort template it marks mandatory
9
+ * (InstituteData.cohortConfig) — the workflow document itself says nothing about who
10
+ * must use it. Schools may still layer per-stage forms/files on top (subject to the
11
+ * workflow's editPolicy); those deltas are stored as WorkflowOverrides on the cohort,
12
+ * and mirrored onto the recurring group template so they carry into next year.
12
13
  */
13
14
  /** The trust a school belongs to, if any. */
14
15
  export declare const approvedTrustId: (institute?: Pick<InstituteData, "trustLinks">) => string | undefined;
15
- /** The trust-authored workflow a school is obliged to use, if the trust set one. */
16
- export declare const mandatoryTrustWorkflow: (workflows?: {
17
- [id: string]: WorkflowConfig;
18
- }, trustId?: string) => [string, WorkflowConfig] | undefined;
19
- /** Trust workflows visible to a school, mandatory or not. */
16
+ /** Trust workflows visible to a school. */
20
17
  export declare const trustWorkflows: (workflows?: {
21
18
  [id: string]: WorkflowConfig;
22
19
  }, trustId?: string) => [string, WorkflowConfig][];
package/lib/workflows.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.cohortWorkflowConfigured = exports.workflowEditPolicy = exports.trustWorkflows = exports.mandatoryTrustWorkflow = exports.approvedTrustId = void 0;
6
+ exports.cohortWorkflowConfigured = exports.workflowEditPolicy = exports.trustWorkflows = exports.approvedTrustId = void 0;
7
7
  exports.applyWorkflowOverrides = applyWorkflowOverrides;
8
8
  exports.diffWorkflowOverrides = diffWorkflowOverrides;
9
9
  exports.enforceEditPolicy = enforceEditPolicy;
@@ -16,23 +16,16 @@ const firebaseQuery_1 = __importDefault(require("./firebase/firebaseQuery"));
16
16
  * school (or a trust, on behalf of its schools) defines a workflow once and every cohort
17
17
  * that uses it points at it with CohortData.workflowId.
18
18
  *
19
- * A trust can mark its workflow mandatory, in which case schools beneath it get that
20
- * workflowId applied to new cohorts. Schools may still layer per-stage forms/files on top
21
- * (subject to the workflow's editPolicy); those deltas are stored as WorkflowOverrides on
22
- * the cohort, and mirrored onto the recurring group template so they carry into next year.
19
+ * A trust requires a workflow by putting it on a cohort template it marks mandatory
20
+ * (InstituteData.cohortConfig) — the workflow document itself says nothing about who
21
+ * must use it. Schools may still layer per-stage forms/files on top (subject to the
22
+ * workflow's editPolicy); those deltas are stored as WorkflowOverrides on the cohort,
23
+ * and mirrored onto the recurring group template so they carry into next year.
23
24
  */
24
25
  /** The trust a school belongs to, if any. */
25
26
  const approvedTrustId = (institute) => Object.entries(institute?.trustLinks || {}).find(([, l]) => l.status === "approved")?.[0];
26
27
  exports.approvedTrustId = approvedTrustId;
27
- /** The trust-authored workflow a school is obliged to use, if the trust set one. */
28
- const mandatoryTrustWorkflow = (workflows, trustId) => {
29
- if (!trustId)
30
- return undefined;
31
- return Object.entries(workflows || {})
32
- .find(([, w]) => w.trust && w.mandatory && w.oId === trustId);
33
- };
34
- exports.mandatoryTrustWorkflow = mandatoryTrustWorkflow;
35
- /** Trust workflows visible to a school, mandatory or not. */
28
+ /** Trust workflows visible to a school. */
36
29
  const trustWorkflows = (workflows, trustId) => Object.entries(workflows || {}).filter(([, w]) => w.trust && (!trustId || w.oId === trustId));
37
30
  exports.trustWorkflows = trustWorkflows;
38
31
  const workflowEditPolicy = (workflow) => (workflow?.trust ? workflow.editPolicy || "open" : "open");
@@ -1 +1 @@
1
- {"version":3,"file":"workflows.js","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"workflows.js","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "private": false,
3
3
  "name": "placementt-core",
4
4
  "author": "Placementt",
5
- "version": "1.400.1043",
5
+ "version": "1.400.1044",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
8
8
  "scripts": {
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./skillsResolver";
19
19
  export * from "./activities";
20
20
  export * from "./lmi";
21
21
  export * from "./productResolver";
22
+ export * from "./pricing";
22
23
  export * from "./groups";
23
24
  export * from "./emailSections";
24
25
  export * from "./exportTransformers";
@@ -0,0 +1,147 @@
1
+ import {calculateHubPrice, calculateSchoolPrice, DEFAULT_PRICING_CONFIG, mergePricingConfig, SchoolPricingInput} from "./pricing";
2
+
3
+ /**
4
+ * The worked examples from the 2026 pricing sheet, kept here as the contract:
5
+ * if the calculation drifts from the spreadsheet, these fail. The awkward
6
+ * numbers (Teddington's 1405/7 year groups) are the point — they exercise the
7
+ * band boundaries and the overflow rate rather than landing on round tiers.
8
+ */
9
+
10
+ const price = (input: SchoolPricingInput) => calculateSchoolPrice(input).total;
11
+
12
+ const line = (input: SchoolPricingInput, key: string) =>
13
+ calculateSchoolPrice(input).lines.find((l) => l.key === key);
14
+
15
+ describe("calculateSchoolPrice - the pricing sheet's examples", () => {
16
+ it("GHS: 1,250 students, discounted events", () => {
17
+ // 1,000 @ 0.6 + 250 @ 0.5 = 725, less 20% = 580, plus the 250 base.
18
+ const input: SchoolPricingInput = {students: 1250, events: true, eventsDiscount: true};
19
+
20
+ expect(line(input, "events")?.amount).toBeCloseTo(580, 2);
21
+ expect(price(input)).toBeCloseTo(830, 2);
22
+ });
23
+
24
+ it("Horizon: a small school falls back to the minimum fee", () => {
25
+ // 250 + 30 + 30 + 54 = 364, which is under the 500 floor.
26
+ const input: SchoolPricingInput = {students: 90, wexY10: 10, wexY12: 10, destinations: true};
27
+ const result = calculateSchoolPrice(input);
28
+
29
+ expect(result.subtotal).toBeCloseTo(364, 2);
30
+ expect(result.minFeeApplied).toBe(true);
31
+ expect(result.total).toBe(500);
32
+ });
33
+
34
+ it("Big School: 2,500 students spills past the last tier at the last rate", () => {
35
+ // 600 + 250 + 125 + (500 over the top threshold @ 0.25) 125 = 1,100.
36
+ const input: SchoolPricingInput = {students: 2500, events: true};
37
+
38
+ expect(line(input, "events")?.amount).toBeCloseTo(1100, 2);
39
+ expect(price(input)).toBeCloseTo(1350, 2);
40
+ });
41
+
42
+ it("BET School: 1,500 students on events and destinations", () => {
43
+ expect(price({students: 1500, events: true, destinations: true})).toBeCloseTo(1950, 2);
44
+ });
45
+
46
+ it("Alperton: both products discounted individually", () => {
47
+ const input: SchoolPricingInput = {
48
+ students: 1929,
49
+ events: true, eventsDiscount: true,
50
+ destinations: true, destinationsDiscount: true,
51
+ };
52
+
53
+ expect(line(input, "events")?.undiscounted).toBeCloseTo(957.25, 2);
54
+ expect(line(input, "events")?.amount).toBeCloseTo(765.8, 2);
55
+ expect(price(input)).toBeCloseTo(1781.6, 2);
56
+ });
57
+
58
+ it("Teddington: full platform, with year groups estimated as roll/7", () => {
59
+ const yearGroup = 1405 / 7;
60
+ const input: SchoolPricingInput = {
61
+ students: 1405,
62
+ wexY10: yearGroup, wexY12: yearGroup,
63
+ events: true, destinations: true,
64
+ fullPlatformDiscount: true,
65
+ };
66
+ const result = calculateSchoolPrice(input);
67
+
68
+ expect(line(input, "wexY10")?.amount).toBeCloseTo(526.07, 2);
69
+ expect(result.subtotal).toBeCloseTo(2907.14, 2);
70
+ expect(result.total).toBeCloseTo(2325.71, 2);
71
+ });
72
+ });
73
+
74
+ describe("calculateSchoolPrice - the breakdown", () => {
75
+ it("only itemises the products the school is taking", () => {
76
+ const keys = calculateSchoolPrice({students: 1000, events: true}).lines.map((l) => l.key);
77
+
78
+ expect(keys).toEqual(["base", "events"]);
79
+ });
80
+
81
+ it("charges a year group that is taking work experience but has no placements yet", () => {
82
+ // Zero is a real answer; undefined means the year group isn't on the product.
83
+ expect(line({students: 500, wexY10: 0}, "wexY10")?.amount).toBe(0);
84
+ expect(line({students: 500}, "wexY10")).toBeUndefined();
85
+ });
86
+
87
+ it("shows the working, band by band", () => {
88
+ const bands = line({students: 1250, events: true}, "events")?.bands;
89
+
90
+ expect(bands).toEqual([
91
+ {tier: 1, quantity: 1000, rate: 0.6, amount: 600},
92
+ {tier: 2, quantity: 250, rate: 0.5, amount: 125},
93
+ ]);
94
+ });
95
+
96
+ it("rolls the amount above the top threshold into the final band", () => {
97
+ // 2,500 students is 500 past the last threshold, charged at the last rate:
98
+ // one Tier 3 row of 1,000, not a 500 row and a 500 row.
99
+ const bands = line({students: 2500, events: true}, "events")?.bands;
100
+
101
+ expect(bands).toEqual([
102
+ {tier: 1, quantity: 1000, rate: 0.6, amount: 600},
103
+ {tier: 2, quantity: 500, rate: 0.5, amount: 250},
104
+ {tier: 3, quantity: 1000, rate: 0.25, amount: 250},
105
+ ]);
106
+ });
107
+
108
+ it("reports what the full-platform discount took off", () => {
109
+ const result = calculateSchoolPrice({students: 2000, events: true, destinations: true, fullPlatformDiscount: true});
110
+
111
+ expect(result.fullPlatformDiscountApplied).toBeCloseTo(result.subtotal * 0.2, 6);
112
+ expect(result.total).toBeCloseTo(result.subtotal - result.fullPlatformDiscountApplied, 6);
113
+ });
114
+ });
115
+
116
+ describe("calculateHubPrice", () => {
117
+ it("prices the agreed option: 2,000 base plus 150 a school", () => {
118
+ expect(calculateHubPrice(50).total).toBe(9500);
119
+ expect(calculateHubPrice(100).total).toBe(17000);
120
+ });
121
+
122
+ it("is the base fee alone with no schools", () => {
123
+ expect(calculateHubPrice(0)).toEqual({baseFee: 2000, schoolFees: 0, total: 2000});
124
+ });
125
+ });
126
+
127
+ describe("mergePricingConfig", () => {
128
+ it("returns the defaults when nothing is stored", () => {
129
+ expect(mergePricingConfig()).toEqual(DEFAULT_PRICING_CONFIG);
130
+ expect(mergePricingConfig({})).toEqual(DEFAULT_PRICING_CONFIG);
131
+ });
132
+
133
+ it("takes a stored value without losing its siblings", () => {
134
+ const merged = mergePricingConfig({minFee: 750, hub: {perSchoolFee: 200} as never});
135
+
136
+ expect(merged.minFee).toBe(750);
137
+ expect(merged.baseFee).toBe(DEFAULT_PRICING_CONFIG.baseFee);
138
+ expect(merged.hub).toEqual({baseFee: 2000, perSchoolFee: 200});
139
+ });
140
+
141
+ it("fills a half-written tier out from the default in its place", () => {
142
+ const merged = mergePricingConfig({tiers: [{events: 0.8} as never, {} as never, {} as never]});
143
+
144
+ expect(merged.tiers[0]).toEqual({...DEFAULT_PRICING_CONFIG.tiers[0], events: 0.8});
145
+ expect(merged.tiers[2]).toEqual(DEFAULT_PRICING_CONFIG.tiers[2]);
146
+ });
147
+ });
package/src/pricing.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The 2026 pricing model.
3
+ *
4
+ * A quote is a base fee plus a per-product fee, and every per-product fee is
5
+ * graduated: the tiers are bands, not lookups, so a school with 1,250 students
6
+ * pays the tier 1 rate on its first 1,000 and the tier 2 rate on the next 250.
7
+ * Anything above the final threshold keeps paying the final tier's rate.
8
+ *
9
+ * Work experience is charged per placement (Y10 and Y12 are quoted separately,
10
+ * and a year group left blank is a year group not taking the product); events
11
+ * and destinations are charged per student. Only events and destinations carry
12
+ * a partial-product discount — that is how the pricing sheet has it, and it is
13
+ * why `partialProductDiscount` names those two rather than being one number.
14
+ *
15
+ * Every figure lives in `PricingConfig` so it can be stored in Firestore and
16
+ * adjusted without a deploy. `DEFAULT_PRICING_CONFIG` is the fallback and the
17
+ * seed for that document.
18
+ */
19
+
20
+ /** One band of the graduated fee, with the rate each product charges in it. */
21
+ export type PricingTier = {
22
+ /** Upper bound of the band, in students (events and destinations). */
23
+ students: number,
24
+ /** Upper bound of the band, in placements (work experience). */
25
+ placements: number,
26
+ /** £ per placement. */
27
+ wex: number,
28
+ /** £ per student. */
29
+ events: number,
30
+ /** £ per student. */
31
+ destinations: number,
32
+ };
33
+
34
+ export type PricingConfig = {
35
+ /** No school is quoted below this, however little the products come to. */
36
+ minFee: number,
37
+ /** Charged on every quote. Not itemised to the customer. */
38
+ baseFee: number,
39
+ /** In ascending order of threshold. The last band's rate runs to infinity. */
40
+ tiers: PricingTier[],
41
+ /** Taken off a single product's fee, as a fraction (0.2 = 20% off). */
42
+ partialProductDiscount: {events: number, destinations: number},
43
+ /** Taken off the whole quote when a school takes the full platform. */
44
+ fullPlatformDiscount: number,
45
+ /** Careers hubs are a flat platform fee plus a fee for each school. */
46
+ hub: {baseFee: number, perSchoolFee: number},
47
+ };
48
+
49
+ /** The pricing sheet's figures, as agreed for 2026. */
50
+ export const DEFAULT_PRICING_CONFIG: PricingConfig = {
51
+ minFee: 500,
52
+ baseFee: 250,
53
+ tiers: [
54
+ {students: 1000, placements: 20, wex: 3, events: 0.6, destinations: 0.6},
55
+ {students: 1500, placements: 150, wex: 3, events: 0.5, destinations: 0.5},
56
+ {students: 2000, placements: 200, wex: 1.5, events: 0.25, destinations: 0.25},
57
+ ],
58
+ partialProductDiscount: {events: 0.2, destinations: 0.2},
59
+ fullPlatformDiscount: 0.2,
60
+ hub: {baseFee: 2000, perSchoolFee: 150},
61
+ };
62
+
63
+ /** Where the adjustable copy of the config lives. */
64
+ export const PRICING_CONFIG_PATH = ["adminConfig", "pricing"];
65
+
66
+ export type SchoolPricingInput = {
67
+ /** Roll size. Drives the events and destinations fees. */
68
+ students?: number,
69
+ /** Y10 placements. Undefined means the year group isn't taking work experience. */
70
+ wexY10?: number,
71
+ /** Y12 placements. Undefined means the year group isn't taking work experience. */
72
+ wexY12?: number,
73
+ events?: boolean,
74
+ /** Discount the events fee on its own. */
75
+ eventsDiscount?: boolean,
76
+ destinations?: boolean,
77
+ /** Discount the destinations fee on its own. */
78
+ destinationsDiscount?: boolean,
79
+ /** Discount the whole quote, for a school taking the full platform. */
80
+ fullPlatformDiscount?: boolean,
81
+ };
82
+
83
+ export type PricingLineKey = "base"|"wexY10"|"wexY12"|"events"|"destinations";
84
+
85
+ export type PricingLine = {
86
+ key: PricingLineKey,
87
+ label: string,
88
+ /** What the product costs after any partial-product discount. */
89
+ amount: number,
90
+ /** The fee before its partial-product discount, when one was applied. */
91
+ undiscounted?: number,
92
+ /** How the amount was reached, band by band. Empty for the base fee. */
93
+ bands: PricingBand[],
94
+ };
95
+
96
+ /** One band's contribution to a product's fee, for showing the working. */
97
+ export type PricingBand = {
98
+ /** 1-indexed, matching the tier's position in the config. */
99
+ tier: number,
100
+ /** Units charged in this band. */
101
+ quantity: number,
102
+ rate: number,
103
+ amount: number,
104
+ };
105
+
106
+ export type SchoolPricingResult = {
107
+ lines: PricingLine[],
108
+ /** The lines added up, before the full-platform discount. */
109
+ subtotal: number,
110
+ /** £ taken off the subtotal by the full-platform discount. */
111
+ fullPlatformDiscountApplied: number,
112
+ /** True when the minimum fee, not the products, set the price. */
113
+ minFeeApplied: boolean,
114
+ total: number,
115
+ };
116
+
117
+ export type HubPricingResult = {
118
+ baseFee: number,
119
+ /** The per-school fee multiplied by the number of schools. */
120
+ schoolFees: number,
121
+ total: number,
122
+ };
123
+
124
+ /**
125
+ * Charge `quantity` across the bands, each band at its own rate, with anything
126
+ * above the final threshold staying on the final band's rate.
127
+ *
128
+ * @param {number} quantity students or placements, depending on the product.
129
+ * @param {Array} bands each band's upper threshold and its rate.
130
+ * @return {Array} one entry per band that charged something.
131
+ */
132
+ function graduated(quantity: number, bands: {limit: number, rate: number}[]): PricingBand[] {
133
+ if (!(quantity > 0) || !bands.length) return [];
134
+
135
+ const lines: PricingBand[] = [];
136
+ let previousLimit = 0;
137
+
138
+ bands.forEach((band, index) => {
139
+ const inBand = Math.max(Math.min(quantity, band.limit) - previousLimit, 0);
140
+ previousLimit = band.limit;
141
+ if (inBand > 0) lines.push({tier: index + 1, quantity: inBand, rate: band.rate, amount: inBand * band.rate});
142
+ });
143
+
144
+ // Everything past the last threshold keeps paying the last rate, so it belongs
145
+ // in the last band rather than as a second row at the same rate.
146
+ const last = bands[bands.length - 1];
147
+ const overflow = Math.max(quantity - last.limit, 0);
148
+ if (overflow > 0) {
149
+ const finalBand = lines.find((l) => l.tier === bands.length);
150
+ if (finalBand) {
151
+ finalBand.quantity += overflow;
152
+ finalBand.amount += overflow * last.rate;
153
+ } else {
154
+ lines.push({tier: bands.length, quantity: overflow, rate: last.rate, amount: overflow * last.rate});
155
+ }
156
+ }
157
+
158
+ return lines;
159
+ }
160
+
161
+ const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
162
+
163
+ const bandsTotal = (bands: PricingBand[]) => sum(bands.map((b) => b.amount));
164
+
165
+ /**
166
+ * Price a school, trust or hub-school quote.
167
+ *
168
+ * Products the school isn't taking are left out of `lines` entirely, so the
169
+ * breakdown only ever shows what is being charged for.
170
+ *
171
+ * @param {object} input the roll, the year groups and the products taken.
172
+ * @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
173
+ * @return {object} the total, and the working that reached it.
174
+ */
175
+ export function calculateSchoolPrice(
176
+ input: SchoolPricingInput,
177
+ config: PricingConfig = DEFAULT_PRICING_CONFIG,
178
+ ): SchoolPricingResult {
179
+ const students = input.students || 0;
180
+ const tiers = config.tiers;
181
+
182
+ const perPlacement = tiers.map((t) => ({limit: t.placements, rate: t.wex}));
183
+ const perStudent = (key: "events"|"destinations") =>
184
+ tiers.map((t) => ({limit: t.students, rate: t[key]}));
185
+
186
+ const lines: PricingLine[] = [{key: "base", label: "Base fee", amount: config.baseFee, bands: []}];
187
+
188
+ const addWex = (key: "wexY10"|"wexY12", label: string, placements?: number) => {
189
+ if (placements === undefined) return;
190
+ const bands = graduated(placements, perPlacement);
191
+ lines.push({key, label, amount: bandsTotal(bands), bands});
192
+ };
193
+ addWex("wexY10", "Work experience — Y10", input.wexY10);
194
+ addWex("wexY12", "Work experience — Y12", input.wexY12);
195
+
196
+ const addPerStudent = (key: "events"|"destinations", label: string, taken?: boolean, discounted?: boolean) => {
197
+ if (!taken) return;
198
+ const bands = graduated(students, perStudent(key));
199
+ const undiscounted = bandsTotal(bands);
200
+ const amount = discounted ? undiscounted * (1 - config.partialProductDiscount[key]) : undiscounted;
201
+ lines.push({key, label, amount, bands, ...(discounted ? {undiscounted} : {})});
202
+ };
203
+ addPerStudent("events", "Employer events", input.events, input.eventsDiscount);
204
+ addPerStudent("destinations", "Destinations", input.destinations, input.destinationsDiscount);
205
+
206
+ const subtotal = sum(lines.map((l) => l.amount));
207
+ const discounted = input.fullPlatformDiscount ? subtotal * (1 - config.fullPlatformDiscount) : subtotal;
208
+ const total = Math.max(discounted, config.minFee);
209
+
210
+ return {
211
+ lines,
212
+ subtotal,
213
+ fullPlatformDiscountApplied: subtotal - discounted,
214
+ minFeeApplied: total > discounted,
215
+ total,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Price a careers hub: the platform fee, plus a fee for every school on it.
221
+ *
222
+ * @param {number} schools how many schools the hub brings on.
223
+ * @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
224
+ * @return {object} the platform fee, the school fees and the total.
225
+ */
226
+ export function calculateHubPrice(
227
+ schools: number,
228
+ config: PricingConfig = DEFAULT_PRICING_CONFIG,
229
+ ): HubPricingResult {
230
+ const schoolFees = Math.max(schools || 0, 0) * config.hub.perSchoolFee;
231
+ return {baseFee: config.hub.baseFee, schoolFees, total: config.hub.baseFee + schoolFees};
232
+ }
233
+
234
+ /**
235
+ * Fill a stored config out with the defaults.
236
+ *
237
+ * The Firestore document is hand-edited, so treat every branch of it as
238
+ * possibly absent — a config saved before a tier was added should still price,
239
+ * rather than throwing on a missing rate.
240
+ *
241
+ * @param {object} [stored] whatever the Firestore document holds.
242
+ * @return {object} a complete config, safe to price with.
243
+ */
244
+ export function mergePricingConfig(stored?: Partial<PricingConfig>): PricingConfig {
245
+ const d = DEFAULT_PRICING_CONFIG;
246
+ if (!stored) return d;
247
+
248
+ return {
249
+ minFee: stored.minFee ?? d.minFee,
250
+ baseFee: stored.baseFee ?? d.baseFee,
251
+ tiers: stored.tiers?.length ? stored.tiers.map((t, i) => ({...(d.tiers[i] || d.tiers[d.tiers.length - 1]), ...t})) : d.tiers,
252
+ partialProductDiscount: {...d.partialProductDiscount, ...stored.partialProductDiscount},
253
+ fullPlatformDiscount: stored.fullPlatformDiscount ?? d.fullPlatformDiscount,
254
+ hub: {...d.hub, ...stored.hub},
255
+ };
256
+ }
@@ -781,8 +781,9 @@ export type WorkflowStage = {
781
781
  export type WorkflowEditPolicy = "locked"|"add"|"open";
782
782
 
783
783
  /** The features a trust can impose on its schools. Work experience is absent because
784
- * it predates this and carries its policy on the workflow document itself — see
785
- * WorkflowConfig.mandatory/editPolicy, which this generalises. */
784
+ * it predates this and carries its policy on the cohort template a school applies —
785
+ * see InstituteData.cohortConfig.mandatory and WorkflowConfig.editPolicy, which this
786
+ * generalises. */
786
787
  export type TrustPolicyFeature = "regularChecks"|"aspirations"|"leaverGroups"|"skills";
787
788
 
788
789
  /**
@@ -828,10 +829,10 @@ export type WorkflowConfig = {
828
829
  forms?: string[],
829
830
  files?: string[],
830
831
  // --- Trust-authored workflows only ---
831
- /** Authored by a trust (institutes-two) for the schools beneath it. */
832
+ /** Authored by a trust (institutes-two) for the schools beneath it. A trust
833
+ * requires a workflow by putting it on a mandatory cohort template
834
+ * (InstituteData.cohortConfig), not by marking the workflow itself. */
832
835
  trust?: boolean,
833
- /** Schools in the trust must use this workflow on their work experience cohorts. */
834
- mandatory?: boolean,
835
836
  /** What a school may change on the stages of this workflow. Defaults to "open". */
836
837
  editPolicy?: WorkflowEditPolicy,
837
838
  };
@@ -1199,16 +1200,27 @@ export type InstituteData = {
1199
1200
  v: unknown;
1200
1201
  }[],
1201
1202
  }
1202
- // RECURRING WORK EXPERIENCE GROUPS (templates that spawn a cohort instance each year)
1203
+ /**
1204
+ * Two things share this map, because they are the same thing at two levels.
1205
+ *
1206
+ * On a SCHOOL it is a recurring work experience group: a cohort is spawned from
1207
+ * it each year on `createMMDD`, carrying the dates, filters and settings below.
1208
+ *
1209
+ * On a TRUST (institutes-two) it is a settings template its schools apply when
1210
+ * they create a cohort. A trust neither runs cohorts nor knows a school's dates,
1211
+ * so the scheduling and student-filter fields are absent there — a trust template
1212
+ * only carries the things it is entitled to decide (workflow, feedback forms and,
1213
+ * in future, daily logs) plus whether its schools must use it (`mandatory`).
1214
+ */
1203
1215
  cohortConfig?: {
1204
1216
  [groupId: string]: {
1205
1217
  name: string,
1206
- placementType: "defined"|"continuous",
1218
+ placementType?: "defined"|"continuous", // school groups only
1207
1219
  workflowId?: string, // doc in the `workflows` collection
1208
1220
  workflowOverrides?: WorkflowOverrides, // school deltas on a trust workflow, reapplied each year
1209
- autoCreate: boolean, // recurrence on; always true when created via setup
1210
- createMMDD: string, // "08-15" — annual creation trigger (mirrors aspirationCycleConfig.scheduledSendMMDD)
1211
- autoAddFilter?: { // same shape as a cohortFilters entry; copied to cohortFilters[newCohortId] on creation
1221
+ autoCreate?: boolean, // school groups only — recurrence on
1222
+ createMMDD?: string, // school groups only — "08-15" annual creation trigger
1223
+ autoAddFilter?: { // school groups only; copied to cohortFilters[newCohortId] on creation
1212
1224
  k: string;
1213
1225
  e?: WhereFilterOp;
1214
1226
  v: unknown;
@@ -1216,6 +1228,9 @@ export type InstituteData = {
1216
1228
  feedback?: CohortData["feedback"], // promoted feedback form config
1217
1229
  feedbackText?: CohortData["feedbackText"],
1218
1230
  emailParentOnStatusChange?: boolean,
1231
+ /** Trust templates only — schools must apply this template to every new
1232
+ * work experience cohort. Optional templates are offered as a choice. */
1233
+ mandatory?: boolean,
1219
1234
  }
1220
1235
  },
1221
1236
  cs?: CustomerSuccessData,
package/src/workflows.ts CHANGED
@@ -6,27 +6,18 @@ import {CohortData, InstituteData, WorkflowConfig, WorkflowEditPolicy, WorkflowO
6
6
  * school (or a trust, on behalf of its schools) defines a workflow once and every cohort
7
7
  * that uses it points at it with CohortData.workflowId.
8
8
  *
9
- * A trust can mark its workflow mandatory, in which case schools beneath it get that
10
- * workflowId applied to new cohorts. Schools may still layer per-stage forms/files on top
11
- * (subject to the workflow's editPolicy); those deltas are stored as WorkflowOverrides on
12
- * the cohort, and mirrored onto the recurring group template so they carry into next year.
9
+ * A trust requires a workflow by putting it on a cohort template it marks mandatory
10
+ * (InstituteData.cohortConfig) — the workflow document itself says nothing about who
11
+ * must use it. Schools may still layer per-stage forms/files on top (subject to the
12
+ * workflow's editPolicy); those deltas are stored as WorkflowOverrides on the cohort,
13
+ * and mirrored onto the recurring group template so they carry into next year.
13
14
  */
14
15
 
15
16
  /** The trust a school belongs to, if any. */
16
17
  export const approvedTrustId = (institute?: Pick<InstituteData, "trustLinks">) =>
17
18
  Object.entries(institute?.trustLinks || {}).find(([, l]) => l.status === "approved")?.[0];
18
19
 
19
- /** The trust-authored workflow a school is obliged to use, if the trust set one. */
20
- export const mandatoryTrustWorkflow = (
21
- workflows?: {[id: string]: WorkflowConfig},
22
- trustId?: string,
23
- ): [string, WorkflowConfig]|undefined => {
24
- if (!trustId) return undefined;
25
- return Object.entries(workflows || {})
26
- .find(([, w]) => w.trust && w.mandatory && w.oId === trustId);
27
- };
28
-
29
- /** Trust workflows visible to a school, mandatory or not. */
20
+ /** Trust workflows visible to a school. */
30
21
  export const trustWorkflows = (workflows?: {[id: string]: WorkflowConfig}, trustId?: string) =>
31
22
  Object.entries(workflows || {}).filter(([, w]) => w.trust && (!trustId || w.oId === trustId));
32
23