placementt-core 1.400.1052 → 1.400.1053
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/pricing.d.ts +130 -1
- package/lib/pricing.js +130 -3
- package/lib/pricing.js.map +1 -1
- package/lib/pricing.test.js +78 -0
- package/lib/pricing.test.js.map +1 -1
- package/lib/typeDefinitions.d.ts +5 -0
- package/package.json +1 -1
- package/src/pricing.test.ts +101 -1
- package/src/pricing.ts +244 -4
- package/src/typeDefinitions.ts +5 -0
package/lib/pricing.d.ts
CHANGED
|
@@ -68,8 +68,14 @@ export type SchoolPricingInput = {
|
|
|
68
68
|
destinationsDiscount?: boolean;
|
|
69
69
|
/** Discount the whole quote, for a school taking the full platform. */
|
|
70
70
|
fullPlatformDiscount?: boolean;
|
|
71
|
+
/** A negotiated discount off the whole quote, as a fraction (0.1 = 10% off).
|
|
72
|
+
* Taken after the full-platform discount and before the minimum fee. */
|
|
73
|
+
bespokeDiscount?: number;
|
|
74
|
+
/** A price agreed with the customer, which replaces everything above it —
|
|
75
|
+
* the discounts, the minimum fee and the total the products came to. */
|
|
76
|
+
quoteOverride?: number;
|
|
71
77
|
};
|
|
72
|
-
export type PricingLineKey = "base" | "wexY10" | "wexY12" | "events" | "destinations";
|
|
78
|
+
export type PricingLineKey = "base" | "wexY10" | "wexY12" | "events" | "destinations" | "hubBase" | "hubSchools";
|
|
73
79
|
export type PricingLine = {
|
|
74
80
|
key: PricingLineKey;
|
|
75
81
|
label: string;
|
|
@@ -95,8 +101,12 @@ export type SchoolPricingResult = {
|
|
|
95
101
|
subtotal: number;
|
|
96
102
|
/** £ taken off the subtotal by the full-platform discount. */
|
|
97
103
|
fullPlatformDiscountApplied: number;
|
|
104
|
+
/** £ taken off by the negotiated discount, after the full-platform one. */
|
|
105
|
+
bespokeDiscountApplied: number;
|
|
98
106
|
/** True when the minimum fee, not the products, set the price. */
|
|
99
107
|
minFeeApplied: boolean;
|
|
108
|
+
/** True when an agreed price replaced the calculated one. */
|
|
109
|
+
overrideApplied: boolean;
|
|
100
110
|
total: number;
|
|
101
111
|
};
|
|
102
112
|
export type HubPricingResult = {
|
|
@@ -135,3 +145,122 @@ export declare function calculateHubPrice(schools: number, config?: PricingConfi
|
|
|
135
145
|
* @return {object} a complete config, safe to price with.
|
|
136
146
|
*/
|
|
137
147
|
export declare function mergePricingConfig(stored?: Partial<PricingConfig>): PricingConfig;
|
|
148
|
+
/** The terms agreed with one customer. Stored at `institutes/{oId}.pricing`. */
|
|
149
|
+
export type InstitutePricing = {
|
|
150
|
+
/** The roll we quoted on. Defaults to the institute's own student count. */
|
|
151
|
+
students?: number;
|
|
152
|
+
/** Y10 placements quoted. Undefined means the year group isn't on work experience. */
|
|
153
|
+
wexY10?: number;
|
|
154
|
+
/** Y12 placements quoted. Undefined means the year group isn't on work experience. */
|
|
155
|
+
wexY12?: number;
|
|
156
|
+
/** Take the partial-product discount off the events fee. */
|
|
157
|
+
eventsDiscount?: boolean;
|
|
158
|
+
/** Take the partial-product discount off the destinations fee. */
|
|
159
|
+
destinationsDiscount?: boolean;
|
|
160
|
+
/** Take the full-platform discount off the whole quote. */
|
|
161
|
+
fullPlatformDiscount?: boolean;
|
|
162
|
+
/** A negotiated discount off the whole quote, as a fraction (0.1 = 10% off). */
|
|
163
|
+
bespokeDiscount?: number;
|
|
164
|
+
/** Why the negotiated discount or the agreed price was given. Admin-only. */
|
|
165
|
+
discountReason?: string;
|
|
166
|
+
/** A price agreed with the customer, which replaces the calculated one. */
|
|
167
|
+
quoteOverride?: number;
|
|
168
|
+
/** Schools on the hub. Hub accounts only. */
|
|
169
|
+
hubSchools?: number;
|
|
170
|
+
/** The quote as last saved, for the customer to see. */
|
|
171
|
+
quote?: InstituteQuoteSnapshot;
|
|
172
|
+
};
|
|
173
|
+
/** A priced institute: the same shape for a school, a trust and a hub. */
|
|
174
|
+
export type InstituteQuote = {
|
|
175
|
+
kind: "school" | "hub";
|
|
176
|
+
/** Every fee charged, with the working behind it. */
|
|
177
|
+
lines: PricingLine[];
|
|
178
|
+
/** The lines added up, before any whole-quote discount. */
|
|
179
|
+
subtotal: number;
|
|
180
|
+
fullPlatformDiscountApplied: number;
|
|
181
|
+
bespokeDiscountApplied: number;
|
|
182
|
+
minFeeApplied: boolean;
|
|
183
|
+
overrideApplied: boolean;
|
|
184
|
+
total: number;
|
|
185
|
+
};
|
|
186
|
+
/** What the customer is shown: the total, and what makes it up. No rates. */
|
|
187
|
+
export type InstituteQuoteSnapshot = {
|
|
188
|
+
lines: {
|
|
189
|
+
key: PricingLineKey;
|
|
190
|
+
label: string;
|
|
191
|
+
amount: number;
|
|
192
|
+
}[];
|
|
193
|
+
/** The lines added up. */
|
|
194
|
+
subtotal: number;
|
|
195
|
+
/** Everything taken off the lines to reach the total, as one figure. */
|
|
196
|
+
discountTotal: number;
|
|
197
|
+
/** True when the total is a floor or an agreed price above what the lines
|
|
198
|
+
* come to, so the breakdown is shown as an illustration, not a sum. */
|
|
199
|
+
minimum: boolean;
|
|
200
|
+
total: number;
|
|
201
|
+
/** ISO date the quote was last worked out. */
|
|
202
|
+
updated: string;
|
|
203
|
+
};
|
|
204
|
+
/** What the quote needs to know about the institute being priced. */
|
|
205
|
+
export type InstituteQuoteInput = {
|
|
206
|
+
/** What the institute has, as resolved by `resolveProducts`. */
|
|
207
|
+
products: {
|
|
208
|
+
wex?: boolean;
|
|
209
|
+
events?: boolean;
|
|
210
|
+
destinations?: boolean;
|
|
211
|
+
isHub?: boolean;
|
|
212
|
+
};
|
|
213
|
+
/** The institute's roll, when the agreed terms don't name one. */
|
|
214
|
+
students?: number;
|
|
215
|
+
/** Schools on the hub, when the agreed terms don't name a number. */
|
|
216
|
+
hubSchools?: number;
|
|
217
|
+
/** The terms agreed with this customer. */
|
|
218
|
+
pricing?: InstitutePricing;
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Price a school and present it as an institute quote.
|
|
222
|
+
*
|
|
223
|
+
* @param {object} input the roll, the year groups, the products and the terms.
|
|
224
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
225
|
+
* @return {object} the quote, ready to render.
|
|
226
|
+
*/
|
|
227
|
+
export declare function schoolQuote(input: SchoolPricingInput, config?: PricingConfig): InstituteQuote;
|
|
228
|
+
/**
|
|
229
|
+
* Price a careers hub as an institute quote: the platform fee, then the schools.
|
|
230
|
+
*
|
|
231
|
+
* Hubs have no products to discount individually and no minimum fee to fall back
|
|
232
|
+
* on, so the only terms that reach them are the negotiated discount and an
|
|
233
|
+
* agreed price.
|
|
234
|
+
*
|
|
235
|
+
* @param {number} schools how many schools the hub brings on.
|
|
236
|
+
* @param {object} [pricing] the terms agreed with this hub.
|
|
237
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
238
|
+
* @return {object} the quote, ready to render.
|
|
239
|
+
*/
|
|
240
|
+
export declare function hubQuote(schools: number, pricing?: InstitutePricing, config?: PricingConfig): InstituteQuote;
|
|
241
|
+
/**
|
|
242
|
+
* Price an institute on what it actually has and what was agreed with it.
|
|
243
|
+
*
|
|
244
|
+
* The products come from `resolveProducts` rather than the raw flags, so a trust
|
|
245
|
+
* is priced on the union of its schools' products the same way it is gated on
|
|
246
|
+
* them. A year group with no placements agreed is a year group not on work
|
|
247
|
+
* experience — except where the institute has work experience and neither year
|
|
248
|
+
* group has been filled in, which is a quote waiting to be finished rather than
|
|
249
|
+
* a school not taking the product, so Y10 is charged at zero to say so.
|
|
250
|
+
*
|
|
251
|
+
* @param {object} input the institute's products, roll and agreed terms.
|
|
252
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
253
|
+
* @return {object} the quote, ready to render.
|
|
254
|
+
*/
|
|
255
|
+
export declare function instituteQuote(input: InstituteQuoteInput, config?: PricingConfig): InstituteQuote;
|
|
256
|
+
/**
|
|
257
|
+
* Reduce a quote to what the customer sees: the fees, and one discount figure.
|
|
258
|
+
*
|
|
259
|
+
* The base fee isn't itemised to customers, so it is folded into the first fee
|
|
260
|
+
* they are shown — and where there is nothing to fold it into, it stands alone
|
|
261
|
+
* as the platform fee it is.
|
|
262
|
+
*
|
|
263
|
+
* @param {object} quote the priced institute.
|
|
264
|
+
* @return {object} the snapshot to store on the institute.
|
|
265
|
+
*/
|
|
266
|
+
export declare function quoteSnapshot(quote: InstituteQuote): InstituteQuoteSnapshot;
|
package/lib/pricing.js
CHANGED
|
@@ -22,6 +22,10 @@ exports.PRICING_CONFIG_PATH = exports.DEFAULT_PRICING_CONFIG = void 0;
|
|
|
22
22
|
exports.calculateSchoolPrice = calculateSchoolPrice;
|
|
23
23
|
exports.calculateHubPrice = calculateHubPrice;
|
|
24
24
|
exports.mergePricingConfig = mergePricingConfig;
|
|
25
|
+
exports.schoolQuote = schoolQuote;
|
|
26
|
+
exports.hubQuote = hubQuote;
|
|
27
|
+
exports.instituteQuote = instituteQuote;
|
|
28
|
+
exports.quoteSnapshot = quoteSnapshot;
|
|
25
29
|
/** The pricing sheet's figures, as agreed for 2026. */
|
|
26
30
|
exports.DEFAULT_PRICING_CONFIG = {
|
|
27
31
|
minFee: 500,
|
|
@@ -73,6 +77,7 @@ function graduated(quantity, bands) {
|
|
|
73
77
|
return lines;
|
|
74
78
|
}
|
|
75
79
|
const sum = (values) => values.reduce((total, value) => total + value, 0);
|
|
80
|
+
const clampFraction = (value) => Math.min(Math.max(value || 0, 0), 1);
|
|
76
81
|
const bandsTotal = (bands) => sum(bands.map((b) => b.amount));
|
|
77
82
|
/**
|
|
78
83
|
* Price a school, trust or hub-school quote.
|
|
@@ -110,13 +115,19 @@ function calculateSchoolPrice(input, config = exports.DEFAULT_PRICING_CONFIG) {
|
|
|
110
115
|
addPerStudent("destinations", "Destinations", input.destinations, input.destinationsDiscount);
|
|
111
116
|
const subtotal = sum(lines.map((l) => l.amount));
|
|
112
117
|
const discounted = input.fullPlatformDiscount ? subtotal * (1 - config.fullPlatformDiscount) : subtotal;
|
|
113
|
-
const
|
|
118
|
+
const negotiated = discounted * (1 - clampFraction(input.bespokeDiscount));
|
|
119
|
+
const floored = Math.max(negotiated, config.minFee);
|
|
120
|
+
const overridden = input.quoteOverride !== undefined && input.quoteOverride >= 0;
|
|
114
121
|
return {
|
|
115
122
|
lines,
|
|
116
123
|
subtotal,
|
|
117
124
|
fullPlatformDiscountApplied: subtotal - discounted,
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
bespokeDiscountApplied: discounted - negotiated,
|
|
126
|
+
// An agreed price is the price, so neither the floor nor the discounts
|
|
127
|
+
// are what set it — they are only reported when the calculation stands.
|
|
128
|
+
minFeeApplied: !overridden && floored > negotiated,
|
|
129
|
+
overrideApplied: overridden,
|
|
130
|
+
total: overridden ? input.quoteOverride : floored,
|
|
120
131
|
};
|
|
121
132
|
}
|
|
122
133
|
/**
|
|
@@ -153,4 +164,120 @@ function mergePricingConfig(stored) {
|
|
|
153
164
|
hub: { ...d.hub, ...stored.hub },
|
|
154
165
|
};
|
|
155
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Price a school and present it as an institute quote.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} input the roll, the year groups, the products and the terms.
|
|
171
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
172
|
+
* @return {object} the quote, ready to render.
|
|
173
|
+
*/
|
|
174
|
+
function schoolQuote(input, config = exports.DEFAULT_PRICING_CONFIG) {
|
|
175
|
+
return { kind: "school", ...calculateSchoolPrice(input, config) };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Price a careers hub as an institute quote: the platform fee, then the schools.
|
|
179
|
+
*
|
|
180
|
+
* Hubs have no products to discount individually and no minimum fee to fall back
|
|
181
|
+
* on, so the only terms that reach them are the negotiated discount and an
|
|
182
|
+
* agreed price.
|
|
183
|
+
*
|
|
184
|
+
* @param {number} schools how many schools the hub brings on.
|
|
185
|
+
* @param {object} [pricing] the terms agreed with this hub.
|
|
186
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
187
|
+
* @return {object} the quote, ready to render.
|
|
188
|
+
*/
|
|
189
|
+
function hubQuote(schools, pricing, config = exports.DEFAULT_PRICING_CONFIG) {
|
|
190
|
+
const count = Math.max(schools || 0, 0);
|
|
191
|
+
const hub = calculateHubPrice(count, config);
|
|
192
|
+
const lines = [
|
|
193
|
+
{ key: "hubBase", label: "Platform fee", amount: hub.baseFee, bands: [] },
|
|
194
|
+
{
|
|
195
|
+
key: "hubSchools",
|
|
196
|
+
label: "School fees",
|
|
197
|
+
amount: hub.schoolFees,
|
|
198
|
+
bands: count > 0 ?
|
|
199
|
+
[{ tier: 1, quantity: count, rate: config.hub.perSchoolFee, amount: hub.schoolFees }] :
|
|
200
|
+
[],
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
const negotiated = hub.total * (1 - clampFraction(pricing?.bespokeDiscount));
|
|
204
|
+
const overridden = pricing?.quoteOverride !== undefined && pricing.quoteOverride >= 0;
|
|
205
|
+
return {
|
|
206
|
+
kind: "hub",
|
|
207
|
+
lines,
|
|
208
|
+
subtotal: hub.total,
|
|
209
|
+
fullPlatformDiscountApplied: 0,
|
|
210
|
+
bespokeDiscountApplied: hub.total - negotiated,
|
|
211
|
+
minFeeApplied: false,
|
|
212
|
+
overrideApplied: overridden,
|
|
213
|
+
total: overridden ? pricing?.quoteOverride : negotiated,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Price an institute on what it actually has and what was agreed with it.
|
|
218
|
+
*
|
|
219
|
+
* The products come from `resolveProducts` rather than the raw flags, so a trust
|
|
220
|
+
* is priced on the union of its schools' products the same way it is gated on
|
|
221
|
+
* them. A year group with no placements agreed is a year group not on work
|
|
222
|
+
* experience — except where the institute has work experience and neither year
|
|
223
|
+
* group has been filled in, which is a quote waiting to be finished rather than
|
|
224
|
+
* a school not taking the product, so Y10 is charged at zero to say so.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} input the institute's products, roll and agreed terms.
|
|
227
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
228
|
+
* @return {object} the quote, ready to render.
|
|
229
|
+
*/
|
|
230
|
+
function instituteQuote(input, config = exports.DEFAULT_PRICING_CONFIG) {
|
|
231
|
+
const pricing = input.pricing;
|
|
232
|
+
if (input.products.isHub) {
|
|
233
|
+
return hubQuote(pricing?.hubSchools ?? input.hubSchools ?? 0, pricing, config);
|
|
234
|
+
}
|
|
235
|
+
const wex = Boolean(input.products.wex);
|
|
236
|
+
const noYearGroupQuoted = pricing?.wexY10 === undefined && pricing?.wexY12 === undefined;
|
|
237
|
+
return schoolQuote({
|
|
238
|
+
students: pricing?.students ?? input.students,
|
|
239
|
+
wexY10: wex ? (noYearGroupQuoted ? 0 : pricing?.wexY10) : undefined,
|
|
240
|
+
wexY12: wex ? pricing?.wexY12 : undefined,
|
|
241
|
+
events: Boolean(input.products.events),
|
|
242
|
+
eventsDiscount: pricing?.eventsDiscount,
|
|
243
|
+
destinations: Boolean(input.products.destinations),
|
|
244
|
+
destinationsDiscount: pricing?.destinationsDiscount,
|
|
245
|
+
fullPlatformDiscount: pricing?.fullPlatformDiscount,
|
|
246
|
+
bespokeDiscount: pricing?.bespokeDiscount,
|
|
247
|
+
quoteOverride: pricing?.quoteOverride,
|
|
248
|
+
}, config);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Reduce a quote to what the customer sees: the fees, and one discount figure.
|
|
252
|
+
*
|
|
253
|
+
* The base fee isn't itemised to customers, so it is folded into the first fee
|
|
254
|
+
* they are shown — and where there is nothing to fold it into, it stands alone
|
|
255
|
+
* as the platform fee it is.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} quote the priced institute.
|
|
258
|
+
* @return {object} the snapshot to store on the institute.
|
|
259
|
+
*/
|
|
260
|
+
function quoteSnapshot(quote) {
|
|
261
|
+
const itemised = quote.lines.filter((l) => l.key !== "base");
|
|
262
|
+
const base = quote.lines.find((l) => l.key === "base")?.amount || 0;
|
|
263
|
+
const lines = itemised.length ?
|
|
264
|
+
itemised.map((line, index) => ({
|
|
265
|
+
key: line.key,
|
|
266
|
+
label: line.label,
|
|
267
|
+
amount: index === 0 ? line.amount + base : line.amount,
|
|
268
|
+
})) :
|
|
269
|
+
[{ key: "base", label: "Platform fee", amount: base }];
|
|
270
|
+
const subtotal = sum(lines.map((l) => l.amount));
|
|
271
|
+
return {
|
|
272
|
+
lines,
|
|
273
|
+
subtotal,
|
|
274
|
+
// The customer is shown the fees and one discount, so whatever separates
|
|
275
|
+
// the two — the whole-quote discounts, an agreed price below them — is
|
|
276
|
+
// that one figure, and a total above the fees is flagged instead.
|
|
277
|
+
discountTotal: Math.max(subtotal - quote.total, 0),
|
|
278
|
+
minimum: quote.total > subtotal,
|
|
279
|
+
total: quote.total,
|
|
280
|
+
updated: new Date().toISOString().split("T")[0],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
156
283
|
//# sourceMappingURL=pricing.js.map
|
package/lib/pricing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pricing.js","sourceRoot":"","sources":["../src/pricing.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;GAiBG;;;
|
|
1
|
+
{"version":3,"file":"pricing.js","sourceRoot":"","sources":["../src/pricing.ts"],"names":[],"mappings":"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"}
|
package/lib/pricing.test.js
CHANGED
|
@@ -116,4 +116,82 @@ describe("mergePricingConfig", () => {
|
|
|
116
116
|
expect(merged.tiers[2]).toEqual(pricing_1.DEFAULT_PRICING_CONFIG.tiers[2]);
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
describe("the negotiated terms", () => {
|
|
120
|
+
it("takes the bespoke discount after the full-platform one", () => {
|
|
121
|
+
const base = { students: 2000, events: true, destinations: true, fullPlatformDiscount: true };
|
|
122
|
+
const plain = (0, pricing_1.calculateSchoolPrice)(base);
|
|
123
|
+
const cut = (0, pricing_1.calculateSchoolPrice)({ ...base, bespokeDiscount: 0.1 });
|
|
124
|
+
expect(cut.total).toBeCloseTo(plain.total * 0.9, 6);
|
|
125
|
+
expect(cut.bespokeDiscountApplied).toBeCloseTo(plain.total * 0.1, 6);
|
|
126
|
+
});
|
|
127
|
+
it("still holds the minimum fee against a bespoke discount", () => {
|
|
128
|
+
const result = (0, pricing_1.calculateSchoolPrice)({ students: 900, events: true, bespokeDiscount: 0.9 });
|
|
129
|
+
expect(result.total).toBe(pricing_1.DEFAULT_PRICING_CONFIG.minFee);
|
|
130
|
+
expect(result.minFeeApplied).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
it("lets an agreed price replace the calculation, floor and all", () => {
|
|
133
|
+
const result = (0, pricing_1.calculateSchoolPrice)({ students: 2000, events: true, quoteOverride: 100 });
|
|
134
|
+
expect(result.total).toBe(100);
|
|
135
|
+
expect(result.overrideApplied).toBe(true);
|
|
136
|
+
// The floor didn't set the price, so it isn't reported as having done.
|
|
137
|
+
expect(result.minFeeApplied).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe("instituteQuote", () => {
|
|
141
|
+
const products = (over = {}) => ({ wex: false, events: false, destinations: false, isHub: false, ...over });
|
|
142
|
+
it("prices a school on what it has, at the roll agreed with it", () => {
|
|
143
|
+
const quote = (0, pricing_1.instituteQuote)({
|
|
144
|
+
products: products({ events: true }),
|
|
145
|
+
students: 3000,
|
|
146
|
+
pricing: { students: 1250, eventsDiscount: true },
|
|
147
|
+
});
|
|
148
|
+
expect(quote.kind).toBe("school");
|
|
149
|
+
expect(quote.total).toBeCloseTo(830, 2);
|
|
150
|
+
});
|
|
151
|
+
it("charges a work experience school nothing until its year groups are quoted", () => {
|
|
152
|
+
const quote = (0, pricing_1.instituteQuote)({ products: products({ wex: true }), students: 1000 });
|
|
153
|
+
expect(quote.lines.map((l) => l.key)).toEqual(["base", "wexY10"]);
|
|
154
|
+
expect(quote.lines.find((l) => l.key === "wexY10")?.amount).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
it("leaves out a year group once the other one is quoted", () => {
|
|
157
|
+
const quote = (0, pricing_1.instituteQuote)({ products: products({ wex: true }), pricing: { wexY12: 100 } });
|
|
158
|
+
expect(quote.lines.map((l) => l.key)).toEqual(["base", "wexY12"]);
|
|
159
|
+
});
|
|
160
|
+
it("prices a hub on its schools, and honours an agreed price", () => {
|
|
161
|
+
const quote = (0, pricing_1.instituteQuote)({ products: products({ isHub: true }), pricing: { hubSchools: 50 } });
|
|
162
|
+
expect(quote.kind).toBe("hub");
|
|
163
|
+
expect(quote.total).toBe(9500);
|
|
164
|
+
expect((0, pricing_1.instituteQuote)({
|
|
165
|
+
products: products({ isHub: true }),
|
|
166
|
+
pricing: { hubSchools: 50, quoteOverride: 8000 },
|
|
167
|
+
}).total).toBe(8000);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe("quoteSnapshot", () => {
|
|
171
|
+
it("folds the base fee into the first fee the customer is shown", () => {
|
|
172
|
+
const snapshot = (0, pricing_1.quoteSnapshot)((0, pricing_1.instituteQuote)({
|
|
173
|
+
products: { events: true },
|
|
174
|
+
pricing: { students: 2500 },
|
|
175
|
+
}));
|
|
176
|
+
// 1,100 of events plus the 250 base, shown as one events line.
|
|
177
|
+
expect(snapshot.lines).toEqual([{ key: "events", label: "Employer events", amount: 1350 }]);
|
|
178
|
+
expect(snapshot.total).toBe(1350);
|
|
179
|
+
expect(snapshot.discountTotal).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
it("reports everything between the fees and the total as one discount", () => {
|
|
182
|
+
const quote = (0, pricing_1.instituteQuote)({
|
|
183
|
+
products: { events: true, destinations: true },
|
|
184
|
+
pricing: { students: 2000, fullPlatformDiscount: true, bespokeDiscount: 0.1 },
|
|
185
|
+
});
|
|
186
|
+
const snapshot = (0, pricing_1.quoteSnapshot)(quote);
|
|
187
|
+
expect(snapshot.subtotal - snapshot.discountTotal).toBeCloseTo(snapshot.total, 6);
|
|
188
|
+
expect(snapshot.minimum).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
it("flags a total the fees don't add up to, rather than inventing a discount", () => {
|
|
191
|
+
const snapshot = (0, pricing_1.quoteSnapshot)((0, pricing_1.instituteQuote)({ products: { events: true }, pricing: { students: 90 } }));
|
|
192
|
+
expect(snapshot.total).toBe(pricing_1.DEFAULT_PRICING_CONFIG.minFee);
|
|
193
|
+
expect(snapshot.minimum).toBe(true);
|
|
194
|
+
expect(snapshot.discountTotal).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
119
197
|
//# sourceMappingURL=pricing.test.js.map
|
package/lib/pricing.test.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pricing.test.js","sourceRoot":"","sources":["../src/pricing.test.ts"],"names":[],"mappings":"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"}
|
|
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
|
@@ -3,6 +3,7 @@ import { Descendant } from "slate";
|
|
|
3
3
|
import { emailTemplates } from "./constants";
|
|
4
4
|
import type { ChecklistItem, GiftHistory } from "./customerCareConfig";
|
|
5
5
|
import type { SurveyGroupSetupMap, SurveyKind, SurveySlot } from "./surveys/types";
|
|
6
|
+
import type { InstitutePricing } from "./pricing";
|
|
6
7
|
export type FilterObject = {
|
|
7
8
|
[key: string]: {
|
|
8
9
|
label: string;
|
|
@@ -1055,6 +1056,10 @@ export type InstituteData = {
|
|
|
1055
1056
|
blockAllEmails?: boolean;
|
|
1056
1057
|
splitBilling?: boolean;
|
|
1057
1058
|
adminBillingContactId?: string;
|
|
1059
|
+
/** The terms agreed with this customer, and the quote they came to. Written
|
|
1060
|
+
* from the admin panel; the institute's own billing page reads the quote
|
|
1061
|
+
* snapshot inside it rather than pricing itself. See ./pricing. */
|
|
1062
|
+
pricing?: InstitutePricing;
|
|
1058
1063
|
employerEvents?: boolean;
|
|
1059
1064
|
workExperience?: boolean;
|
|
1060
1065
|
network?: boolean;
|
package/package.json
CHANGED
package/src/pricing.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {calculateHubPrice, calculateSchoolPrice, DEFAULT_PRICING_CONFIG, mergePricingConfig, SchoolPricingInput} from "./pricing";
|
|
1
|
+
import {calculateHubPrice, calculateSchoolPrice, DEFAULT_PRICING_CONFIG, InstituteQuoteInput, instituteQuote, mergePricingConfig, quoteSnapshot, SchoolPricingInput} from "./pricing";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The worked examples from the 2026 pricing sheet, kept here as the contract:
|
|
@@ -145,3 +145,103 @@ describe("mergePricingConfig", () => {
|
|
|
145
145
|
expect(merged.tiers[2]).toEqual(DEFAULT_PRICING_CONFIG.tiers[2]);
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
describe("the negotiated terms", () => {
|
|
150
|
+
it("takes the bespoke discount after the full-platform one", () => {
|
|
151
|
+
const base: SchoolPricingInput = {students: 2000, events: true, destinations: true, fullPlatformDiscount: true};
|
|
152
|
+
const plain = calculateSchoolPrice(base);
|
|
153
|
+
const cut = calculateSchoolPrice({...base, bespokeDiscount: 0.1});
|
|
154
|
+
|
|
155
|
+
expect(cut.total).toBeCloseTo(plain.total * 0.9, 6);
|
|
156
|
+
expect(cut.bespokeDiscountApplied).toBeCloseTo(plain.total * 0.1, 6);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("still holds the minimum fee against a bespoke discount", () => {
|
|
160
|
+
const result = calculateSchoolPrice({students: 900, events: true, bespokeDiscount: 0.9});
|
|
161
|
+
|
|
162
|
+
expect(result.total).toBe(DEFAULT_PRICING_CONFIG.minFee);
|
|
163
|
+
expect(result.minFeeApplied).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("lets an agreed price replace the calculation, floor and all", () => {
|
|
167
|
+
const result = calculateSchoolPrice({students: 2000, events: true, quoteOverride: 100});
|
|
168
|
+
|
|
169
|
+
expect(result.total).toBe(100);
|
|
170
|
+
expect(result.overrideApplied).toBe(true);
|
|
171
|
+
// The floor didn't set the price, so it isn't reported as having done.
|
|
172
|
+
expect(result.minFeeApplied).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("instituteQuote", () => {
|
|
177
|
+
const products = (over: Partial<InstituteQuoteInput["products"]> = {}) =>
|
|
178
|
+
({wex: false, events: false, destinations: false, isHub: false, ...over});
|
|
179
|
+
|
|
180
|
+
it("prices a school on what it has, at the roll agreed with it", () => {
|
|
181
|
+
const quote = instituteQuote({
|
|
182
|
+
products: products({events: true}),
|
|
183
|
+
students: 3000,
|
|
184
|
+
pricing: {students: 1250, eventsDiscount: true},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(quote.kind).toBe("school");
|
|
188
|
+
expect(quote.total).toBeCloseTo(830, 2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("charges a work experience school nothing until its year groups are quoted", () => {
|
|
192
|
+
const quote = instituteQuote({products: products({wex: true}), students: 1000});
|
|
193
|
+
|
|
194
|
+
expect(quote.lines.map((l) => l.key)).toEqual(["base", "wexY10"]);
|
|
195
|
+
expect(quote.lines.find((l) => l.key === "wexY10")?.amount).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("leaves out a year group once the other one is quoted", () => {
|
|
199
|
+
const quote = instituteQuote({products: products({wex: true}), pricing: {wexY12: 100}});
|
|
200
|
+
|
|
201
|
+
expect(quote.lines.map((l) => l.key)).toEqual(["base", "wexY12"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("prices a hub on its schools, and honours an agreed price", () => {
|
|
205
|
+
const quote = instituteQuote({products: products({isHub: true}), pricing: {hubSchools: 50}});
|
|
206
|
+
|
|
207
|
+
expect(quote.kind).toBe("hub");
|
|
208
|
+
expect(quote.total).toBe(9500);
|
|
209
|
+
expect(instituteQuote({
|
|
210
|
+
products: products({isHub: true}),
|
|
211
|
+
pricing: {hubSchools: 50, quoteOverride: 8000},
|
|
212
|
+
}).total).toBe(8000);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("quoteSnapshot", () => {
|
|
217
|
+
it("folds the base fee into the first fee the customer is shown", () => {
|
|
218
|
+
const snapshot = quoteSnapshot(instituteQuote({
|
|
219
|
+
products: {events: true},
|
|
220
|
+
pricing: {students: 2500},
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
// 1,100 of events plus the 250 base, shown as one events line.
|
|
224
|
+
expect(snapshot.lines).toEqual([{key: "events", label: "Employer events", amount: 1350}]);
|
|
225
|
+
expect(snapshot.total).toBe(1350);
|
|
226
|
+
expect(snapshot.discountTotal).toBe(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("reports everything between the fees and the total as one discount", () => {
|
|
230
|
+
const quote = instituteQuote({
|
|
231
|
+
products: {events: true, destinations: true},
|
|
232
|
+
pricing: {students: 2000, fullPlatformDiscount: true, bespokeDiscount: 0.1},
|
|
233
|
+
});
|
|
234
|
+
const snapshot = quoteSnapshot(quote);
|
|
235
|
+
|
|
236
|
+
expect(snapshot.subtotal - snapshot.discountTotal).toBeCloseTo(snapshot.total, 6);
|
|
237
|
+
expect(snapshot.minimum).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("flags a total the fees don't add up to, rather than inventing a discount", () => {
|
|
241
|
+
const snapshot = quoteSnapshot(instituteQuote({products: {events: true}, pricing: {students: 90}}));
|
|
242
|
+
|
|
243
|
+
expect(snapshot.total).toBe(DEFAULT_PRICING_CONFIG.minFee);
|
|
244
|
+
expect(snapshot.minimum).toBe(true);
|
|
245
|
+
expect(snapshot.discountTotal).toBe(0);
|
|
246
|
+
});
|
|
247
|
+
});
|
package/src/pricing.ts
CHANGED
|
@@ -78,9 +78,16 @@ export type SchoolPricingInput = {
|
|
|
78
78
|
destinationsDiscount?: boolean,
|
|
79
79
|
/** Discount the whole quote, for a school taking the full platform. */
|
|
80
80
|
fullPlatformDiscount?: boolean,
|
|
81
|
+
/** A negotiated discount off the whole quote, as a fraction (0.1 = 10% off).
|
|
82
|
+
* Taken after the full-platform discount and before the minimum fee. */
|
|
83
|
+
bespokeDiscount?: number,
|
|
84
|
+
/** A price agreed with the customer, which replaces everything above it —
|
|
85
|
+
* the discounts, the minimum fee and the total the products came to. */
|
|
86
|
+
quoteOverride?: number,
|
|
81
87
|
};
|
|
82
88
|
|
|
83
|
-
export type PricingLineKey =
|
|
89
|
+
export type PricingLineKey =
|
|
90
|
+
"base"|"wexY10"|"wexY12"|"events"|"destinations"|"hubBase"|"hubSchools";
|
|
84
91
|
|
|
85
92
|
export type PricingLine = {
|
|
86
93
|
key: PricingLineKey,
|
|
@@ -109,8 +116,12 @@ export type SchoolPricingResult = {
|
|
|
109
116
|
subtotal: number,
|
|
110
117
|
/** £ taken off the subtotal by the full-platform discount. */
|
|
111
118
|
fullPlatformDiscountApplied: number,
|
|
119
|
+
/** £ taken off by the negotiated discount, after the full-platform one. */
|
|
120
|
+
bespokeDiscountApplied: number,
|
|
112
121
|
/** True when the minimum fee, not the products, set the price. */
|
|
113
122
|
minFeeApplied: boolean,
|
|
123
|
+
/** True when an agreed price replaced the calculated one. */
|
|
124
|
+
overrideApplied: boolean,
|
|
114
125
|
total: number,
|
|
115
126
|
};
|
|
116
127
|
|
|
@@ -160,6 +171,8 @@ function graduated(quantity: number, bands: {limit: number, rate: number}[]): Pr
|
|
|
160
171
|
|
|
161
172
|
const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
|
|
162
173
|
|
|
174
|
+
const clampFraction = (value?: number) => Math.min(Math.max(value || 0, 0), 1);
|
|
175
|
+
|
|
163
176
|
const bandsTotal = (bands: PricingBand[]) => sum(bands.map((b) => b.amount));
|
|
164
177
|
|
|
165
178
|
/**
|
|
@@ -205,14 +218,20 @@ export function calculateSchoolPrice(
|
|
|
205
218
|
|
|
206
219
|
const subtotal = sum(lines.map((l) => l.amount));
|
|
207
220
|
const discounted = input.fullPlatformDiscount ? subtotal * (1 - config.fullPlatformDiscount) : subtotal;
|
|
208
|
-
const
|
|
221
|
+
const negotiated = discounted * (1 - clampFraction(input.bespokeDiscount));
|
|
222
|
+
const floored = Math.max(negotiated, config.minFee);
|
|
223
|
+
const overridden = input.quoteOverride !== undefined && input.quoteOverride >= 0;
|
|
209
224
|
|
|
210
225
|
return {
|
|
211
226
|
lines,
|
|
212
227
|
subtotal,
|
|
213
228
|
fullPlatformDiscountApplied: subtotal - discounted,
|
|
214
|
-
|
|
215
|
-
|
|
229
|
+
bespokeDiscountApplied: discounted - negotiated,
|
|
230
|
+
// An agreed price is the price, so neither the floor nor the discounts
|
|
231
|
+
// are what set it — they are only reported when the calculation stands.
|
|
232
|
+
minFeeApplied: !overridden && floored > negotiated,
|
|
233
|
+
overrideApplied: overridden,
|
|
234
|
+
total: overridden ? (input.quoteOverride as number) : floored,
|
|
216
235
|
};
|
|
217
236
|
}
|
|
218
237
|
|
|
@@ -254,3 +273,224 @@ export function mergePricingConfig(stored?: Partial<PricingConfig>): PricingConf
|
|
|
254
273
|
hub: {...d.hub, ...stored.hub},
|
|
255
274
|
};
|
|
256
275
|
}
|
|
276
|
+
|
|
277
|
+
/* -------------------------------------------------------------------------
|
|
278
|
+
* An institute's own quote.
|
|
279
|
+
*
|
|
280
|
+
* The calculator above prices a hypothetical school. What an actual customer
|
|
281
|
+
* pays is that calculation plus the terms agreed with them — the year groups
|
|
282
|
+
* and roll we quoted on, which discounts we gave, and any price we simply shook
|
|
283
|
+
* hands on — so those live on the institute, in `InstitutePricing`, and the
|
|
284
|
+
* quote is worked out from the two together.
|
|
285
|
+
*
|
|
286
|
+
* A school never reads the pricing configuration itself (it is admin data, and
|
|
287
|
+
* a school has no business seeing the tiers), so the agreed quote is snapshotted
|
|
288
|
+
* onto the institute as `InstitutePricing.quote` whenever an admin saves. That
|
|
289
|
+
* snapshot is what the school's own billing page shows.
|
|
290
|
+
* ---------------------------------------------------------------------- */
|
|
291
|
+
|
|
292
|
+
/** The terms agreed with one customer. Stored at `institutes/{oId}.pricing`. */
|
|
293
|
+
export type InstitutePricing = {
|
|
294
|
+
/** The roll we quoted on. Defaults to the institute's own student count. */
|
|
295
|
+
students?: number,
|
|
296
|
+
/** Y10 placements quoted. Undefined means the year group isn't on work experience. */
|
|
297
|
+
wexY10?: number,
|
|
298
|
+
/** Y12 placements quoted. Undefined means the year group isn't on work experience. */
|
|
299
|
+
wexY12?: number,
|
|
300
|
+
/** Take the partial-product discount off the events fee. */
|
|
301
|
+
eventsDiscount?: boolean,
|
|
302
|
+
/** Take the partial-product discount off the destinations fee. */
|
|
303
|
+
destinationsDiscount?: boolean,
|
|
304
|
+
/** Take the full-platform discount off the whole quote. */
|
|
305
|
+
fullPlatformDiscount?: boolean,
|
|
306
|
+
/** A negotiated discount off the whole quote, as a fraction (0.1 = 10% off). */
|
|
307
|
+
bespokeDiscount?: number,
|
|
308
|
+
/** Why the negotiated discount or the agreed price was given. Admin-only. */
|
|
309
|
+
discountReason?: string,
|
|
310
|
+
/** A price agreed with the customer, which replaces the calculated one. */
|
|
311
|
+
quoteOverride?: number,
|
|
312
|
+
/** Schools on the hub. Hub accounts only. */
|
|
313
|
+
hubSchools?: number,
|
|
314
|
+
/** The quote as last saved, for the customer to see. */
|
|
315
|
+
quote?: InstituteQuoteSnapshot,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** A priced institute: the same shape for a school, a trust and a hub. */
|
|
319
|
+
export type InstituteQuote = {
|
|
320
|
+
kind: "school"|"hub",
|
|
321
|
+
/** Every fee charged, with the working behind it. */
|
|
322
|
+
lines: PricingLine[],
|
|
323
|
+
/** The lines added up, before any whole-quote discount. */
|
|
324
|
+
subtotal: number,
|
|
325
|
+
fullPlatformDiscountApplied: number,
|
|
326
|
+
bespokeDiscountApplied: number,
|
|
327
|
+
minFeeApplied: boolean,
|
|
328
|
+
overrideApplied: boolean,
|
|
329
|
+
total: number,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** What the customer is shown: the total, and what makes it up. No rates. */
|
|
333
|
+
export type InstituteQuoteSnapshot = {
|
|
334
|
+
lines: {key: PricingLineKey, label: string, amount: number}[],
|
|
335
|
+
/** The lines added up. */
|
|
336
|
+
subtotal: number,
|
|
337
|
+
/** Everything taken off the lines to reach the total, as one figure. */
|
|
338
|
+
discountTotal: number,
|
|
339
|
+
/** True when the total is a floor or an agreed price above what the lines
|
|
340
|
+
* come to, so the breakdown is shown as an illustration, not a sum. */
|
|
341
|
+
minimum: boolean,
|
|
342
|
+
total: number,
|
|
343
|
+
/** ISO date the quote was last worked out. */
|
|
344
|
+
updated: string,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** What the quote needs to know about the institute being priced. */
|
|
348
|
+
export type InstituteQuoteInput = {
|
|
349
|
+
/** What the institute has, as resolved by `resolveProducts`. */
|
|
350
|
+
products: {wex?: boolean, events?: boolean, destinations?: boolean, isHub?: boolean},
|
|
351
|
+
/** The institute's roll, when the agreed terms don't name one. */
|
|
352
|
+
students?: number,
|
|
353
|
+
/** Schools on the hub, when the agreed terms don't name a number. */
|
|
354
|
+
hubSchools?: number,
|
|
355
|
+
/** The terms agreed with this customer. */
|
|
356
|
+
pricing?: InstitutePricing,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Price a school and present it as an institute quote.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} input the roll, the year groups, the products and the terms.
|
|
363
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
364
|
+
* @return {object} the quote, ready to render.
|
|
365
|
+
*/
|
|
366
|
+
export function schoolQuote(
|
|
367
|
+
input: SchoolPricingInput,
|
|
368
|
+
config: PricingConfig = DEFAULT_PRICING_CONFIG,
|
|
369
|
+
): InstituteQuote {
|
|
370
|
+
return {kind: "school", ...calculateSchoolPrice(input, config)};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Price a careers hub as an institute quote: the platform fee, then the schools.
|
|
375
|
+
*
|
|
376
|
+
* Hubs have no products to discount individually and no minimum fee to fall back
|
|
377
|
+
* on, so the only terms that reach them are the negotiated discount and an
|
|
378
|
+
* agreed price.
|
|
379
|
+
*
|
|
380
|
+
* @param {number} schools how many schools the hub brings on.
|
|
381
|
+
* @param {object} [pricing] the terms agreed with this hub.
|
|
382
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
383
|
+
* @return {object} the quote, ready to render.
|
|
384
|
+
*/
|
|
385
|
+
export function hubQuote(
|
|
386
|
+
schools: number,
|
|
387
|
+
pricing?: InstitutePricing,
|
|
388
|
+
config: PricingConfig = DEFAULT_PRICING_CONFIG,
|
|
389
|
+
): InstituteQuote {
|
|
390
|
+
const count = Math.max(schools || 0, 0);
|
|
391
|
+
const hub = calculateHubPrice(count, config);
|
|
392
|
+
|
|
393
|
+
const lines: PricingLine[] = [
|
|
394
|
+
{key: "hubBase", label: "Platform fee", amount: hub.baseFee, bands: []},
|
|
395
|
+
{
|
|
396
|
+
key: "hubSchools",
|
|
397
|
+
label: "School fees",
|
|
398
|
+
amount: hub.schoolFees,
|
|
399
|
+
bands: count > 0 ?
|
|
400
|
+
[{tier: 1, quantity: count, rate: config.hub.perSchoolFee, amount: hub.schoolFees}] :
|
|
401
|
+
[],
|
|
402
|
+
},
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
const negotiated = hub.total * (1 - clampFraction(pricing?.bespokeDiscount));
|
|
406
|
+
const overridden = pricing?.quoteOverride !== undefined && pricing.quoteOverride >= 0;
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
kind: "hub",
|
|
410
|
+
lines,
|
|
411
|
+
subtotal: hub.total,
|
|
412
|
+
fullPlatformDiscountApplied: 0,
|
|
413
|
+
bespokeDiscountApplied: hub.total - negotiated,
|
|
414
|
+
minFeeApplied: false,
|
|
415
|
+
overrideApplied: overridden,
|
|
416
|
+
total: overridden ? (pricing?.quoteOverride as number) : negotiated,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Price an institute on what it actually has and what was agreed with it.
|
|
422
|
+
*
|
|
423
|
+
* The products come from `resolveProducts` rather than the raw flags, so a trust
|
|
424
|
+
* is priced on the union of its schools' products the same way it is gated on
|
|
425
|
+
* them. A year group with no placements agreed is a year group not on work
|
|
426
|
+
* experience — except where the institute has work experience and neither year
|
|
427
|
+
* group has been filled in, which is a quote waiting to be finished rather than
|
|
428
|
+
* a school not taking the product, so Y10 is charged at zero to say so.
|
|
429
|
+
*
|
|
430
|
+
* @param {object} input the institute's products, roll and agreed terms.
|
|
431
|
+
* @param {object} [config] the figures to price with. Defaults to the 2026 sheet.
|
|
432
|
+
* @return {object} the quote, ready to render.
|
|
433
|
+
*/
|
|
434
|
+
export function instituteQuote(
|
|
435
|
+
input: InstituteQuoteInput,
|
|
436
|
+
config: PricingConfig = DEFAULT_PRICING_CONFIG,
|
|
437
|
+
): InstituteQuote {
|
|
438
|
+
const pricing = input.pricing;
|
|
439
|
+
|
|
440
|
+
if (input.products.isHub) {
|
|
441
|
+
return hubQuote(pricing?.hubSchools ?? input.hubSchools ?? 0, pricing, config);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const wex = Boolean(input.products.wex);
|
|
445
|
+
const noYearGroupQuoted = pricing?.wexY10 === undefined && pricing?.wexY12 === undefined;
|
|
446
|
+
|
|
447
|
+
return schoolQuote({
|
|
448
|
+
students: pricing?.students ?? input.students,
|
|
449
|
+
wexY10: wex ? (noYearGroupQuoted ? 0 : pricing?.wexY10) : undefined,
|
|
450
|
+
wexY12: wex ? pricing?.wexY12 : undefined,
|
|
451
|
+
events: Boolean(input.products.events),
|
|
452
|
+
eventsDiscount: pricing?.eventsDiscount,
|
|
453
|
+
destinations: Boolean(input.products.destinations),
|
|
454
|
+
destinationsDiscount: pricing?.destinationsDiscount,
|
|
455
|
+
fullPlatformDiscount: pricing?.fullPlatformDiscount,
|
|
456
|
+
bespokeDiscount: pricing?.bespokeDiscount,
|
|
457
|
+
quoteOverride: pricing?.quoteOverride,
|
|
458
|
+
}, config);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Reduce a quote to what the customer sees: the fees, and one discount figure.
|
|
463
|
+
*
|
|
464
|
+
* The base fee isn't itemised to customers, so it is folded into the first fee
|
|
465
|
+
* they are shown — and where there is nothing to fold it into, it stands alone
|
|
466
|
+
* as the platform fee it is.
|
|
467
|
+
*
|
|
468
|
+
* @param {object} quote the priced institute.
|
|
469
|
+
* @return {object} the snapshot to store on the institute.
|
|
470
|
+
*/
|
|
471
|
+
export function quoteSnapshot(quote: InstituteQuote): InstituteQuoteSnapshot {
|
|
472
|
+
const itemised = quote.lines.filter((l) => l.key !== "base");
|
|
473
|
+
const base = quote.lines.find((l) => l.key === "base")?.amount || 0;
|
|
474
|
+
|
|
475
|
+
const lines = itemised.length ?
|
|
476
|
+
itemised.map((line, index) => ({
|
|
477
|
+
key: line.key,
|
|
478
|
+
label: line.label,
|
|
479
|
+
amount: index === 0 ? line.amount + base : line.amount,
|
|
480
|
+
})) :
|
|
481
|
+
[{key: "base" as PricingLineKey, label: "Platform fee", amount: base}];
|
|
482
|
+
|
|
483
|
+
const subtotal = sum(lines.map((l) => l.amount));
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
lines,
|
|
487
|
+
subtotal,
|
|
488
|
+
// The customer is shown the fees and one discount, so whatever separates
|
|
489
|
+
// the two — the whole-quote discounts, an agreed price below them — is
|
|
490
|
+
// that one figure, and a total above the fees is flagged instead.
|
|
491
|
+
discountTotal: Math.max(subtotal - quote.total, 0),
|
|
492
|
+
minimum: quote.total > subtotal,
|
|
493
|
+
total: quote.total,
|
|
494
|
+
updated: new Date().toISOString().split("T")[0],
|
|
495
|
+
};
|
|
496
|
+
}
|
package/src/typeDefinitions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {Descendant} from "slate";
|
|
|
3
3
|
import {emailTemplates} from "./constants";
|
|
4
4
|
import type {ChecklistItem, GiftHistory} from "./customerCareConfig";
|
|
5
5
|
import type {SurveyGroupSetupMap, SurveyKind, SurveySlot} from "./surveys/types";
|
|
6
|
+
import type {InstitutePricing} from "./pricing";
|
|
6
7
|
|
|
7
8
|
export type FilterObject = {
|
|
8
9
|
[key: string]: {
|
|
@@ -1077,6 +1078,10 @@ export type InstituteData = {
|
|
|
1077
1078
|
blockAllEmails?: boolean,
|
|
1078
1079
|
splitBilling?: boolean,
|
|
1079
1080
|
adminBillingContactId?: string,
|
|
1081
|
+
/** The terms agreed with this customer, and the quote they came to. Written
|
|
1082
|
+
* from the admin panel; the institute's own billing page reads the quote
|
|
1083
|
+
* snapshot inside it rather than pricing itself. See ./pricing. */
|
|
1084
|
+
pricing?: InstitutePricing,
|
|
1080
1085
|
// Packages institutes can be signed up to
|
|
1081
1086
|
employerEvents?: boolean,
|
|
1082
1087
|
workExperience?: boolean,
|