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 +1 -0
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/pricing.d.ts +137 -0
- package/lib/pricing.js +156 -0
- package/lib/pricing.js.map +1 -0
- package/lib/pricing.test.d.ts +1 -0
- package/lib/pricing.test.js +119 -0
- package/lib/pricing.test.js.map +1 -0
- package/lib/typeDefinitions.d.ts +24 -8
- package/lib/workflows.d.ts +6 -9
- package/lib/workflows.js +7 -14
- package/lib/workflows.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/pricing.test.ts +147 -0
- package/src/pricing.ts +256 -0
- package/src/typeDefinitions.ts +25 -10
- package/src/workflows.ts +6 -15
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"}
|
package/lib/pricing.d.ts
ADDED
|
@@ -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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"}
|
package/lib/typeDefinitions.d.ts
CHANGED
|
@@ -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
|
|
783
|
-
* WorkflowConfig.
|
|
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
|
|
1188
|
+
placementType?: "defined" | "continuous";
|
|
1176
1189
|
workflowId?: string;
|
|
1177
1190
|
workflowOverrides?: WorkflowOverrides;
|
|
1178
|
-
autoCreate
|
|
1179
|
-
createMMDD
|
|
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;
|
package/lib/workflows.d.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
/**
|
|
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");
|
package/lib/workflows.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workflows.js","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"workflows.js","sourceRoot":"","sources":["../src/workflows.ts"],"names":[],"mappings":";;;;;;AA2BA,wDAWC;AAGD,sDAgBC;AASD,8CAMC;AAGD,oEAQC;AAMD,sDAQC;AAID,kDAQC;AA7GD,6EAAqD;AAGrD;;;;;;;;;;GAUG;AAEH,6CAA6C;AACtC,MAAM,eAAe,GAAG,CAAC,SAA6C,EAAE,EAAE,CAC7E,MAAM,CAAC,OAAO,CAAC,SAAS,EAAE,UAAU,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AADjF,QAAA,eAAe,mBACkE;AAE9F,2CAA2C;AACpC,MAAM,cAAc,GAAG,CAAC,SAA0C,EAAE,OAAgB,EAAE,EAAE,CAC3F,MAAM,CAAC,OAAO,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,GAAG,KAAK,OAAO,CAAC,CAAC,CAAC;AADrF,QAAA,cAAc,kBACuE;AAE3F,MAAM,kBAAkB,GAAG,CAAC,QAAyB,EAAsB,EAAE,CAChF,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,IAAI,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;AADlD,QAAA,kBAAkB,sBACgC;AAE/D,0EAA0E;AAC1E,SAAgB,sBAAsB,CAAC,QAAyB,EAAE,SAA6B;IAC3F,IAAI,CAAC,SAAS,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,MAAM;QAAE,OAAO,QAAQ,CAAC;IAClE,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QAC1B,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;QAC7C,IAAI,CAAC,QAAQ;YAAE,OAAO,KAAK,CAAC;QAC5B,OAAO;YACH,GAAG,KAAK;YACR,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAC,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAClD,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAC,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAC,CAAC,CAAC,CAAC,EAAE,CAAC;SACrD,CAAC;IACN,CAAC,CAAC,CAAC;AACP,CAAC;AAED,gGAAgG;AAChG,SAAgB,qBAAqB,CAAC,IAAqB,EAAE,MAAuB;IAChF,MAAM,QAAQ,GAAG,CAAC,CAAY,EAAE,CAAY,EAAE,EAAE,CAC5C,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;IAE3F,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;QAChC,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,KAAK,CAAC,EAAE,CAAC,CAAC;QACtD,IAAI,CAAC,SAAS;YAAE,OAAO,GAAG,CAAC;QAC3B,MAAM,YAAY,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;QAC7D,MAAM,YAAY,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;QAC7D,IAAI,CAAC,YAAY,IAAI,CAAC,YAAY;YAAE,OAAO,GAAG,CAAC;QAC/C,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,GAAG;YACpB,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,EAAC,KAAK,EAAE,KAAK,CAAC,KAAK,IAAI,EAAE,EAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACnD,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,EAAC,KAAK,EAAE,KAAK,CAAC,KAAK,IAAI,EAAE,EAAC,CAAC,CAAC,CAAC,EAAE,CAAC;SACtD,CAAC;QACF,OAAO,GAAG,CAAC;IACf,CAAC,EAAE,EAAuB,CAAC,CAAC;AAChC,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,iBAAiB,CAAC,IAAwB,EAAE,QAA4B,EAAE,MAA0B;IAChH,MAAM,QAAQ,GAAG,IAAI,IAAI,EAAE,CAAC;IAC5B,MAAM,YAAY,GAAG,QAAQ,IAAI,EAAE,CAAC;IACpC,IAAI,MAAM,KAAK,QAAQ;QAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,CAAC;IAC9C,IAAI,MAAM,KAAK,KAAK;QAAE,OAAO,CAAC,GAAG,QAAQ,EAAE,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACjG,OAAO,CAAC,GAAG,YAAY,CAAC,CAAC;AAC7B,CAAC;AAED,sFAAsF;AACtF,SAAgB,4BAA4B,CAAC,QAAyB;IAClE,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,QAAQ,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE;QACvB,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACtD,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;IACH,OAAO,EAAC,KAAK,EAAE,CAAC,GAAG,KAAK,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,KAAK,CAAC,EAAC,CAAC;AAClD,CAAC;AAED;;;GAGG;AACH,SAAgB,qBAAqB,CACjC,MAAoE,EACpE,SAA0C;IAE1C,IAAI,CAAC,MAAM,EAAE,UAAU;QAAE,OAAO,EAAE,CAAC;IACnC,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC9C,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,CAAC;IACvB,OAAO,sBAAsB,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,EAAE,MAAM,CAAC,iBAAiB,CAAC,CAAC;AACnF,CAAC;AAED;oFACoF;AAC7E,KAAK,UAAU,mBAAmB,CACrC,MAAoE,EACpE,aAAa,GAAG,IAAI,uBAAa,EAAE;IAEnC,IAAI,CAAC,MAAM,EAAE,UAAU;QAAE,OAAO,EAAE,CAAC;IACnC,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAA6B,CAAC;IACnI,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM;QAAE,OAAO,EAAE,CAAC;IACzC,OAAO,sBAAsB,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,iBAAiB,CAAC,CAAC;AAC7E,CAAC;AAED,yDAAyD;AAClD,MAAM,wBAAwB,GAAG,CACpC,MAAgD,EAChD,SAA0C,EAC5C,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,UAAU,IAAI,SAAS,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;AAHxE,QAAA,wBAAwB,4BAGgD"}
|
package/package.json
CHANGED
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
|
+
}
|
package/src/typeDefinitions.ts
CHANGED
|
@@ -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
|
|
785
|
-
* WorkflowConfig.
|
|
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
|
-
|
|
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
|
|
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
|
|
1210
|
-
createMMDD
|
|
1211
|
-
autoAddFilter?: { //
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
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
|
|