@tribe-nest/forge 3.29.0 → 3.34.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 (58) hide show
  1. package/package.json +11 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/contexts/CartContext.tsx +17 -1
  6. package/src/contexts/PublicAuthContext.tsx +34 -5
  7. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  8. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  9. package/src/data/queries/useBroadcasts.ts +151 -0
  10. package/src/data/queries/useMyBookings.ts +18 -1
  11. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  12. package/src/i18n/de.json +97 -0
  13. package/src/i18n/en.json +97 -0
  14. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  15. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  16. package/src/ui/format/membershipPwyw.ts +164 -0
  17. package/src/ui/format/pwyw.ts +37 -0
  18. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  19. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  20. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  21. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  22. package/src/ui/headless/index.ts +14 -0
  23. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  24. package/src/ui/index.ts +61 -0
  25. package/src/ui/media/BookingCallScreen.tsx +33 -0
  26. package/src/ui/media/CallHelpHint.tsx +87 -0
  27. package/src/ui/media/CallStage.tsx +633 -0
  28. package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
  29. package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
  30. package/src/ui/media/_tests/callState.spec.ts +499 -0
  31. package/src/ui/media/_tests/fakeNode.ts +178 -0
  32. package/src/ui/media/bookingSession.tsx +182 -0
  33. package/src/ui/media/bookingWindow.ts +81 -0
  34. package/src/ui/media/callState.ts +360 -0
  35. package/src/ui/media/index.ts +135 -0
  36. package/src/ui/styled/AccountDashboard.tsx +193 -4
  37. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  38. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  39. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  40. package/src/ui/styled/LoginForm.tsx +10 -0
  41. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  42. package/src/ui/styled/MembershipTiers.tsx +10 -3
  43. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  44. package/src/ui/styled/SignupForm.tsx +5 -0
  45. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
  46. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  47. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  48. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  49. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  50. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  51. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  52. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  53. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  54. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  55. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  56. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  57. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
  58. package/src/ui/styled/forge-utilities.css +832 -0
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { MembershipTier } from "../../../types/models";
3
+ import {
4
+ clampMembershipAmount,
5
+ cycleCeiling,
6
+ cycleFloor,
7
+ cycleIsFree,
8
+ cycleIsOffered,
9
+ defaultChosenAmount,
10
+ defaultCycle,
11
+ offeredCycles,
12
+ refuseMembershipAmount,
13
+ resolveSubscriptionAmount,
14
+ } from "../membershipPwyw";
15
+
16
+ /**
17
+ * The membership money rules, away from React.
18
+ *
19
+ * Everything here decides either what a fan is CHARGED or which endpoint is
20
+ * called, and both have shipped wrong: a pay-what-you-want box that sent its
21
+ * initial zero to an endpoint that refuses anything not positive, and a
22
+ * yearly-only tier read as free because the default cycle was the literal
23
+ * "month".
24
+ */
25
+
26
+ const tier = (overrides: Partial<MembershipTier> = {}): MembershipTier => ({
27
+ id: "tier-1",
28
+ name: "Inner Circle",
29
+ description: "The good stuff",
30
+ payWhatYouWant: false,
31
+ benefits: [],
32
+ ...overrides,
33
+ });
34
+
35
+ describe("cycleFloor", () => {
36
+ it("reads a pay-what-you-want tier's YEARLY minimum on the yearly cycle", () => {
37
+ // Monthly 5, yearly 50. Reading the monthly floor on a yearly subscription
38
+ // would let someone buy a whole year for the price of a month.
39
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5, payWhatYouWantYearlyMinimum: 50 });
40
+
41
+ expect(cycleFloor(t, "month")).toBe(5);
42
+ expect(cycleFloor(t, "year")).toBe(50);
43
+ });
44
+
45
+ it("reads a fixed tier's own cycle price, which is the only amount the server takes", () => {
46
+ const t = tier({ priceMonthly: 10, priceYearly: 100 });
47
+
48
+ expect(cycleFloor(t, "month")).toBe(10);
49
+ expect(cycleFloor(t, "year")).toBe(100);
50
+ });
51
+
52
+ it("treats a missing, null or negative price as no price at all", () => {
53
+ expect(cycleFloor(tier({ priceMonthly: undefined }), "month")).toBe(0);
54
+ expect(cycleFloor(tier({ priceYearly: -4 }), "year")).toBe(0);
55
+ });
56
+ });
57
+
58
+ describe("offeredCycles / defaultCycle", () => {
59
+ it("REGRESSION: opens a YEARLY-ONLY tier on the yearly cycle", () => {
60
+ // The defect: the cycle defaulted to the literal "month", the monthly price
61
+ // was absent, and the tier was therefore read as FREE and activated through
62
+ // the free endpoint. The fan got the tier and the artist was never charged.
63
+ const t = tier({ priceYearly: 100 });
64
+
65
+ expect(offeredCycles(t)).toEqual({ month: false, year: true });
66
+ expect(defaultCycle(t)).toBe("year");
67
+ expect(cycleIsFree(t, defaultCycle(t))).toBe(false);
68
+ });
69
+
70
+ it("opens a pay-what-you-want tier with only a yearly minimum on the yearly cycle", () => {
71
+ const t = tier({ payWhatYouWant: true, payWhatYouWantYearlyMinimum: 60 });
72
+
73
+ expect(offeredCycles(t)).toEqual({ month: false, year: true });
74
+ expect(defaultCycle(t)).toBe("year");
75
+ });
76
+
77
+ it("prefers monthly when both are sold", () => {
78
+ expect(defaultCycle(tier({ priceMonthly: 10, priceYearly: 100 }))).toBe("month");
79
+ });
80
+
81
+ it("calls a tier with no price on either cycle free, and opens it on monthly", () => {
82
+ const t = tier();
83
+
84
+ expect(cycleIsOffered(t, "month")).toBe(false);
85
+ expect(cycleIsOffered(t, "year")).toBe(false);
86
+ expect(defaultCycle(t)).toBe("month");
87
+ expect(cycleIsFree(t, "month")).toBe(true);
88
+ });
89
+
90
+ it("never calls a pay-what-you-want tier free, whatever its minimums", () => {
91
+ // A PWYW tier is bought through the PAID endpoint even at its floor, so
92
+ // reading it as free would activate a membership with no charge behind it.
93
+ expect(cycleIsFree(tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5 }), "month")).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("defaultChosenAmount", () => {
98
+ it("REGRESSION: opens the amount box at the floor, not at zero", () => {
99
+ // The defect: the box started at 0 and nothing ever seeded it, so the very
100
+ // first press of Subscribe sent `amount: 0` and the server answered
101
+ // `amount_must_be_positive`. Pay-what-you-want could not be bought at all.
102
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25, payWhatYouWantYearlyMinimum: 250 });
103
+
104
+ expect(defaultChosenAmount(t, "month")).toBe(25);
105
+ expect(defaultChosenAmount(t, "year")).toBe(250);
106
+ });
107
+
108
+ it("leaves a fixed tier's box at zero, because it is never read", () => {
109
+ expect(defaultChosenAmount(tier({ priceMonthly: 10 }), "month")).toBe(0);
110
+ });
111
+ });
112
+
113
+ describe("clampMembershipAmount", () => {
114
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 20, payWhatYouWantMaximum: 100 });
115
+
116
+ it("lifts anything under the floor up to it", () => {
117
+ expect(clampMembershipAmount(t, "month", 5)).toBe(20);
118
+ expect(clampMembershipAmount(t, "month", 0)).toBe(20);
119
+ expect(clampMembershipAmount(t, "month", -3)).toBe(20);
120
+ });
121
+
122
+ it("treats an unparseable box (a cleared input) as the floor rather than NaN", () => {
123
+ expect(clampMembershipAmount(t, "month", Number(""))).toBe(20);
124
+ expect(clampMembershipAmount(t, "month", undefined)).toBe(20);
125
+ });
126
+
127
+ it("keeps a generous amount exactly as typed, ceiling and all", () => {
128
+ // The ceiling is display-only: neither the client app nor the server
129
+ // enforces one on a membership, and lowering the figure here would charge
130
+ // less than the total the fan just read.
131
+ expect(clampMembershipAmount(t, "month", 45)).toBe(45);
132
+ expect(clampMembershipAmount(t, "month", 500)).toBe(500);
133
+ expect(cycleCeiling(t, "month")).toBe(100);
134
+ });
135
+ });
136
+
137
+ describe("resolveSubscriptionAmount", () => {
138
+ it("ignores the amount box on a fixed tier", () => {
139
+ // A figure left over from a pay-what-you-want tier must not follow the fan
140
+ // onto a fixed one: the server rejects anything that is not exactly the
141
+ // cycle price, so this would be a checkout that always 400s.
142
+ const t = tier({ priceMonthly: 10, priceYearly: 100 });
143
+
144
+ expect(resolveSubscriptionAmount(t, "month", 999)).toBe(10);
145
+ expect(resolveSubscriptionAmount(t, "year", 999)).toBe(100);
146
+ });
147
+
148
+ it("sends the fan's own figure on a pay-what-you-want tier", () => {
149
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5 });
150
+
151
+ expect(resolveSubscriptionAmount(t, "month", 12.5)).toBe(12.5);
152
+ });
153
+ });
154
+
155
+ describe("refuseMembershipAmount", () => {
156
+ it("refuses a pay-what-you-want figure under the floor, and names the floor", () => {
157
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 20 });
158
+
159
+ expect(refuseMembershipAmount(t, "month", 5)).toEqual({ reason: "below_minimum", minimum: 20 });
160
+ });
161
+
162
+ it("refuses a zero before the request rather than after the 400", () => {
163
+ // A pay-what-you-want tier whose floor is 0 passes the minimum check on 0
164
+ // and then fails `amount_must_be_positive` server-side, with the failure
165
+ // landing nowhere the fan can see.
166
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 0, payWhatYouWantYearlyMinimum: 60 });
167
+
168
+ expect(refuseMembershipAmount(t, "month", 0)).toEqual({ reason: "not_positive" });
169
+ });
170
+
171
+ it("passes a figure at the floor, and anything above it", () => {
172
+ const t = tier({ payWhatYouWant: true, payWhatYouWantMinimum: 20 });
173
+
174
+ expect(refuseMembershipAmount(t, "month", 20)).toBeNull();
175
+ expect(refuseMembershipAmount(t, "month", 21)).toBeNull();
176
+ });
177
+
178
+ it("passes a free cycle, which never sends an amount at all", () => {
179
+ expect(refuseMembershipAmount(tier(), "month", 0)).toBeNull();
180
+ });
181
+
182
+ it("passes a fixed paid cycle regardless of the box", () => {
183
+ expect(refuseMembershipAmount(tier({ priceMonthly: 10 }), "month", 0)).toBeNull();
184
+ });
185
+ });
@@ -1,13 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import type { ITicket } from "../../../types/models";
3
- import {
4
- clampChosenAmount,
5
- isPayWhatYouWant,
6
- pwywDefaultAmount,
7
- pwywMaximum,
8
- resolveUnitPrice,
9
- ticketSubtotals,
10
- } from "../pwyw";
3
+ import { checkoutAmounts, clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum, resolveUnitPrice, ticketSubtotals } from "../pwyw";
11
4
 
12
5
  /**
13
6
  * The pay-what-you-want arithmetic shared by BOTH storefront stacks (Forge and
@@ -155,3 +148,67 @@ describe("pwyw helpers", () => {
155
148
  });
156
149
  });
157
150
  });
151
+
152
+ describe("what actually gets sent as `amounts`", () => {
153
+ /**
154
+ * The server takes `amounts` as `z.number().positive()`. A zero answers
155
+ * `amounts.<id>: Amount must be a positive number` and the checkout fails
156
+ * outright, so a free pay-what-you-like ticket could not be bought at all.
157
+ * These assert the PAYLOAD rather than the displayed price, which is why the
158
+ * existing subtotal tests could not see it.
159
+ */
160
+ const pwyw = (over: Partial<ITicket> = {}): ITicket =>
161
+ ({ id: "t1", price: 0, payWhatYouWant: true, ...over }) as ITicket;
162
+
163
+ it("sends NOTHING for a free pay-what-you-like tier the buyer left alone", () => {
164
+ // The reported failure. Floor 0, no suggestion, so both the default and the
165
+ // clamp hand back 0.
166
+ const sent = checkoutAmounts({ tickets: [pwyw()], quantities: { t1: 1 }, chosen: {} });
167
+
168
+ expect(sent).toEqual({});
169
+ expect(Object.values(sent).every((v) => v > 0)).toBe(true);
170
+ });
171
+
172
+ it("sends nothing when the buyer TYPES a zero on a free tier", () => {
173
+ const sent = checkoutAmounts({ tickets: [pwyw()], quantities: { t1: 1 }, chosen: { t1: 0 } });
174
+ expect(sent).toEqual({});
175
+ });
176
+
177
+ it("still sends a real amount on a free tier when the buyer chooses to pay", () => {
178
+ // Omitting must not mean "never send"; a free tier is exactly where a
179
+ // voluntary payment matters most.
180
+ const sent = checkoutAmounts({ tickets: [pwyw()], quantities: { t1: 1 }, chosen: { t1: 15 } });
181
+ expect(sent).toEqual({ t1: 15 });
182
+ });
183
+
184
+ it("sends the floor on a PAID tier, which is positive and therefore legal", () => {
185
+ const sent = checkoutAmounts({
186
+ tickets: [pwyw({ price: 10 })],
187
+ quantities: { t1: 1 },
188
+ chosen: { t1: 4 },
189
+ });
190
+ // Clamped up to the floor rather than dropped: 10 is a positive number and
191
+ // says something true.
192
+ expect(sent).toEqual({ t1: 10 });
193
+ });
194
+
195
+ it("ignores a tier with no quantity, and a tier that is not PWYW", () => {
196
+ const sent = checkoutAmounts({
197
+ tickets: [pwyw({ id: "none" }), { id: "fixed", price: 20, payWhatYouWant: false } as ITicket],
198
+ quantities: { none: 0, fixed: 2 },
199
+ chosen: { none: 50, fixed: 99 },
200
+ });
201
+ expect(sent).toEqual({});
202
+ });
203
+
204
+ it("never emits a non-positive value for any mix of tiers", () => {
205
+ const sent = checkoutAmounts({
206
+ tickets: [pwyw({ id: "free" }), pwyw({ id: "paid", price: 12 })],
207
+ quantities: { free: 1, paid: 1 },
208
+ chosen: {},
209
+ });
210
+
211
+ expect(sent).toEqual({ paid: 12 });
212
+ expect(Object.values(sent).some((v) => v <= 0)).toBe(false);
213
+ });
214
+ });
@@ -0,0 +1,164 @@
1
+ import type { MembershipTier } from "../../types/models";
2
+
3
+ /**
4
+ * Membership pricing arithmetic: which billing cycles a tier actually offers,
5
+ * what a pay-what-you-want cycle's floor is, and the ONE number that may be
6
+ * sent to `POST /public/payments/subscriptions`.
7
+ *
8
+ * ## Why this is a module and not four lines inside the hook
9
+ *
10
+ * Every rule here has a wrong-charge or a dead-end on the other side of it, and
11
+ * none of them is reachable from a React test without a provider tree:
12
+ *
13
+ * - **A cycle a tier does not sell must not be selectable.** The default cycle
14
+ * used to be the literal `"month"`, so a tier priced YEARLY only was read as
15
+ * "no monthly price, therefore free" and activated as a free membership. The
16
+ * fan got the tier and the artist got nothing.
17
+ * - **The floor belongs to the CYCLE, not the tier.** `payWhatYouWantMinimum`
18
+ * is the monthly floor and `payWhatYouWantYearlyMinimum` the yearly one.
19
+ * Reading the monthly one on a yearly subscription understates the minimum
20
+ * by a factor of twelve.
21
+ * - **A zero must never be sent.** The server takes the amount as
22
+ * `z.number()` and then refuses anything `<= 0` with
23
+ * `amount_must_be_positive`, so a pay-what-you-want box left at its initial
24
+ * 0 is not "pay nothing", it is a subscribe button that always fails.
25
+ *
26
+ * ## Units
27
+ *
28
+ * Every number in and out of here is MAJOR units (19.99, not 1999) in the
29
+ * TENANT'S SETTLEMENT currency (`useSiteConfig().currency`), because that is
30
+ * what the tier rows hold and what the server converts with `toMinorUnits`.
31
+ * A visitor's chosen display currency converts on the way to the SCREEN and
32
+ * never on the way to the API: convert what is sent and the fan is charged a
33
+ * number nobody agreed to.
34
+ */
35
+
36
+ export type BillingCycle = "month" | "year";
37
+
38
+ /** A tier's own numbers, narrowed to what pricing needs. */
39
+ export type PricedTier = Pick<
40
+ MembershipTier,
41
+ | "payWhatYouWant"
42
+ | "payWhatYouWantMinimum"
43
+ | "payWhatYouWantYearlyMinimum"
44
+ | "payWhatYouWantMaximum"
45
+ | "payWhatYouWantYearlyMaximum"
46
+ | "priceMonthly"
47
+ | "priceYearly"
48
+ >;
49
+
50
+ const positive = (value: number | null | undefined): number => {
51
+ const parsed = typeof value === "number" ? value : Number(value ?? 0);
52
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
53
+ };
54
+
55
+ /**
56
+ * The lowest amount this cycle may be bought at.
57
+ *
58
+ * Pay-what-you-want: the cycle's own minimum. Fixed price: the cycle's price,
59
+ * which is also the only amount the server accepts (`amount !== cyclePrice`
60
+ * is rejected outright). 0 means "this cycle is free or not offered", which
61
+ * `cycleIsOffered` separates.
62
+ */
63
+ export const cycleFloor = (tier: PricedTier, cycle: BillingCycle): number => {
64
+ if (tier.payWhatYouWant) {
65
+ return positive(cycle === "month" ? tier.payWhatYouWantMinimum : tier.payWhatYouWantYearlyMinimum);
66
+ }
67
+ return positive(cycle === "month" ? tier.priceMonthly : tier.priceYearly);
68
+ };
69
+
70
+ /**
71
+ * The operator's ceiling for a pay-what-you-want cycle, or `null` for none.
72
+ *
73
+ * Exposed for display only. It is deliberately NOT applied to the amount that
74
+ * is sent: neither the client app nor the server enforces a membership PWYW
75
+ * maximum today, and clamping here would charge less than the total the fan
76
+ * just read on screen.
77
+ */
78
+ export const cycleCeiling = (tier: PricedTier, cycle: BillingCycle): number | null => {
79
+ if (!tier.payWhatYouWant) return null;
80
+ const raw = cycle === "month" ? tier.payWhatYouWantMaximum : tier.payWhatYouWantYearlyMaximum;
81
+ const value = positive(raw);
82
+ return value > 0 ? value : null;
83
+ };
84
+
85
+ /** Does the tier sell this cycle at all? A cycle with no price is not on offer. */
86
+ export const cycleIsOffered = (tier: PricedTier, cycle: BillingCycle): boolean => cycleFloor(tier, cycle) > 0;
87
+
88
+ /** Both answers at once, for rendering the billing-cycle radios. */
89
+ export const offeredCycles = (tier: PricedTier): { month: boolean; year: boolean } => ({
90
+ month: cycleIsOffered(tier, "month"),
91
+ year: cycleIsOffered(tier, "year"),
92
+ });
93
+
94
+ /**
95
+ * The cycle a tier opens on: monthly when it is sold, otherwise yearly.
96
+ *
97
+ * A tier that sells NEITHER is a free tier, where the cycle is never sent and
98
+ * only decides whether the summary reads "per month" or "per year".
99
+ */
100
+ export const defaultCycle = (tier: PricedTier): BillingCycle =>
101
+ cycleIsOffered(tier, "month") ? "month" : cycleIsOffered(tier, "year") ? "year" : "month";
102
+
103
+ /**
104
+ * Free means free on THIS cycle: no pay-what-you-want floor and no price.
105
+ * A free tier is activated through `/subscriptions/free`, which takes no
106
+ * amount, so this is the branch that decides which endpoint is called.
107
+ */
108
+ export const cycleIsFree = (tier: PricedTier, cycle: BillingCycle): boolean =>
109
+ !tier.payWhatYouWant && cycleFloor(tier, cycle) === 0;
110
+
111
+ /** What the amount box starts at: the floor for the cycle. */
112
+ export const defaultChosenAmount = (tier: PricedTier, cycle: BillingCycle): number =>
113
+ tier.payWhatYouWant ? cycleFloor(tier, cycle) : 0;
114
+
115
+ /** Lift a fan-entered figure up to the floor. Never lowers it to a ceiling. */
116
+ export const clampMembershipAmount = (tier: PricedTier, cycle: BillingCycle, chosen: number | null | undefined): number => {
117
+ const floor = cycleFloor(tier, cycle);
118
+ const parsed = typeof chosen === "number" ? chosen : Number(chosen ?? 0);
119
+ if (!Number.isFinite(parsed) || parsed < floor) return floor;
120
+ return parsed;
121
+ };
122
+
123
+ /**
124
+ * The number to SEND for a paid subscription, in settlement-currency major
125
+ * units.
126
+ *
127
+ * A fixed tier ignores `chosen` exactly as the server does, so a stale amount
128
+ * left in state after switching from a pay-what-you-want tier cannot change
129
+ * what is charged.
130
+ */
131
+ export const resolveSubscriptionAmount = (
132
+ tier: PricedTier,
133
+ cycle: BillingCycle,
134
+ chosen: number | null | undefined,
135
+ ): number => (tier.payWhatYouWant ? clampMembershipAmount(tier, cycle, chosen) : cycleFloor(tier, cycle));
136
+
137
+ /** Why an amount cannot be sent. `null` when it can. */
138
+ export type MembershipAmountRefusal =
139
+ | { reason: "below_minimum"; minimum: number }
140
+ | { reason: "not_positive" };
141
+
142
+ /**
143
+ * The check that runs before the request, not after the 400.
144
+ *
145
+ * Two refusals, and the second is the one that bites hardest: a
146
+ * pay-what-you-want box that is still on its initial 0, or one the fan cleared,
147
+ * resolves to 0 on a tier whose floor is 0, and the server answers
148
+ * `amount_must_be_positive`. Saying so here turns an unexplained failure into a
149
+ * field error next to the field.
150
+ */
151
+ export const refuseMembershipAmount = (
152
+ tier: PricedTier,
153
+ cycle: BillingCycle,
154
+ chosen: number | null | undefined,
155
+ ): MembershipAmountRefusal | null => {
156
+ if (cycleIsFree(tier, cycle)) return null;
157
+ const floor = cycleFloor(tier, cycle);
158
+ if (tier.payWhatYouWant) {
159
+ const parsed = typeof chosen === "number" ? chosen : Number(chosen ?? 0);
160
+ if (!Number.isFinite(parsed) || parsed < floor) return { reason: "below_minimum", minimum: floor };
161
+ }
162
+ if (resolveSubscriptionAmount(tier, cycle, chosen) <= 0) return { reason: "not_positive" };
163
+ return null;
164
+ };
@@ -93,3 +93,40 @@ export function ticketSubtotals(input: {
93
93
  }
94
94
  return { paid, floor };
95
95
  }
96
+
97
+ /**
98
+ * The `amounts` map to SEND for a selection, keyed by ticket id.
99
+ *
100
+ * Only PWYW tiers with a quantity appear, and only with a POSITIVE amount. That
101
+ * last rule is not a tidy-up: the server takes `amounts` as
102
+ * `z.number().positive()`, so a zero answers
103
+ *
104
+ * amounts.<id>: Amount must be a positive number
105
+ *
106
+ * and the checkout fails outright. It is reachable on any PWYW tier whose floor
107
+ * is zero, a free "pay what you like" ticket: with no suggestion
108
+ * `pwywDefaultAmount` returns the floor, and `clampChosenAmount` returns the
109
+ * floor for anything below it, so both paths hand back 0 and the buyer cannot
110
+ * check out at all.
111
+ *
112
+ * Omitting the key says what the 0 was trying to say. The server reads a missing
113
+ * entry as `NaN`, fails `Number.isFinite`, and falls through to the tier's list
114
+ * price, which for such a tier IS zero.
115
+ */
116
+ export function checkoutAmounts(input: {
117
+ tickets: ITicket[];
118
+ quantities: Record<string, number>;
119
+ /** What the buyer typed, where they typed anything. */
120
+ chosen: Record<string, number | null | undefined>;
121
+ }): Record<string, number> {
122
+ const out: Record<string, number> = {};
123
+ for (const ticket of input.tickets) {
124
+ if (!isPayWhatYouWant(ticket) || (input.quantities[ticket.id] ?? 0) <= 0) continue;
125
+ const amount =
126
+ input.chosen[ticket.id] != null
127
+ ? resolveUnitPrice(ticket, input.chosen[ticket.id])
128
+ : pwywDefaultAmount(ticket);
129
+ if (amount > 0) out[ticket.id] = amount;
130
+ }
131
+ return out;
132
+ }