@tribe-nest/forge 3.14.0 → 3.19.0

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/CartContext.tsx +25 -1
  3. package/src/data/queries/useCheckouts.ts +14 -0
  4. package/src/data/queries/useCoachingAvailability.ts +13 -0
  5. package/src/data/queries/useCourseAccess.ts +81 -0
  6. package/src/data/queries/useCourses.ts +19 -1
  7. package/src/data/queries/useEvents.ts +6 -0
  8. package/src/data/queries/useFinalize.ts +24 -4
  9. package/src/index.ts +7 -0
  10. package/src/types/models.ts +41 -1
  11. package/src/ui/format/_tests/pwyw.spec.ts +157 -0
  12. package/src/ui/format/pwyw.ts +95 -0
  13. package/src/ui/headless/booking/useBookingSecret.ts +41 -0
  14. package/src/ui/headless/coaching/useCoachingBooking.ts +26 -3
  15. package/src/ui/headless/course/_tests/courseAccessGate.spec.ts +135 -0
  16. package/src/ui/headless/course/useCourseCheckout.ts +18 -1
  17. package/src/ui/headless/course/useCourseClassroom.ts +242 -0
  18. package/src/ui/headless/event/useEventCheckout.ts +96 -6
  19. package/src/ui/headless/index.ts +11 -0
  20. package/src/ui/index.ts +17 -0
  21. package/src/ui/styled/AccountDashboard.tsx +7 -0
  22. package/src/ui/styled/Cart.tsx +11 -8
  23. package/src/ui/styled/CartLineOptions.tsx +107 -0
  24. package/src/ui/styled/Checkout.tsx +5 -0
  25. package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
  26. package/src/ui/styled/CoachingBooking.tsx +9 -1
  27. package/src/ui/styled/CoachingConfirmation.tsx +44 -3
  28. package/src/ui/styled/Confirmation.tsx +94 -0
  29. package/src/ui/styled/CourseAccess.tsx +191 -51
  30. package/src/ui/styled/CourseCheckout.tsx +9 -1
  31. package/src/ui/styled/CourseConfirmation.tsx +42 -3
  32. package/src/ui/styled/EventTickets.tsx +114 -0
  33. package/src/ui/styled/InvoicePayment.tsx +65 -11
  34. package/src/ui/styled/ProductDetail.tsx +8 -0
  35. package/src/utils/_tests/bookingSecret.spec.ts +115 -0
  36. package/src/utils/bookingSecret.ts +100 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.14.0",
3
+ "version": "3.19.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -29,7 +29,22 @@ export type CartItem = {
29
29
  quantity: number;
30
30
  recipientMessage?: string;
31
31
  payWhatYouWant: boolean;
32
+ /**
33
+ * WHICH version this line is — "Format: FLAC", "Size: L".
34
+ *
35
+ * The cart used to show a colour swatch and a size string, which is all a
36
+ * variant could be. Anything else was unnameable: a buyer who chose FLAC over
37
+ * MP3 saw two identical lines at two prices and no way to tell which was
38
+ * which, right at the moment they are deciding whether to pay.
39
+ *
40
+ * Optional because a product sold one way has no versions to distinguish, and
41
+ * because carts persist — a line saved before this existed still has to
42
+ * render.
43
+ */
44
+ options?: { axis: string; value: string; swatchHex?: string | null }[];
45
+ /** @deprecated Superseded by `options`. Still read for carts saved earlier. */
32
46
  color?: string;
47
+ /** @deprecated Superseded by `options`. Still read for carts saved earlier. */
33
48
  size?: string;
34
49
  /**
35
50
  * REQUIRED, and deliberately so.
@@ -67,7 +82,16 @@ export type TicketCartItem = {
67
82
  /** ticketId → quantity, matching `useEventCheckout`'s `selectedTickets`. */
68
83
  tickets: Record<string, number>;
69
84
  /** Display data per ticket id, so the cart can render lines without refetching. */
70
- ticketMeta: Record<string, { title: string; price: number }>;
85
+ /**
86
+ * `price` is the per-unit figure the cart DISPLAYS — on a pay-what-you-want
87
+ * tier that is the buyer's chosen amount, not the tier's floor, so returning
88
+ * from an add-on page doesn't silently forget what they picked.
89
+ *
90
+ * `pwywAmount` is the same number carried explicitly, because the bundle
91
+ * endpoint treats a line's `price` as display-only and never charges it; the
92
+ * chosen amount has to arrive in a field the server actually reads.
93
+ */
94
+ ticketMeta: Record<string, { title: string; price: number; pwywAmount?: number }>;
71
95
  };
72
96
 
73
97
  interface CartContextType {
@@ -25,7 +25,14 @@ export type CheckoutLineInput =
25
25
  eventId: string;
26
26
  ticketId: string;
27
27
  quantity: number;
28
+ /** Display only — the bundle never charges this. */
28
29
  price: number;
30
+ /**
31
+ * The buyer's chosen per-unit amount on a pay-what-you-want tier. Unlike
32
+ * `price`, this IS forwarded into the ticket order and charged (after the
33
+ * server clamps it against the tier's floor).
34
+ */
35
+ pwywAmount?: number;
29
36
  title: string;
30
37
  coverImage?: string;
31
38
  };
@@ -45,6 +52,13 @@ export function cartToCheckoutLines(cartItems: CartItem[], ticketItems: TicketCa
45
52
  ticketId,
46
53
  quantity: qty,
47
54
  price: t.ticketMeta[ticketId]?.price ?? 0,
55
+ // Sent SEPARATELY from `price` above, which the bundle treats as
56
+ // display-only and never charges. This is the field the server forwards
57
+ // into the ticket order — dropping it would charge the tier's floor and
58
+ // still return 200, so the buyer would be quietly undercharged.
59
+ ...(t.ticketMeta[ticketId]?.pwywAmount != null
60
+ ? { pwywAmount: t.ticketMeta[ticketId]?.pwywAmount }
61
+ : {}),
48
62
  title: t.ticketMeta[ticketId]?.title ?? t.eventTitle,
49
63
  coverImage: t.coverImage,
50
64
  })),
@@ -20,6 +20,13 @@ export function useCoachingAvailability(productId?: string, fromDate?: string, t
20
20
 
21
21
  export type ReserveCoachingBookingResult = {
22
22
  bookingId: string;
23
+ /**
24
+ * The per-booking credential, returned EXACTLY ONCE and never re-readable.
25
+ * Every later call on this booking (`booking/update`, `start-payment`,
26
+ * `finalize`) must present it. Persist it immediately — see
27
+ * `utils/bookingSecret`. Absent for bookings the backend grandfathered.
28
+ */
29
+ bookingSecret?: string;
23
30
  totalAmount?: number;
24
31
  /**
25
32
  * The cancellation terms SNAPSHOTTED onto this booking (S.6). Authoritative
@@ -45,6 +52,12 @@ export function useReserveCoachingBooking(productId?: string) {
45
52
 
46
53
  export type UpdateCoachingBookingInput = {
47
54
  bookingId: string | null;
55
+ /**
56
+ * The per-booking credential from `useReserveCoachingBooking`. Required by the
57
+ * backend for every call on a booking it minted one for — without it the call
58
+ * 404s (a 403 would confirm the id exists). See `utils/bookingSecret`.
59
+ */
60
+ bookingSecret?: string;
48
61
  email: string;
49
62
  firstName: string;
50
63
  lastName: string;
@@ -2,6 +2,75 @@ import type { CourseAccessData } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
5
+ /**
6
+ * Why a course grant refused to open.
7
+ *
8
+ * `course_access.account_id` decides this PER ROW. A NULL binds to nobody — the
9
+ * emailed `/i/course-access/:accessId` link is the whole credential, and every
10
+ * one of those links must keep working, so a legacy grant still answers 200 to
11
+ * an anonymous caller. A grant with `account_id` set answers only to that
12
+ * account, and the API splits the refusal in two on purpose:
13
+ *
14
+ * - **401 `account_required`** — nobody is signed in. Overwhelmingly the
15
+ * LEGITIMATE owner following an old email on a new device. Needs a sign-in
16
+ * that comes back to this page.
17
+ * - **403 `wrong_account`** — signed in, but as somebody else. Telling this
18
+ * person to "sign in" is a loop with no exit; they need to be told whose
19
+ * enrolment it is and offered a way to switch.
20
+ *
21
+ * Collapsing the two into one "error" strands one of the two groups, which is
22
+ * exactly why the API bothers to distinguish them.
23
+ */
24
+ export type CourseAccessDenialReason = "account_required" | "wrong_account";
25
+
26
+ export type CourseAccessDenial = {
27
+ reason: CourseAccessDenialReason;
28
+ /** 401 for `account_required`, 403 for `wrong_account`. */
29
+ status: 401 | 403;
30
+ /** The API's own already-translated sentence, when it sent one. */
31
+ message: string | null;
32
+ };
33
+
34
+ /** Status off an axios-ish rejection, without importing axios or trusting its shape. */
35
+ const errorStatus = (error: unknown): number | null => {
36
+ const status = (error as { response?: { status?: unknown } } | null | undefined)?.response?.status;
37
+ return typeof status === "number" ? status : null;
38
+ };
39
+
40
+ const errorMessage = (error: unknown): string | null => {
41
+ const message = (error as { response?: { data?: { message?: unknown } } } | null | undefined)?.response?.data
42
+ ?.message;
43
+ return typeof message === "string" && message.trim() ? message : null;
44
+ };
45
+
46
+ /**
47
+ * The account-binding refusal behind a failed course-access read, or `null` when
48
+ * the failure was something else (404 for a revoked/unknown grant, a 500, an
49
+ * offline browser).
50
+ *
51
+ * A total function over `unknown` so a caller cannot forget to narrow first, and
52
+ * the one place the status→meaning mapping is written down.
53
+ */
54
+ export const courseAccessDenial = (error: unknown): CourseAccessDenial | null => {
55
+ const status = errorStatus(error);
56
+ if (status !== 401 && status !== 403) return null;
57
+ return {
58
+ reason: status === 401 ? "account_required" : "wrong_account",
59
+ status,
60
+ message: errorMessage(error),
61
+ };
62
+ };
63
+
64
+ /**
65
+ * Is this grant still open to anyone holding the link?
66
+ *
67
+ * `access.accountId === null` is the legacy shape. Worth surfacing on a
68
+ * SUCCESSFUL load too: the student can be nudged to put the course behind their
69
+ * account before the link is forwarded, screenshotted or indexed.
70
+ */
71
+ export const isLegacyCourseAccess = (data?: CourseAccessData | null): boolean =>
72
+ !!data && (data.access.accountId ?? null) === null;
73
+
5
74
  /** A purchased course-access record (course content + progress) by access id. */
6
75
  export function useCourseAccess(accessId?: string) {
7
76
  const { client, profileId } = useForge();
@@ -15,6 +84,18 @@ export function useCourseAccess(accessId?: string) {
15
84
  return res.data;
16
85
  },
17
86
  enabled: !!client && !!accessId,
87
+ // Every failure here is an ANSWER, not a fault: 401 means "sign in", 403
88
+ // means "wrong account", 404 means the grant is revoked or unknown. Retrying
89
+ // three times (the react-query default) only makes the owner of an old email
90
+ // link watch a spinner before being shown the sign-in button that was always
91
+ // the outcome.
92
+ //
93
+ // Callers MUST NOT fire this until `usePublicAuth().isInitialized` — a
94
+ // request sent before the session is restored goes out anonymous, and a
95
+ // bound grant would 401 a reader who is in fact signed in, which is the one
96
+ // dead end this whole split exists to prevent. `useCourseClassroom` handles
97
+ // that gate; a hand-rolled caller has to do it too.
98
+ retry: false,
18
99
  });
19
100
  }
20
101
 
@@ -12,6 +12,12 @@ export type CreateCourseBookingInput = {
12
12
 
13
13
  export type UpdateCourseBookingInput = {
14
14
  bookingId: string;
15
+ /**
16
+ * The per-booking credential from `useCreateCourseBooking`. Required by the
17
+ * backend for every call on a booking it minted one for — without it the call
18
+ * 404s (a 403 would confirm the id exists). See `utils/bookingSecret`.
19
+ */
20
+ bookingSecret?: string;
15
21
  email: string;
16
22
  firstName: string;
17
23
  lastName: string;
@@ -51,11 +57,23 @@ export function useUpdateCourseBooking(courseId?: string) {
51
57
  });
52
58
  }
53
59
 
60
+ export type CreateCourseBookingResult = {
61
+ bookingId: string;
62
+ /**
63
+ * The per-booking credential, returned EXACTLY ONCE and never re-readable.
64
+ * Every later call on this booking (`booking/update`, `start-payment`,
65
+ * `finalize`) must present it. Persist it immediately — see
66
+ * `utils/bookingSecret`. Absent for bookings the backend grandfathered.
67
+ */
68
+ bookingSecret?: string;
69
+ isFree?: boolean;
70
+ };
71
+
54
72
  /** Create a course booking (buyer details → bookingId; free courses skip payment). */
55
73
  export function useCreateCourseBooking(courseId?: string) {
56
74
  const { client, profileId } = useForge();
57
75
 
58
- return useMutation<{ bookingId: string; isFree?: boolean }, unknown, CreateCourseBookingInput>({
76
+ return useMutation<CreateCourseBookingResult, unknown, CreateCourseBookingInput>({
59
77
  mutationFn: async (body) => {
60
78
  const res = await client.post(`/public/courses/${courseId}/bookings`, {
61
79
  courseId,
@@ -43,6 +43,12 @@ export function useEvent(id?: string, options?: { initialData?: IEvent }) {
43
43
 
44
44
  export type CreateEventOrderInput = {
45
45
  items: Record<string, number>;
46
+ /**
47
+ * ticketId → the buyer's chosen amount, PER UNIT, on a pay-what-you-want
48
+ * tier. Ignored server-side for any tier that is not PWYW, and clamped up to
49
+ * the tier's own `price` — it can raise what is charged, never lower it.
50
+ */
51
+ amounts?: Record<string, number>;
46
52
  email: string;
47
53
  firstName?: string;
48
54
  lastName?: string;
@@ -59,8 +59,17 @@ export function useEventOrderFinalize(eventId?: string, orderId?: string) {
59
59
  });
60
60
  }
61
61
 
62
- /** Finalize a coaching booking (`/public/coaching/products/:productId/finalize`). */
63
- export function useCoachingBookingFinalize(productId?: string, orderId?: string) {
62
+ /**
63
+ * Finalize a coaching booking (`/public/coaching/products/:productId/finalize`).
64
+ *
65
+ * `bookingSecret` is the per-booking credential minted at reserve — the id alone
66
+ * no longer resolves a booking that has one, and the call 404s without it. The
67
+ * confirmation page is reached via a provider redirect, so the caller reads it
68
+ * back out of `utils/bookingSecret` rather than off in-memory state. Optional
69
+ * because bookings created before the secret existed have no stored hash and
70
+ * still finalize on the id.
71
+ */
72
+ export function useCoachingBookingFinalize(productId?: string, orderId?: string, bookingSecret?: string) {
64
73
  const { client, profileId } = useForge();
65
74
 
66
75
  return useQuery<CoachingBooking>({
@@ -69,6 +78,7 @@ export function useCoachingBookingFinalize(productId?: string, orderId?: string)
69
78
  const res = await client.post(`/public/coaching/products/${productId}/finalize`, {
70
79
  profileId,
71
80
  orderId,
81
+ bookingSecret,
72
82
  });
73
83
  return res.data;
74
84
  },
@@ -91,8 +101,17 @@ export function useDonationFinalize() {
91
101
  });
92
102
  }
93
103
 
94
- /** Finalize a course booking (`/public/courses/:courseId/finalize`). */
95
- export function useCourseBookingFinalize(courseId?: string, bookingId?: string) {
104
+ /**
105
+ * Finalize a course booking (`/public/courses/:courseId/finalize`).
106
+ *
107
+ * `bookingSecret` is the per-booking credential minted at create — the id alone
108
+ * no longer resolves a booking that has one, and the call 404s without it. The
109
+ * confirmation page is reached via a provider redirect, so the caller reads it
110
+ * back out of `utils/bookingSecret` rather than off in-memory state. Optional
111
+ * because bookings created before the secret existed have no stored hash and
112
+ * still finalize on the id.
113
+ */
114
+ export function useCourseBookingFinalize(courseId?: string, bookingId?: string, bookingSecret?: string) {
96
115
  const { client, profileId } = useForge();
97
116
 
98
117
  return useQuery<CourseBooking>({
@@ -101,6 +120,7 @@ export function useCourseBookingFinalize(courseId?: string, bookingId?: string)
101
120
  const res = await client.post(`/public/courses/${courseId}/finalize`, {
102
121
  profileId,
103
122
  bookingId,
123
+ bookingSecret,
104
124
  });
105
125
  return res.data;
106
126
  },
package/src/index.ts CHANGED
@@ -30,6 +30,9 @@ export { CartProvider, useCart } from "./contexts/CartContext";
30
30
  // way the cart and audio player are — so a code site and a Craft site cannot
31
31
  // disagree about which version a buyer picked.
32
32
  export { useVariantSelection } from "./ui/headless/useVariantSelection";
33
+ // Which version a cart line is. Exported from the package root because the
34
+ // Craft themes render their own cart and must resolve it the same way.
35
+ export { resolveCartLineOptions } from "./ui/styled/CartLineOptions";
33
36
  export type { VariantAxis, VariantAxisValue } from "./ui/headless/useVariantSelection";
34
37
  export type { CartItem, TicketCartItem, AttachedTo } from "./contexts/CartContext";
35
38
  export {
@@ -124,6 +127,10 @@ export * from "./utils/landing";
124
127
  export * from "./utils/metaPixel";
125
128
  export * from "./utils/cookieConsent";
126
129
  export * from "./utils/structuredData";
130
+ // The per-booking checkout credential + where it is kept across the payment
131
+ // redirect. Exported so a host that drives the booking endpoints itself can
132
+ // hold the secret the same way the built-in flows do.
133
+ export * from "./utils/bookingSecret";
127
134
  // Shared by BOTH starters. Kept here rather than in a starter's `/i/-lib` so an
128
135
  // app can use them without inheriting the fan-site route tree, and so a fix
129
136
  // reaches existing sites through the normal Forge publish.
@@ -582,6 +582,15 @@ export type IMedia = {
582
582
  type: MediaType;
583
583
  previewUrl?: string | null;
584
584
  previewStatus?: string | null;
585
+ /**
586
+ * Which version this file is for, when it is a song's file.
587
+ *
588
+ * Null — and absent everywhere else — means every version gets it. Present so
589
+ * an EDITOR can round-trip a per-version file; a storefront never needs it,
590
+ * because the server has already filtered the list to the version being
591
+ * viewed.
592
+ */
593
+ productOptionValueId?: string | null;
585
594
  };
586
595
 
587
596
  /**
@@ -1097,11 +1106,22 @@ export type ITicket = {
1097
1106
  id: string;
1098
1107
  title: string;
1099
1108
  description: string;
1109
+ /**
1110
+ * On a pay-what-you-want tier this is the MINIMUM, not a fixed price — there
1111
+ * is no separate minimum field. Every "from $X" on the storefront reads it,
1112
+ * which is exactly why it stays the floor.
1113
+ */
1100
1114
  price: number;
1101
1115
  // Optional display-only "compare-at" price (higher than `price`). When
1102
1116
  // present the storefront strikes it through next to `price`. Charging uses
1103
- // `price`.
1117
+ // `price`. Mutually exclusive with `payWhatYouWant` — the API refuses both.
1104
1118
  compareAtPrice?: number | string | null;
1119
+ /** Buyer chooses the amount, at or above `price`. */
1120
+ payWhatYouWant?: boolean;
1121
+ /** Pre-fills the buyer's amount box. Presentation only — never a floor. */
1122
+ pwywSuggestedAmount?: number | string | null;
1123
+ /** Enforced server-side at checkout. */
1124
+ payWhatYouWantMaximum?: number | string | null;
1105
1125
  quantity: number;
1106
1126
  order: number;
1107
1127
  sold: number;
@@ -1268,7 +1288,20 @@ export type IPublicOrderItem = {
1268
1288
  quantity: number;
1269
1289
  recipientMessage?: string;
1270
1290
  payWhatYouWant: boolean;
1291
+ /**
1292
+ * What this line WAS, recorded when it was sold.
1293
+ *
1294
+ * Not resolved through `productVariantId` on read: the creator can rename an
1295
+ * option value or delete the version, and an order that re-resolves would
1296
+ * quietly start describing itself differently from how it was bought.
1297
+ *
1298
+ * Null for a product sold one way, and for every order placed before the
1299
+ * snapshot existed — those fall back to `size` below.
1300
+ */
1301
+ variantOptions?: { axis: string; value: string; swatchHex?: string | null }[] | null;
1302
+ /** @deprecated Superseded by `variantOptions`. The only record for older orders. */
1271
1303
  color: string;
1304
+ /** @deprecated Superseded by `variantOptions`. The only record for older orders. */
1272
1305
  size: string;
1273
1306
  };
1274
1307
 
@@ -1415,6 +1448,13 @@ export type CourseAccessRecord = {
1415
1448
  firstName: string | null;
1416
1449
  lastName: string | null;
1417
1450
  currentLessonId: string | null;
1451
+ /**
1452
+ * The account this grant is bound to, or `null` for a LEGACY grant that any
1453
+ * holder of the access id can open. Optional on the type because sites built
1454
+ * against an older API still get payloads without the field — treat a missing
1455
+ * value as legacy (see `isLegacyCourseAccess`).
1456
+ */
1457
+ accountId?: string | null;
1418
1458
  };
1419
1459
 
1420
1460
  export type CourseLessonProgress = {
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { ITicket } from "../../../types/models";
3
+ import {
4
+ clampChosenAmount,
5
+ isPayWhatYouWant,
6
+ pwywDefaultAmount,
7
+ pwywMaximum,
8
+ resolveUnitPrice,
9
+ ticketSubtotals,
10
+ } from "../pwyw";
11
+
12
+ /**
13
+ * The pay-what-you-want arithmetic shared by BOTH storefront stacks (Forge and
14
+ * the legacy client). It is shared rather than written twice precisely so the
15
+ * two cannot drift from each other or from the server — these tests pin the
16
+ * behaviour they both depend on.
17
+ *
18
+ * The clamp here is UX, not enforcement: the server clamps again and is what
19
+ * decides the charge. What matters is that the number on screen matches the
20
+ * number that will be charged.
21
+ */
22
+
23
+ const tier = (over: Partial<ITicket> = {}): ITicket => ({
24
+ id: over.id ?? "t1",
25
+ title: "GA",
26
+ description: "",
27
+ price: 20,
28
+ quantity: 100,
29
+ order: 0,
30
+ sold: 0,
31
+ maxPerPerson: 10,
32
+ ...over,
33
+ });
34
+
35
+ describe("pwyw helpers", () => {
36
+ describe("isPayWhatYouWant", () => {
37
+ it("is false when the flag is absent — every pre-feature tier", () => {
38
+ expect(isPayWhatYouWant(tier())).toBe(false);
39
+ });
40
+
41
+ it("is true only when explicitly set", () => {
42
+ expect(isPayWhatYouWant(tier({ payWhatYouWant: true }))).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe("pwywMaximum", () => {
47
+ it("is null on a non-PWYW tier even if a maximum is stored", () => {
48
+ // A stale ceiling on a tier whose toggle was turned off must not start
49
+ // constraining a fixed price.
50
+ expect(pwywMaximum(tier({ payWhatYouWantMaximum: 50 }))).toBeNull();
51
+ });
52
+
53
+ it("parses a numeric string, as the API returns for decimals", () => {
54
+ expect(pwywMaximum(tier({ payWhatYouWant: true, payWhatYouWantMaximum: "50.00" }))).toBe(50);
55
+ });
56
+
57
+ it("is null for an unparseable value rather than NaN", () => {
58
+ expect(pwywMaximum(tier({ payWhatYouWant: true, payWhatYouWantMaximum: "nonsense" }))).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe("pwywDefaultAmount", () => {
63
+ it("falls back to the floor with no suggestion", () => {
64
+ expect(pwywDefaultAmount(tier({ payWhatYouWant: true }))).toBe(20);
65
+ });
66
+
67
+ it("uses the operator's suggestion", () => {
68
+ expect(pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 35 }))).toBe(35);
69
+ });
70
+
71
+ it("never pre-fills below the floor", () => {
72
+ // Otherwise the form opens with a value its own validation rejects.
73
+ expect(pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 5 }))).toBe(20);
74
+ });
75
+
76
+ it("never pre-fills above the maximum", () => {
77
+ expect(
78
+ pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 80, payWhatYouWantMaximum: 50 })),
79
+ ).toBe(50);
80
+ });
81
+ });
82
+
83
+ describe("clampChosenAmount", () => {
84
+ const t = tier({ payWhatYouWant: true });
85
+
86
+ it("keeps a figure above the floor", () => {
87
+ expect(clampChosenAmount(t, 75)).toBe(75);
88
+ });
89
+
90
+ it("raises a figure below the floor — the one-directional property", () => {
91
+ expect(clampChosenAmount(t, 1)).toBe(20);
92
+ });
93
+
94
+ it.each([
95
+ ["null", null],
96
+ ["undefined", undefined],
97
+ ["NaN", NaN],
98
+ ["negative", -50],
99
+ ["zero", 0],
100
+ ])("falls back to the floor for %s", (_label, value) => {
101
+ expect(clampChosenAmount(t, value as number | null | undefined)).toBe(20);
102
+ });
103
+
104
+ it("caps at the maximum", () => {
105
+ expect(clampChosenAmount(tier({ payWhatYouWant: true, payWhatYouWantMaximum: 50 }), 5000)).toBe(50);
106
+ });
107
+ });
108
+
109
+ describe("resolveUnitPrice", () => {
110
+ it("ignores a chosen amount on a non-PWYW tier, matching the server", () => {
111
+ expect(resolveUnitPrice(tier(), 500)).toBe(20);
112
+ expect(resolveUnitPrice(tier(), 1)).toBe(20);
113
+ });
114
+
115
+ it("honours a chosen amount on a PWYW tier", () => {
116
+ expect(resolveUnitPrice(tier({ payWhatYouWant: true }), 75)).toBe(75);
117
+ });
118
+ });
119
+
120
+ describe("ticketSubtotals", () => {
121
+ it("returns identical paid and floor figures with no PWYW tier", () => {
122
+ // The property that makes every existing surface's numbers unchanged.
123
+ const result = ticketSubtotals({
124
+ tickets: [tier({ id: "a", price: 20 }), tier({ id: "b", price: 30 })],
125
+ quantities: { a: 2, b: 1 },
126
+ });
127
+ expect(result).toEqual({ paid: 70, floor: 70 });
128
+ });
129
+
130
+ it("separates what is paid from what the fee is computed on", () => {
131
+ const result = ticketSubtotals({
132
+ tickets: [tier({ id: "a", price: 20, payWhatYouWant: true })],
133
+ quantities: { a: 2 },
134
+ amounts: { a: 75 },
135
+ });
136
+ expect(result).toEqual({ paid: 150, floor: 40 });
137
+ });
138
+
139
+ it("mixes PWYW and fixed tiers correctly", () => {
140
+ const result = ticketSubtotals({
141
+ tickets: [tier({ id: "a", price: 10, payWhatYouWant: true }), tier({ id: "b", price: 40 })],
142
+ quantities: { a: 2, b: 1 },
143
+ amounts: { a: 35 },
144
+ });
145
+ expect(result).toEqual({ paid: 110, floor: 60 });
146
+ });
147
+
148
+ it("ignores tiers with no quantity selected", () => {
149
+ const result = ticketSubtotals({
150
+ tickets: [tier({ id: "a", price: 20, payWhatYouWant: true })],
151
+ quantities: {},
152
+ amounts: { a: 500 },
153
+ });
154
+ expect(result).toEqual({ paid: 0, floor: 0 });
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,95 @@
1
+ import type { ITicket } from "../../types/models";
2
+
3
+ /**
4
+ * Pay-what-you-want, as a buyer-facing surface has to handle it.
5
+ *
6
+ * ## The floor is the ticket, everything above it is a tip
7
+ *
8
+ * On a PWYW tier `ticket.price` IS the minimum — there is no separate minimum
9
+ * field — so every existing "from $X" already reads the right number. What
10
+ * changes is that the buyer may choose to pay MORE, and two totals then exist
11
+ * at once:
12
+ *
13
+ * - the **paid** subtotal, which is what the card is charged, and
14
+ * - the **floor** subtotal, which is what discounts and the artist's booking
15
+ * fee are calculated on.
16
+ *
17
+ * The server computes both the same way (`PublicEventsService.createOrder`).
18
+ * These helpers exist so a storefront cannot accidentally show a fee estimate
19
+ * derived from the paid figure and then be charged one derived from the floor.
20
+ *
21
+ * ## The client is not the guard
22
+ *
23
+ * `clampChosenAmount` is UX, not enforcement. The server reads the PWYW flag
24
+ * off the ticket row and refuses anything below `price` regardless of what a
25
+ * client sends — a buyer cannot pay less than the floor by editing anything
26
+ * here. The clamp exists so the number on screen is the number that will be
27
+ * charged, not to make the request safe.
28
+ */
29
+
30
+ const toNumber = (value: number | string | null | undefined): number | null => {
31
+ if (value == null) return null;
32
+ const parsed = typeof value === "number" ? value : Number(value);
33
+ return Number.isFinite(parsed) ? parsed : null;
34
+ };
35
+
36
+ export const isPayWhatYouWant = (ticket: ITicket): boolean => !!ticket.payWhatYouWant;
37
+
38
+ /** The tier's ceiling, or `null` for no limit. */
39
+ export const pwywMaximum = (ticket: ITicket): number | null =>
40
+ isPayWhatYouWant(ticket) ? toNumber(ticket.payWhatYouWantMaximum) : null;
41
+
42
+ /**
43
+ * What the amount box should start at: the operator's suggestion when there is
44
+ * one, else the floor. Never below the floor — a suggestion under the minimum
45
+ * would pre-fill the form with a value the buyer is not allowed to pay.
46
+ */
47
+ export const pwywDefaultAmount = (ticket: ITicket): number => {
48
+ const floor = ticket.price;
49
+ const suggested = toNumber(ticket.pwywSuggestedAmount);
50
+ if (suggested == null || suggested < floor) return floor;
51
+ const maximum = pwywMaximum(ticket);
52
+ return maximum != null ? Math.min(suggested, maximum) : suggested;
53
+ };
54
+
55
+ /** Pin a buyer-entered figure into [floor, maximum]. Mirrors the server clamp. */
56
+ export const clampChosenAmount = (ticket: ITicket, chosen: number | null | undefined): number => {
57
+ const floor = ticket.price;
58
+ if (chosen == null || !Number.isFinite(chosen) || chosen < floor) return floor;
59
+ const maximum = pwywMaximum(ticket);
60
+ return maximum != null ? Math.min(chosen, maximum) : chosen;
61
+ };
62
+
63
+ /**
64
+ * The per-unit price this tier will actually be charged at.
65
+ *
66
+ * A non-PWYW tier ignores `chosen` entirely, exactly as the server does — so a
67
+ * stale amount left in state after the operator turns PWYW off cannot change
68
+ * what is shown.
69
+ */
70
+ export const resolveUnitPrice = (ticket: ITicket, chosen: number | null | undefined): number =>
71
+ isPayWhatYouWant(ticket) ? clampChosenAmount(ticket, chosen) : ticket.price;
72
+
73
+ /**
74
+ * Both subtotals for a selection, in one pass.
75
+ *
76
+ * `paid` is what the buyer owes for the tickets; `floor` is the base the
77
+ * booking-fee estimate and any percentage discount must be computed against.
78
+ * They are equal on any cart with no PWYW tier, which is why every existing
79
+ * surface keeps its current numbers untouched.
80
+ */
81
+ export function ticketSubtotals(input: {
82
+ tickets: ITicket[];
83
+ quantities: Record<string, number>;
84
+ amounts?: Record<string, number>;
85
+ }): { paid: number; floor: number } {
86
+ let paid = 0;
87
+ let floor = 0;
88
+ for (const ticket of input.tickets) {
89
+ const quantity = input.quantities[ticket.id] ?? 0;
90
+ if (quantity <= 0) continue;
91
+ paid += resolveUnitPrice(ticket, input.amounts?.[ticket.id]) * quantity;
92
+ floor += ticket.price * quantity;
93
+ }
94
+ return { paid, floor };
95
+ }