@tribe-nest/forge 3.53.0 → 3.54.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useEvents.ts +14 -0
  7. package/src/data/queries/useMyTickets.ts +49 -0
  8. package/src/data/queries/usePaymentFlow.ts +8 -0
  9. package/src/i18n/de.json +42 -1
  10. package/src/i18n/en.json +42 -1
  11. package/src/i18n/index.ts +3 -2
  12. package/src/index.ts +22 -0
  13. package/src/provider/ForgeAppProvider.tsx +8 -1
  14. package/src/provider/ForgeProvider.tsx +3 -2
  15. package/src/provider/SiteConfigProvider.tsx +3 -2
  16. package/src/runtime/RemotePage.tsx +110 -0
  17. package/src/runtime/hostRuntime.ts +84 -0
  18. package/src/runtime/pages.spec.ts +41 -0
  19. package/src/runtime/pages.ts +183 -0
  20. package/src/runtime/pagesClient.ts +127 -0
  21. package/src/runtime/registry.spec.ts +45 -0
  22. package/src/runtime/registry.ts +102 -0
  23. package/src/server/jobs.ts +4 -1
  24. package/src/server/platformEvents.generated.ts +92 -0
  25. package/src/types/models.ts +58 -0
  26. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  27. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  28. package/src/ui/headless/event/ticketApproval.ts +109 -0
  29. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  30. package/src/ui/headless/index.ts +11 -0
  31. package/src/ui/index.ts +18 -0
  32. package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
  33. package/src/ui/payment/ForgeStripePayment.tsx +37 -3
  34. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  35. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  36. package/src/ui/shell/TribeNestApp.tsx +6 -0
  37. package/src/ui/styled/AccountDashboard.tsx +79 -13
  38. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  39. package/src/ui/styled/EventConfirmation.tsx +106 -9
  40. package/src/ui/styled/EventTickets.tsx +107 -13
  41. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  42. package/src/ui/styled/forge-utilities.css +310 -0
  43. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  44. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  45. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -0,0 +1,109 @@
1
+ import type { IEvent, ITicket } from "../../../types/models";
2
+
3
+ /**
4
+ * Ticket requests (curated access, feature 3): the pure decisions behind the
5
+ * picker and the checkout, kept out of the hook so they can be tested without
6
+ * standing up the providers the hook needs.
7
+ *
8
+ * A tier with `requiresApproval` is not sold, it is REQUESTED: the card is
9
+ * committed (authorised) and the seat held, and the organiser has a window to
10
+ * approve or decline. One order is either a request or a purchase, never
11
+ * both: the API refuses a mixed cart (`errors.event.mixed_approval_cart`), and
12
+ * the picker stops the fan building one in the first place.
13
+ */
14
+
15
+ /** Does this tier need the organiser's approval? Absent on an older API build means no. */
16
+ export function tierRequiresApproval(ticket: Pick<ITicket, "requiresApproval"> | undefined): boolean {
17
+ return ticket?.requiresApproval === true;
18
+ }
19
+
20
+ /** Does this event carry any approval-required tier at all? Read off the tiers, not the flag, so a partial payload still answers. */
21
+ export function eventHasApprovalTiers(event: Pick<IEvent, "tickets" | "hasApprovalTiers"> | undefined): boolean {
22
+ if (!event) return false;
23
+ if (event.hasApprovalTiers === true) return true;
24
+ return (event.tickets ?? []).some(tierRequiresApproval);
25
+ }
26
+
27
+ /**
28
+ * Which kind of order the current selection would make. `null` while nothing
29
+ * is selected (either kind is still addable), `"request"` once an approval
30
+ * tier is in, `"purchase"` once an ordinary one is. A mixed selection cannot
31
+ * arise through `tierBlockedByApprovalMix`, but if one is handed in (a stale
32
+ * cache, a tier that changed mode underneath the fan) it reads as a request,
33
+ * because that is the button the server will refuse rather than the one it
34
+ * will silently sell.
35
+ */
36
+ export function selectionMode(
37
+ tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
38
+ quantities: Record<string, number>,
39
+ ): "request" | "purchase" | null {
40
+ let approval = false;
41
+ let ordinary = false;
42
+ for (const ticket of tickets) {
43
+ if ((quantities[ticket.id] ?? 0) <= 0) continue;
44
+ if (tierRequiresApproval(ticket)) approval = true;
45
+ else ordinary = true;
46
+ }
47
+ if (approval) return "request";
48
+ if (ordinary) return "purchase";
49
+ return null;
50
+ }
51
+
52
+ /** The selection contains at least one approval tier, so submitting it lodges a request. */
53
+ export function selectionRequiresApproval(
54
+ tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
55
+ quantities: Record<string, number>,
56
+ ): boolean {
57
+ return selectionMode(tickets, quantities) === "request";
58
+ }
59
+
60
+ /**
61
+ * May this tier be added to the current selection?
62
+ *
63
+ * Blocked when the selection already holds the OTHER kind: an approval tier
64
+ * beside a purchase, or a purchase beside a request. A tier that is already in
65
+ * the selection is never blocked (it is the kind that set the mode), and an
66
+ * empty selection blocks nothing.
67
+ */
68
+ export function tierBlockedByApprovalMix(
69
+ ticket: Pick<ITicket, "id" | "requiresApproval">,
70
+ tickets: readonly Pick<ITicket, "id" | "requiresApproval">[],
71
+ quantities: Record<string, number>,
72
+ ): boolean {
73
+ if ((quantities[ticket.id] ?? 0) > 0) return false;
74
+ const mode = selectionMode(tickets, quantities);
75
+ if (mode === null) return false;
76
+ return tierRequiresApproval(ticket) ? mode === "purchase" : mode === "request";
77
+ }
78
+
79
+ /** The fields of a `POST /public/events/:id/orders` response the routing reads. */
80
+ export interface TicketOrderRoutingInput {
81
+ isFreeCheckout?: boolean;
82
+ awaitingApproval?: boolean;
83
+ totalAmount?: number | string | null;
84
+ }
85
+
86
+ /**
87
+ * Where the checkout goes once the order row exists.
88
+ *
89
+ * complete nothing to pay: a free purchase (passes minted, `isFreeCheckout`)
90
+ * or a FREE REQUEST (already `pending_approval`, `isFreeCheckout`
91
+ * false, `awaitingApproval` true, total 0). Both hand the order id
92
+ * to `onComplete`, and the host navigates to the finalise page,
93
+ * which renders the right outcome off the order's own status.
94
+ * payment there is money on it: start the charge. A priced request runs
95
+ * the payment step exactly as a sale does; only the wording and
96
+ * the settlement differ, and start-payment says which.
97
+ *
98
+ * `isFreeCheckout` wins outright, as it always has. The free-request branch is
99
+ * keyed on the TOTAL, not on `awaitingApproval` alone, because a priced
100
+ * request also carries `awaitingApproval: true` and must still reach the card.
101
+ */
102
+ export function nextStepAfterTicketOrder(order: TicketOrderRoutingInput): "complete" | "payment" {
103
+ if (order.isFreeCheckout) return "complete";
104
+ if (order.awaitingApproval === true) {
105
+ const total = Number(order.totalAmount ?? 0);
106
+ if (!Number.isFinite(total) || total <= 0) return "complete";
107
+ }
108
+ return "payment";
109
+ }
@@ -22,6 +22,12 @@ import { checkoutAmounts, isPayWhatYouWant, resolveUnitPrice, ticketSubtotals }
22
22
  import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
23
23
  import { readAttributionRef } from "../../../utils/attribution";
24
24
  import { readLanding } from "../../../utils/landing";
25
+ import {
26
+ eventHasApprovalTiers,
27
+ nextStepAfterTicketOrder,
28
+ selectionRequiresApproval,
29
+ tierBlockedByApprovalMix,
30
+ } from "./ticketApproval";
25
31
 
26
32
  export type EventCheckoutStep = "tickets" | "details" | "payment";
27
33
 
@@ -32,7 +38,10 @@ export interface UseEventCheckoutOptions {
32
38
  finalisePath?: (slug: string, orderId: string) => string;
33
39
  /** Called when a FREE order completes in-app — the host navigates however it
34
40
  * wants (e.g. its router), instead of Forge doing a `window.location` redirect.
35
- * Omit to keep the default redirect. */
41
+ * Omit to keep the default redirect. Also fired for a FREE TICKET REQUEST
42
+ * (`awaitingApproval` with nothing to pay), which has no payment step either:
43
+ * the finalise page it lands on reads the order's status and renders
44
+ * "request received". */
36
45
  onComplete?: (info: { slug: string; orderId: string }) => void;
37
46
  }
38
47
 
@@ -179,6 +188,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
179
188
  [selectedTickets],
180
189
  );
181
190
 
191
+ /**
192
+ * Ticket requests (curated access, feature 3).
193
+ *
194
+ * `hasApprovalTiers` says the event carries one at all; `awaitingApproval`
195
+ * says THIS selection is a request rather than a purchase, which is what the
196
+ * primary button ("Request tickets") and the payment wording read. One order
197
+ * is either, never both: `isTierBlockedByApprovalMix` is what the picker
198
+ * disables the `+` on, so the server's mixed-cart refusal is never reached.
199
+ */
200
+ const hasApprovalTiers = eventHasApprovalTiers(event);
201
+ const awaitingApproval = useMemo(
202
+ () => selectionRequiresApproval(event?.tickets ?? [], selectedTickets),
203
+ [event, selectedTickets],
204
+ );
205
+ const isTierBlockedByApprovalMix = (ticketId: string) => {
206
+ const ticket = event?.tickets.find((t) => t.id === ticketId);
207
+ return ticket ? tierBlockedByApprovalMix(ticket, event?.tickets ?? [], selectedTickets) : false;
208
+ };
209
+ /** When the request lapses if the organiser does not decide, from the order response. */
210
+ const [requestExpiresAt, setRequestExpiresAt] = useState<string | null>(null);
211
+
182
212
  /**
183
213
  * The artist's booking fee (1.4) — the thing the buyer was charged and never
184
214
  * told about, on any surface, on either rendering stack.
@@ -212,6 +242,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
212
242
  // rejects those with "quantity must be positive").
213
243
  const setTicketQty = (ticketId: string, qty: number) =>
214
244
  setSelectedTickets((prev) => {
245
+ // A request and a purchase cannot share an order. The picker disables
246
+ // the control, and this refuses the write too, so a host that drives the
247
+ // hook from its own UI cannot build the cart the server will refuse.
248
+ if (qty > 0 && (prev[ticketId] ?? 0) === 0 && event) {
249
+ const ticket = event.tickets.find((t) => t.id === ticketId);
250
+ if (ticket && tierBlockedByApprovalMix(ticket, event.tickets, prev)) return prev;
251
+ }
215
252
  const next = { ...prev };
216
253
  if (qty > 0) next[ticketId] = qty;
217
254
  else delete next[ticketId];
@@ -253,6 +290,14 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
253
290
  return false;
254
291
  }
255
292
  if (!event) return false;
293
+ // A request is not a purchase and cannot ride in a bundle: the bundle
294
+ // checkout forces payment and the API refuses approval tiers in one. Sent
295
+ // to the cart it would fail at the end of a longer journey, so it is
296
+ // refused here, and the surface hides the exit for a request anyway.
297
+ if (awaitingApproval) {
298
+ setError("Tickets that need our approval are requested on their own.");
299
+ return false;
300
+ }
256
301
  setError(null);
257
302
  setTickets({
258
303
  eventId: event.id,
@@ -372,12 +417,15 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
372
417
  // legitimately reports 0 — falling back to the estimate there would put a
373
418
  // fee on a comp.
374
419
  setServerFeeAmount(data.feeAmount ?? 0);
420
+ setRequestExpiresAt(data.requestExpiresAt ?? null);
375
421
  const origin = typeof window !== "undefined" ? window.location.origin : "";
376
422
  const ru = `${origin}${finalisePath(slug, data.orderId)}`;
377
423
  setReturnUrl(ru);
378
- if (data.isFreeCheckout) {
379
- // Free order — no payment. Hand navigation to the host if it asked, else
380
- // default to an in-app redirect to the finalise page.
424
+ if (nextStepAfterTicketOrder(data) === "complete") {
425
+ // Nothing to pay: a free order, or a free ticket REQUEST that is already
426
+ // `pending_approval`. Hand navigation to the host if it asked, else
427
+ // default to an in-app redirect to the finalise page, which renders the
428
+ // right outcome off the order's own status.
381
429
  if (opts.onComplete) opts.onComplete({ slug, orderId: data.orderId });
382
430
  else if (typeof window !== "undefined") window.location.href = ru;
383
431
  return;
@@ -436,6 +484,7 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
436
484
  // subtotal, so keeping it would understate the fee on the fresh, full-price
437
485
  // order the next `continueToPayment` creates.
438
486
  setServerFeeAmount(null);
487
+ setRequestExpiresAt(null);
439
488
  setStep("details");
440
489
  };
441
490
 
@@ -493,6 +542,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
493
542
  /** What each selected PWYW tier will actually be charged, per unit. */
494
543
  effectiveAmounts,
495
544
  ticketCount,
545
+ /**
546
+ * Ticket requests (curated access, feature 3). `hasApprovalTiers`: the
547
+ * event carries at least one tier the organiser approves. `awaitingApproval`:
548
+ * THIS selection is a request, so the primary button reads "Request
549
+ * tickets", the pay button "Authorize", and nothing is sold until the
550
+ * organiser decides. `isTierBlockedByApprovalMix(id)`: the tier cannot join
551
+ * the current selection because one order is either a request or a
552
+ * purchase, never both. `requestExpiresAt`: when an undecided request
553
+ * lapses, once the order exists.
554
+ */
555
+ hasApprovalTiers,
556
+ awaitingApproval,
557
+ isTierBlockedByApprovalMix,
558
+ requestExpiresAt,
559
+ /**
560
+ * From start-payment: `"deferred"` on a priced request, so the payment
561
+ * renderer can label the button "Authorize" and say the card is charged
562
+ * only on approval. Undefined on a sale.
563
+ */
564
+ settlement: flow.settlement,
565
+ deferredStrategy: flow.deferredStrategy,
496
566
  // The server's figure wins the moment there is one: start-payment first,
497
567
  // then the order response (which is already net of any discount), and only
498
568
  // the locally summed ticket prices before either exists.
@@ -53,6 +53,17 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
53
53
  // Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
54
54
  // each composed from the data hooks so a creator can rebuild any page.
55
55
  export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
56
+ // Ticket requests (curated access): the pure decisions behind the picker and
57
+ // the post-order routing, for a host that drives the hook from its own UI.
58
+ export {
59
+ eventHasApprovalTiers,
60
+ nextStepAfterTicketOrder,
61
+ selectionMode,
62
+ selectionRequiresApproval,
63
+ tierBlockedByApprovalMix,
64
+ tierRequiresApproval,
65
+ type TicketOrderRoutingInput,
66
+ } from "./event/ticketApproval";
56
67
  export {
57
68
  usePresaleCode,
58
69
  type PresaleCodeField,
package/src/ui/index.ts CHANGED
@@ -73,6 +73,15 @@ export {
73
73
  export { PresaleCodeField, type PresaleCodeFieldProps } from "./styled/PresaleCode";
74
74
  export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
75
75
  export { ForgeStripePayment } from "./payment/ForgeStripePayment";
76
+ // For a custom payment renderer: which post-confirm statuses count as done
77
+ // (a deferred charge stops at `requires_capture`), and the deferred-charge
78
+ // wording decision the built-in renderer makes.
79
+ export {
80
+ deferredChargeNotice,
81
+ isDeferredSettlement,
82
+ isManualConfirmSuccess,
83
+ MANUAL_CONFIRM_SUCCESS_STATUSES,
84
+ } from "./payment/stripeConfirmOutcome";
76
85
  export { Cart, type CartProps } from "./styled/Cart";
77
86
  /** The page a cart-recovery email lands on. Drop in at `/i/checkout/resume`. */
78
87
  export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
@@ -346,3 +355,12 @@ export * from "./styled/work";
346
355
 
347
356
  // Unified member home (`/i/members`) styled blocks.
348
357
  export * from "./styled/members";
358
+
359
+ // The review page a "how did we do?" email lands on. Here rather than in a
360
+ // storefront because both the hosted `/i/reviews/$token` page and every site's
361
+ // root-level `/reviews/$token` alias render it.
362
+ export * from "./styled/ReviewRequestPage";
363
+ // The contract signing page. Here for the same reason: both storefronts render
364
+ // it, at BOTH the canonical `/i/documents/$token` and the older
365
+ // `/documents/$token` that already-sent links point at.
366
+ export * from "./styled/DocumentSigningPage";
@@ -1,3 +1,4 @@
1
+ import type { DeferredChargeStrategy, PaymentSettlement } from "../../types/models";
1
2
  import { createContext, useContext, type ReactNode } from "react";
2
3
  import { ForgeStripePayment } from "./ForgeStripePayment";
3
4
 
@@ -16,6 +17,20 @@ export interface PaymentRenderProps {
16
17
  mode?: "redirect" | "manual";
17
18
  onSucceeded?: () => void;
18
19
  onFailed?: (message?: string) => void;
20
+ /**
21
+ * `"deferred"` when the charge is committed now and taken only if the
22
+ * organiser approves (a ticket request). The built-in renderer then labels
23
+ * the button "Authorize {amount}" and says under it that the card is charged
24
+ * only on approval. Absent on every ordinary sale, so a custom renderer that
25
+ * ignores it keeps working exactly as before.
26
+ */
27
+ settlement?: PaymentSettlement;
28
+ /**
29
+ * How the provider defers: `"authorization"` shows a temporary hold on the
30
+ * fan's statement and the renderer says so; `"saved_instrument"` takes
31
+ * nothing until approval. Only sent with `settlement: "deferred"`.
32
+ */
33
+ deferredStrategy?: DeferredChargeStrategy | null;
19
34
  }
20
35
 
21
36
  export type PaymentRenderer = (props: PaymentRenderProps) => ReactNode;
@@ -5,7 +5,9 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
5
  import { readableTextOn } from "../theme/contrast";
6
6
  import { useFormatCurrency } from "../format/useFormatCurrency";
7
7
  import { useSiteConfig } from "../../data/queries/useWebsite";
8
+ import { useForgeT } from "../../i18n";
8
9
  import type { PaymentRenderProps } from "./ForgePaymentProvider";
10
+ import { deferredChargeNotice, isDeferredSettlement, isManualConfirmSuccess } from "./stripeConfirmOutcome";
9
11
 
10
12
  function alpha(hex: number) {
11
13
  return Math.round(hex * 255)
@@ -30,6 +32,8 @@ export function ForgeStripePayment({
30
32
  mode = "redirect",
31
33
  onSucceeded,
32
34
  onFailed,
35
+ settlement,
36
+ deferredStrategy,
33
37
  }: PaymentRenderProps) {
34
38
  const theme = useForgeTheme();
35
39
  const { data: siteConfig } = useSiteConfig();
@@ -91,7 +95,16 @@ export function ForgeStripePayment({
91
95
 
92
96
  return (
93
97
  <Elements options={{ clientSecret, appearance, loader: "auto" }} stripe={stripePromise}>
94
- <Inner returnUrl={returnUrl} amount={amount} currency={currency} mode={mode} onSucceeded={onSucceeded} onFailed={onFailed} />
98
+ <Inner
99
+ returnUrl={returnUrl}
100
+ amount={amount}
101
+ currency={currency}
102
+ mode={mode}
103
+ onSucceeded={onSucceeded}
104
+ onFailed={onFailed}
105
+ settlement={settlement}
106
+ deferredStrategy={deferredStrategy}
107
+ />
95
108
  </Elements>
96
109
  );
97
110
  }
@@ -103,15 +116,26 @@ function Inner({
103
116
  mode = "redirect",
104
117
  onSucceeded,
105
118
  onFailed,
119
+ settlement,
120
+ deferredStrategy,
106
121
  }: Omit<PaymentRenderProps, "clientSecret">) {
107
122
  const theme = useForgeTheme();
123
+ const t = useForgeT();
108
124
  const { formatCurrency } = useFormatCurrency();
109
125
  const stripe = useStripe();
110
126
  const elements = useElements();
111
127
  const [message, setMessage] = useState("");
112
128
  const [isLoading, setIsLoading] = useState(false);
113
129
 
114
- const buttonText = `Pay ${formatCurrency(amount, currency || undefined)} Now`;
130
+ // A deferred charge is not a payment yet: the button says what actually
131
+ // happens when it is pressed, and the line under it says when the money
132
+ // moves. That line is content, not help: a fan reading "Authorize" without
133
+ // it has been told their card was committed and nothing about why.
134
+ const deferred = isDeferredSettlement(settlement);
135
+ const formattedAmount = formatCurrency(amount, currency || undefined);
136
+ const buttonText = deferred
137
+ ? t("forge.forge_stripe_payment.authorize", { amount: formattedAmount })
138
+ : `Pay ${formattedAmount} Now`;
115
139
 
116
140
  const handleSubmit = useCallback(async () => {
117
141
  if (!stripe || !elements) return;
@@ -131,7 +155,9 @@ function Inner({
131
155
  onFailed?.(msg);
132
156
  return;
133
157
  }
134
- if (paymentIntent?.status === "succeeded" || paymentIntent?.status === "processing") {
158
+ // `requires_capture` is a deferred charge that went through: the card is
159
+ // authorised and the platform captures it on approval. See the helper.
160
+ if (isManualConfirmSuccess(paymentIntent?.status)) {
135
161
  setIsLoading(false);
136
162
  onSucceeded?.();
137
163
  return;
@@ -166,6 +192,14 @@ function Inner({
166
192
  >
167
193
  {isLoading ? "Processing…" : buttonText}
168
194
  </button>
195
+ {deferred && (
196
+ <p data-testid="deferred-charge-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
197
+ {t("forge.forge_stripe_payment.deferred_charged_on_approval")}
198
+ {deferredChargeNotice(deferredStrategy) === "authorization" && (
199
+ <> {t("forge.forge_stripe_payment.deferred_hold_on_statement")}</>
200
+ )}
201
+ </p>
202
+ )}
169
203
  </form>
170
204
  );
171
205
  }
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ deferredChargeNotice,
4
+ isDeferredSettlement,
5
+ isManualConfirmSuccess,
6
+ MANUAL_CONFIRM_SUCCESS_STATUSES,
7
+ } from "../stripeConfirmOutcome";
8
+
9
+ /**
10
+ * The one-line fix from the curated-access design (section 6.2, step 2): a
11
+ * deferred charge leaves the PaymentIntent at `requires_capture`, and the
12
+ * manual-mode branch of `ForgeStripePayment` used to read anything that was
13
+ * not `succeeded`/`processing` as "Payment did not complete". The decision is
14
+ * a pure function now, so the renderer cannot drift back.
15
+ */
16
+ describe("isManualConfirmSuccess", () => {
17
+ it("REGRESSION: counts an authorised, uncaptured intent as the fan being done", () => {
18
+ expect(isManualConfirmSuccess("requires_capture")).toBe(true);
19
+ });
20
+
21
+ it("keeps the two statuses it always accepted", () => {
22
+ expect(isManualConfirmSuccess("succeeded")).toBe(true);
23
+ expect(isManualConfirmSuccess("processing")).toBe(true);
24
+ });
25
+
26
+ it("still refuses every status that means the fan is not done", () => {
27
+ for (const status of ["requires_payment_method", "requires_confirmation", "requires_action", "canceled"]) {
28
+ expect(isManualConfirmSuccess(status)).toBe(false);
29
+ }
30
+ expect(isManualConfirmSuccess(undefined)).toBe(false);
31
+ expect(isManualConfirmSuccess(null)).toBe(false);
32
+ expect(isManualConfirmSuccess("")).toBe(false);
33
+ });
34
+
35
+ it("exposes the same set the renderer reads, so a custom renderer can share it", () => {
36
+ expect([...MANUAL_CONFIRM_SUCCESS_STATUSES].sort()).toEqual(["processing", "requires_capture", "succeeded"]);
37
+ });
38
+ });
39
+
40
+ describe("isDeferredSettlement", () => {
41
+ it("is true only for an explicit deferred charge", () => {
42
+ expect(isDeferredSettlement("deferred")).toBe(true);
43
+ expect(isDeferredSettlement("immediate")).toBe(false);
44
+ // An older API build sends nothing, and nothing must render as a sale.
45
+ expect(isDeferredSettlement(undefined)).toBe(false);
46
+ expect(isDeferredSettlement(null)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe("deferredChargeNotice", () => {
51
+ it("promises a visible hold only when the provider actually places one", () => {
52
+ expect(deferredChargeNotice("authorization")).toBe("authorization");
53
+ expect(deferredChargeNotice("saved_instrument")).toBe("saved_instrument");
54
+ });
55
+
56
+ it("falls back to the quieter line for an unknown strategy", () => {
57
+ // Promising a hold that never appears is the larger lie of the two.
58
+ expect(deferredChargeNotice(undefined)).toBe("saved_instrument");
59
+ expect(deferredChargeNotice(null)).toBe("saved_instrument");
60
+ });
61
+ });
@@ -0,0 +1,50 @@
1
+ import type { DeferredChargeStrategy, PaymentSettlement } from "../../types/models";
2
+
3
+ /**
4
+ * The PaymentIntent statuses `confirmPayment` can leave behind that mean "the
5
+ * fan's part is done", in the manual (confirm-in-place) branch of
6
+ * `ForgeStripePayment`.
7
+ *
8
+ * succeeded charged
9
+ * processing a delayed-notification method (bank debit); charged later
10
+ * requires_capture AUTHORISED, not captured: a deferred charge. The platform
11
+ * captures it when the organiser approves a ticket request
12
+ * (curated access, feature 3) and cancels it otherwise.
13
+ *
14
+ * Before `requires_capture` was here a request checkout confirmed the card,
15
+ * Stripe answered exactly what a manual-capture intent answers, and the
16
+ * renderer told the fan "Payment did not complete" while the order was already
17
+ * on its way to `pending_approval`. Anything else (`requires_payment_method`,
18
+ * `requires_action` that did not resolve, `canceled`) is not done.
19
+ */
20
+ export const MANUAL_CONFIRM_SUCCESS_STATUSES: ReadonlySet<string> = new Set([
21
+ "succeeded",
22
+ "processing",
23
+ "requires_capture",
24
+ ]);
25
+
26
+ /** Does this post-confirm PaymentIntent status count as the fan having finished? */
27
+ export function isManualConfirmSuccess(status: string | null | undefined): boolean {
28
+ return !!status && MANUAL_CONFIRM_SUCCESS_STATUSES.has(status);
29
+ }
30
+
31
+ /** A deferred charge: the card is committed now and taken only on approval. */
32
+ export function isDeferredSettlement(settlement: PaymentSettlement | null | undefined): boolean {
33
+ return settlement === "deferred";
34
+ }
35
+
36
+ /**
37
+ * Which explanation goes under the pay button on a deferred charge.
38
+ *
39
+ * `authorization` places a hold the fan will SEE on their statement, so the
40
+ * line has to say so or the first thing they notice is a pending charge they
41
+ * were promised would not happen. `saved_instrument` (a provider that stores
42
+ * the card and charges it later) takes nothing, and the line says only that.
43
+ * An unknown strategy is treated as the quieter of the two: promising a hold
44
+ * that never appears is the lesser lie.
45
+ */
46
+ export function deferredChargeNotice(
47
+ strategy: DeferredChargeStrategy | null | undefined,
48
+ ): "authorization" | "saved_instrument" {
49
+ return strategy === "authorization" ? "authorization" : "saved_instrument";
50
+ }
@@ -16,6 +16,12 @@ import { previewDiagnosticsEnabled, resolvePreviewDiagnostics } from "./diagnost
16
16
  import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
17
17
  import { captureAttributionRefFromUrl, readAttributionRef } from "../../utils/attribution";
18
18
  import { captureLandingFromUrl, postLandingBeacon } from "../../utils/landing";
19
+ import { registerHostRuntime } from "../../runtime/hostRuntime";
20
+
21
+ // Module scope, not a hook. The hosted `/i/*` bundle can be imported by the
22
+ // very first page a visitor lands on, so the runtime it reads has to be
23
+ // published before any component renders, not during one.
24
+ registerHostRuntime();
19
25
 
20
26
  // Captures both attribution signals on first paint: the tracked-link `?tn_ref`
21
27
  // (stripped afterwards) and the first-touch landing snapshot (all utm/click-id