apps/web/src/lib/livePricing.ts

1import { z } from 'zod'
2
3export const productSchema = z.object({
4	id: z.string(),
5	eventId: z.string(),
6	status: z.string(),
7	showcms: z.boolean(),
8	applicationRule: z.object({ personKinds: z.array(z.string()) }),
9	price: z.number().finite().nonnegative(),
10	priceHT: z.number().finite().nonnegative().optional(),
11	priceTTC: z.number().finite().nonnegative().optional(),
12	currency: z.string().regex(/^[A-Z]{3}$/),
13	isPriceWithVat: z.boolean(),
14	vat: z.number().finite().nonnegative(),
15	availabilityStartDate: z.string().nullish(),
16	availabilityEndDate: z.string().nullish(),
17})
18export type PriceProduct = z.infer<typeof productSchema>
19export type PricePolicy = {
20	category?: string
21	tax?: 'none' | 'vat' | 'sales-tax'
22	applicationOnly?: boolean
23	override?: number
24}
25export type ResolvedPrice = {
26	status: 'available' | 'application-only' | 'unavailable' | 'conflict'
27	amount: number | null
28	currency: string | null
29	taxLabel: string
30	displayPrice: string
31	productId?: string
32	validUntil?: string
33	nextStart?: string
34	reason?: string
35}
36const unknownPrice = (
37	status: 'unavailable' | 'conflict',
38	reason: string,
39): ResolvedPrice => ({
40	status,
41	amount: null,
42	currency: null,
43	taxLabel: '',
44	displayPrice: 'Price unavailable',
45	reason,
46})
47
48/** Open boundaries are valid; malformed boundaries never become active prices. */
49function boundary(value: string | null | undefined, open: number) {
50	return value?.trim() ? Date.parse(value) : open
51}
52export function resolvePublicPrice(
53	products: PriceProduct[],
54	eventId: string,
55	policy: PricePolicy,
56	now = new Date(),
57): ResolvedPrice {
58	if (policy.applicationOnly)
59		return {
60			status: 'application-only',
61			amount: null,
62			currency: null,
63			taxLabel: '',
64			displayPrice: 'Price on application',
65		}
66	const category = policy.category
67	if (!category) return unknownPrice('unavailable', 'No public category mapping')
68	const scoped = products.filter(
69		(p) =>
70			p.eventId === eventId &&
71			p.status === 'Published' &&
72			p.showcms &&
73			p.applicationRule.personKinds.includes(category),
74	)
75	const active = scoped.filter(
76		(p) =>
77			now.getTime() >= boundary(p.availabilityStartDate, -Infinity) &&
78			now.getTime() <= boundary(p.availabilityEndDate, Infinity),
79	)
80	if (active.length !== 1) {
81		const r = unknownPrice(
82			active.length > 1 ? 'conflict' : 'unavailable',
83			active.length > 1
84				? 'Overlapping published price bands'
85				: 'No active published price',
86		)
87		const next = scoped
88			.map((p) => boundary(p.availabilityStartDate, Infinity))
89			.filter((t) => t > now.getTime() && Number.isFinite(t))
90			.sort((a, b) => a - b)[0]
91		return { ...r, ...(next ? { nextStart: new Date(next).toISOString() } : {}) }
92	}
93	const p = active[0]
94	const amount =
95		policy.override ??
96		(policy.tax === 'vat' || policy.tax === 'sales-tax'
97			? (p.priceHT ?? (!p.isPriceWithVat ? p.price : undefined))
98			: p.isPriceWithVat
99				? p.priceTTC
100				: p.price)
101	if (amount === undefined || !Number.isFinite(amount) || amount < 0)
102		return unknownPrice('unavailable', 'Cannot establish tax basis')
103	const taxLabel =
104		policy.tax === 'vat'
105			? '+ VAT'
106			: policy.tax === 'sales-tax'
107				? '+ Sales Tax'
108				: p.isPriceWithVat
109					? 'VAT included'
110					: ''
111	const nextBoundaries = scoped
112		.flatMap((x) => [
113			boundary(x.availabilityStartDate, Infinity),
114			boundary(x.availabilityEndDate, Infinity) + 1,
115		])
116		.filter((t) => Number.isFinite(t) && t > now.getTime())
117	const validUntil = new Date(
118		Math.min(now.getTime() + 60_000, ...nextBoundaries),
119	).toISOString()
120	return {
121		status: 'available',
122		amount,
123		currency: p.currency,
124		taxLabel,
125		displayPrice: `${p.currency} ${amount.toLocaleString('en-GB', { maximumFractionDigits: 2 })}${taxLabel ? ` ${taxLabel}` : ''}`,
126		productId: p.id,
127		validUntil,
128	}
129}
130