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