@12-apps/payments-frontend 3.22.0 → 3.23.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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/checkout-actions.ts +55 -9
  3. package/src/components/checkout/checkout-flow.tsx +15 -7
  4. package/src/components/checkout/confirmation-wait.ts +97 -0
  5. package/src/components/checkout/en-US.ts +8 -0
  6. package/src/components/checkout/failure-codes.ts +82 -0
  7. package/src/components/checkout/hosted-store.ts +23 -1
  8. package/src/components/checkout/payment-error-panel.tsx +9 -3
  9. package/src/components/checkout/pix-view.tsx +97 -8
  10. package/src/components/checkout/poll-loop.ts +5 -3
  11. package/src/components/checkout/pt-BR.ts +9 -0
  12. package/src/components/checkout/types.ts +11 -0
  13. package/src/components/checkout/use-card-checkout.ts +3 -2
  14. package/src/components/checkout/use-checkout-controller.ts +19 -5
  15. package/src/components/checkout/use-payment-polling.ts +58 -7
  16. package/src/components/checkout/use-wallet-charge.ts +24 -1
  17. package/src/components/checkout/view-copy.ts +38 -0
  18. package/src/components/checkout/wallet-pane.tsx +6 -1
  19. package/src/flows/catalog-exit.ts +33 -0
  20. package/src/flows/create-payment-flows.tsx +12 -1
  21. package/src/flows/pipeline/actions.tsx +104 -0
  22. package/src/flows/pipeline/admission.ts +55 -0
  23. package/src/flows/pipeline/context.ts +140 -0
  24. package/src/flows/pipeline/derive-step.ts +234 -0
  25. package/src/flows/pipeline/engine-actions.ts +257 -0
  26. package/src/flows/pipeline/engine-chrome.tsx +123 -0
  27. package/src/flows/pipeline/engine-state.ts +107 -0
  28. package/src/flows/pipeline/engine.tsx +377 -0
  29. package/src/flows/pipeline/methods.ts +71 -0
  30. package/src/flows/pipeline/refusal-routing.ts +106 -0
  31. package/src/flows/pipeline/slices.ts +110 -0
  32. package/src/flows/pipeline/stable-plugins.ts +72 -0
  33. package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
  34. package/src/flows/pipeline/steps/index.ts +54 -0
  35. package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
  36. package/src/flows/pipeline/steps/status-step.tsx +41 -0
  37. package/src/flows/pipeline/types.ts +232 -0
  38. package/src/flows/public.ts +78 -0
  39. package/src/flows/screens-hosted.tsx +37 -4
  40. package/src/flows/screens-pay.tsx +6 -1
  41. package/src/flows/types.ts +25 -2
  42. package/src/index.ts +7 -19
@@ -0,0 +1,107 @@
1
+ /**
2
+ * THE ENGINE'S OWN STATE, and the one writer of each fact (FUT-1240).
3
+ *
4
+ * Everything here is state a STEP reads and only the engine writes. Which step
5
+ * the shopper is on is deliberately NOT among them — that is derived (see
6
+ * `derive-step.ts`), which is the whole point of the pipeline.
7
+ *
8
+ * `slices` lives here rather than inside each step because the engine owns the
9
+ * storage: one `useState` for every step's scrap, so the number of hooks does
10
+ * not depend on how many steps a host registered.
11
+ */
12
+ import { useCallback, useMemo, useState } from "react";
13
+
14
+ import type { CheckoutDecline } from "../../components/checkout/decline";
15
+ import type {
16
+ BuyerInfo,
17
+ CheckoutError,
18
+ CheckoutOrder,
19
+ OrderStatus,
20
+ } from "../../components/checkout/types";
21
+
22
+ import { initialSlices, writeSlice } from "./slices";
23
+ import type { AnyCheckoutStep, CheckoutContext } from "./types";
24
+
25
+ /** What one visit accumulates. */
26
+ export interface EngineState {
27
+ buyer: BuyerInfo;
28
+ /**
29
+ * The shopper has edited the buyer form, so the host's saved defaults must
30
+ * stop overwriting it. Until they have, the defaults are adopted live —
31
+ * a profile that arrives after mount still prefills the form it is for.
32
+ */
33
+ buyerTouched: boolean;
34
+ saveProfile: boolean;
35
+ order: CheckoutOrder | null;
36
+ outcome: OrderStatus | null;
37
+ decline: CheckoutDecline | null;
38
+ /** The refusal the engine holds, routed to whoever claimed its code. */
39
+ error: CheckoutError | null;
40
+ placing: boolean;
41
+ slices: Record<string, unknown>;
42
+ /** A step the shopper pressed BACK into; it wins while it still applies. */
43
+ reopened: string | null;
44
+ }
45
+
46
+ /** The state, plus the only two ways to change it. */
47
+ export interface EngineStore {
48
+ state: EngineState;
49
+ patch(next: Partial<EngineState>): void;
50
+ setSlice(stepId: string, value: unknown): void;
51
+ }
52
+
53
+ /** Everything at rest, with each step's slice rehydrated or freshly built. */
54
+ function freshState(
55
+ steps: readonly AnyCheckoutStep[],
56
+ ctx: CheckoutContext,
57
+ ): EngineState {
58
+ return {
59
+ buyer: ctx.buyer,
60
+ buyerTouched: false,
61
+ // The "salvar meus dados" consent starts checked, exactly as the flat
62
+ // controller's does — the checkbox is the shopper's way to say otherwise.
63
+ saveProfile: true,
64
+ order: null,
65
+ outcome: null,
66
+ decline: null,
67
+ error: null,
68
+ placing: false,
69
+ slices: initialSlices(steps, ctx),
70
+ reopened: null,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The engine's store.
76
+ *
77
+ * `steps` and the initial `ctx` are read ONCE, in the lazy initializer: the
78
+ * slices a step declared are seeded from storage at mount, and re-seeding them
79
+ * on a later render would undo whatever the shopper has since done.
80
+ */
81
+ export function useEngineStore(
82
+ steps: readonly AnyCheckoutStep[],
83
+ ctx: CheckoutContext,
84
+ ): EngineStore {
85
+ const [state, setState] = useState<EngineState>(() => freshState(steps, ctx));
86
+ const tenantSlug = ctx.tenantSlug;
87
+ const patch = useCallback((next: Partial<EngineState>) => {
88
+ setState((current) => ({ ...current, ...next }));
89
+ }, []);
90
+ const setSlice = useCallback(
91
+ (stepId: string, value: unknown) => {
92
+ const step = steps.find((entry) => entry.id === stepId);
93
+ if (step) writeSlice(step, tenantSlug, value);
94
+ setState((current) => ({ ...current, slices: { ...current.slices, [stepId]: value } }));
95
+ },
96
+ [steps, tenantSlug],
97
+ );
98
+ return useMemo(() => ({ state, patch, setSlice }), [state, patch, setSlice]);
99
+ }
100
+
101
+ /**
102
+ * The buyer the walk should show: the shopper's own edits once they have made
103
+ * any, and the host's saved defaults until then.
104
+ */
105
+ export function effectiveBuyer(state: EngineState, defaults: BuyerInfo): BuyerInfo {
106
+ return state.buyerTouched ? state.buyer : defaults;
107
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * THE ENGINE (FUT-1240) — a checkout whose current step is DERIVED rather than
3
+ * remembered.
4
+ *
5
+ * It owns four things and nothing else: the order the plugins run in, the
6
+ * state a step reads, the walk (`derive-step.ts`), and the one rule no lane is
7
+ * allowed to restate — a settlement method that raises no charge mounts no
8
+ * payment surface. Every pixel below belongs to a step, a gate or a registered
9
+ * method.
10
+ *
11
+ * ## Hook order is a property of the ARRAYS
12
+ *
13
+ * Every plugin's `useFacts()` runs here, on every render, in array order —
14
+ * which is what lets a plugin read the host's own hooks at all, and why the
15
+ * arrays must be identity-stable. The `map`s below are hook calls in a loop on
16
+ * purpose; `stable-plugins.ts` is what makes the loop's length a constant, and
17
+ * it says so loudly in a development build when a host makes it one.
18
+ */
19
+ import { Box } from "@mui/material";
20
+ import { useEffect, useMemo, useState, type JSX, type ReactNode } from "react";
21
+
22
+ import { forgetHostedOrder } from "../../components/checkout/hosted-return";
23
+ import type { SettlementCheckout } from "../../components/checkout/types";
24
+ import type { CheckoutViewCopy } from "../../components/checkout/view-copy";
25
+ import { FlowsProvider, type FlowsRuntime } from "../runtime";
26
+ import { storeCannotCharge } from "../screens-pay";
27
+ import type { CheckoutScreens, PaymentFlows } from "../types";
28
+
29
+ import { PipelineActionsProvider, type PipelineActions } from "./actions";
30
+ import { decideAdmission, resumePending } from "./admission";
31
+ import { useCheckoutBase, withCheckoutState } from "./context";
32
+ import { deriveNav, deriveStep, raisesCharge, sliceFor } from "./derive-step";
33
+ import { useEngineWriters, type PipelineWiring } from "./engine-actions";
34
+ import { EngineChrome, EngineLoading } from "./engine-chrome";
35
+ import { effectiveBuyer, useEngineStore } from "./engine-state";
36
+ import { PACKAGE_METHODS } from "./methods";
37
+ import { errorForStep, refusalOwner, refusalStepOverride } from "./refusal-routing";
38
+ import { clearSlices } from "./slices";
39
+ import { useStablePluginArray } from "./stable-plugins";
40
+ import { methodSliceOf, packageSteps } from "./steps";
41
+ import type { AnyCheckoutGate, CheckoutContext, GateVerdict } from "./types";
42
+
43
+ /** A host that registered no gates admits everyone. Frozen: this array IS hook order. */
44
+ const NO_GATES: readonly AnyCheckoutGate[] = Object.freeze([]);
45
+
46
+ /** One plugin list's facts, in array order. */
47
+ function usePluginFacts(plugins: readonly { useFacts?(): unknown }[]): readonly unknown[] {
48
+ return plugins.map((plugin) => plugin.useFacts?.());
49
+ }
50
+
51
+ /** The same facts, addressed by id — how the walk reads them. */
52
+ function byId(
53
+ plugins: readonly { id: string }[],
54
+ facts: readonly unknown[],
55
+ ): Record<string, unknown> {
56
+ const table: Record<string, unknown> = {};
57
+ plugins.forEach((plugin, at) => {
58
+ table[plugin.id] = facts[at];
59
+ });
60
+ return table;
61
+ }
62
+
63
+ /**
64
+ * The end of a checkout: let the parked entry and the parked slices go.
65
+ *
66
+ * Storage only — the React slices stay for the rest of this visit, so the
67
+ * confirmation still knows which method it is confirming. What must not
68
+ * survive is the NEXT mount finding a finished walk parked and skipping the
69
+ * steps a new purchase needs.
70
+ */
71
+ function useSettled(input: {
72
+ ctx: CheckoutContext;
73
+ wiring: PipelineWiring;
74
+ runtime: FlowsRuntime;
75
+ }): void {
76
+ const { ctx, wiring, runtime } = input;
77
+ const { outcome, order, tenantSlug } = ctx;
78
+ // AWAITING_PAYMENT is TERMINAL for a settlement that raises no charge:
79
+ // nothing is coming to confirm, because placing the order was the whole of it.
80
+ const charges = raisesCharge(ctx.method, wiring.methods);
81
+ const settled = outcome !== null && (outcome !== "AWAITING_PAYMENT" || !charges);
82
+ const onSettled = runtime.config.onSettled;
83
+ const onPaid = runtime.config.ports.onPaid;
84
+ const steps = wiring.steps;
85
+ useEffect(() => {
86
+ if (!settled || outcome === null) return;
87
+ forgetHostedOrder(tenantSlug);
88
+ clearSlices(steps, tenantSlug);
89
+ // PAID only: a FAILED or EXPIRED shopper still has a basket to retry with,
90
+ // and the host must not be told otherwise.
91
+ if (outcome === "PAID") onPaid?.();
92
+ if (order) onSettled?.(outcome, order);
93
+ }, [settled, outcome, order, tenantSlug, steps, onPaid, onSettled]);
94
+ }
95
+
96
+ /** The walk, once admission has passed. Split out for the size gate. */
97
+ function EngineWalk(props: {
98
+ runtime: FlowsRuntime;
99
+ screens: CheckoutScreens;
100
+ wiring: PipelineWiring;
101
+ ctx: CheckoutContext;
102
+ store: ReturnType<typeof useEngineStore>;
103
+ stepFacts: Readonly<Record<string, unknown>>;
104
+ offered: PipelineActions["offered"];
105
+ }): ReactNode {
106
+ const { runtime, screens, wiring, ctx, store, stepFacts, offered } = props;
107
+ const owner = refusalOwner(store.state.error?.code, wiring.steps, wiring.gates);
108
+ const derived = deriveStep({
109
+ steps: wiring.steps,
110
+ ctx,
111
+ facts: stepFacts,
112
+ methods: wiring.methods,
113
+ reopened: refusalStepOverride(store.state.error, owner) ?? store.state.reopened,
114
+ });
115
+ const writers = useEngineWriters({ runtime, wiring, store, ctx, stepFacts });
116
+ const nav = deriveNav({
117
+ applying: derived.applying,
118
+ index: derived.index,
119
+ taxIdOnFile: ctx.taxIdOnFile,
120
+ terminal: ctx.outcome !== null,
121
+ ports: writers,
122
+ });
123
+ const copy = runtime.config.copy.views;
124
+ const actions = usePipelineActionsValue({
125
+ screens,
126
+ copy,
127
+ wiring,
128
+ offered,
129
+ store,
130
+ writers,
131
+ editBuyer: nav.editBuyer,
132
+ });
133
+ const step = derived.step;
134
+ return (
135
+ <PipelineActionsProvider actions={actions}>
136
+ <Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 2, sm: 3 } }}>
137
+ <EngineChrome
138
+ copy={copy}
139
+ applying={derived.applying}
140
+ currentId={step?.id ?? null}
141
+ first={derived.index <= 0}
142
+ onBack={nav.back}
143
+ />
144
+ {step === null ? (
145
+ <EngineLoading copy={copy} />
146
+ ) : (
147
+ step.render({
148
+ ctx,
149
+ facts: stepFacts[step.id],
150
+ slice: sliceFor(step, ctx),
151
+ setSlice: (value: unknown) => store.setSlice(step.id, value),
152
+ advance: () => store.patch({ reopened: null }),
153
+ back: nav.back,
154
+ error: errorForStep(store.state.error, owner, step.id, step.id),
155
+ })
156
+ )}
157
+ </Box>
158
+ </PipelineActionsProvider>
159
+ );
160
+ }
161
+
162
+ /** What a step's render reads, assembled once per render. */
163
+ function usePipelineActionsValue(input: {
164
+ screens: CheckoutScreens;
165
+ copy: CheckoutViewCopy;
166
+ wiring: PipelineWiring;
167
+ offered: PipelineActions["offered"];
168
+ store: ReturnType<typeof useEngineStore>;
169
+ writers: ReturnType<typeof useEngineWriters>;
170
+ editBuyer: (() => void) | undefined;
171
+ }): PipelineActions {
172
+ const { screens, copy, wiring, offered, store, writers, editBuyer } = input;
173
+ return useMemo<PipelineActions>(
174
+ () => ({
175
+ screens,
176
+ copy,
177
+ methods: wiring.methods,
178
+ offered,
179
+ placing: store.state.placing,
180
+ saveProfile: store.state.saveProfile,
181
+ error: store.state.error,
182
+ editBuyer,
183
+ choose: writers.choose,
184
+ place: writers.place,
185
+ continueFromDados: writers.continueFromDados,
186
+ setBuyer: writers.setBuyer,
187
+ setSaveProfile: writers.setSaveProfile,
188
+ resolve: writers.resolve,
189
+ adoptOrder: writers.adoptOrder,
190
+ exitToCatalog: writers.exitToCatalog,
191
+ }),
192
+ [screens, copy, wiring.methods, offered, store.state, editBuyer, writers],
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Whether a hand-off from this tab was still waiting WHEN THIS CHECKOUT
198
+ * MOUNTED.
199
+ *
200
+ * Asked once, and the "once" is the whole of it. The resume CONSUMES the
201
+ * parked entry — that is what makes a resume happen exactly once — so a second
202
+ * read a render later answers "nothing is pending" about the very shopper
203
+ * being resumed. Every caller of this answer would then flip mid-visit: gates
204
+ * that stood aside would close, and the empty-cart screen would land on top of
205
+ * a confirmation for money that already moved.
206
+ */
207
+ function useResumePending(ctx: CheckoutContext): boolean {
208
+ const [pending] = useState(() => resumePending(ctx));
209
+ return pending;
210
+ }
211
+
212
+ /** The engine body: facts, admission, state, then the walk. */
213
+ function EngineBody(props: {
214
+ runtime: FlowsRuntime;
215
+ screens: CheckoutScreens;
216
+ wiring: PipelineWiring;
217
+ settlement?: SettlementCheckout | null;
218
+ }): ReactNode {
219
+ const { runtime, screens, wiring } = props;
220
+ useStablePluginArray("steps", runtime.config.steps);
221
+ useStablePluginArray("gates", runtime.config.gates);
222
+ useStablePluginArray("settlementMethods", runtime.config.settlementMethods);
223
+ const base = useCheckoutBase(runtime, runtime.config);
224
+ const stepFactList = usePluginFacts(wiring.steps);
225
+ const gateFacts = usePluginFacts(wiring.gates);
226
+ const methodFacts = usePluginFacts(wiring.methods);
227
+ const store = useEngineStore(wiring.steps, base.ctx);
228
+ const stepFacts = byId(wiring.steps, stepFactList);
229
+ // Whatever order this visit is about, in the same priority the context
230
+ // resolves: the one just raised, then the one the host's open-payable read
231
+ // or the park answered with.
232
+ const inFlight = store.state.order ?? base.ctx.order;
233
+ const ctx = withCheckoutState(
234
+ { ...base.ctx, settlement: props.settlement ?? base.ctx.settlement },
235
+ {
236
+ buyer: effectiveBuyer(store.state, base.ctx.buyer),
237
+ // ONE writer: the chosen method IS the method step's slice. An order
238
+ // this shopper never chose for — resumed from a park, or answered by
239
+ // the server's own open payable — speaks for itself, which is what puts
240
+ // them back in front of the pane that still has what they need.
241
+ method: methodSliceOf(store.state.slices).chosen ?? inFlight?.method ?? null,
242
+ order: store.state.order,
243
+ outcome: store.state.outcome,
244
+ slices: store.state.slices,
245
+ },
246
+ );
247
+ const offered = wiring.methods.filter((method, at) => method.offered(ctx, methodFacts[at]));
248
+ useSettled({ ctx, wiring, runtime });
249
+ const resuming = useResumePending(ctx);
250
+ const admission = decideAdmission({ gates: wiring.gates, facts: gateFacts, ctx, resuming });
251
+ // Read BEFORE the early returns below. `runtime.useAvailability()` wraps the
252
+ // host's `ports.useAvailability` and is therefore a hook: leaving it at its
253
+ // only use site put it after `refused`, so a gate answering `pending` and then
254
+ // `pass` changed this component's hook count between renders and React threw
255
+ // "Rendered more hooks than during the previous render". Nothing caught it —
256
+ // eslint-plugin-react-hooks only reads `X.useFoo()` as a hook when X is
257
+ // PascalCase, and `runtime` is not.
258
+ const payable = runtime.useAvailability().payable;
259
+ const refused = admittedOrScreen(admission, ctx, store, runtime.config.copy.views);
260
+ if (refused !== null) return refused;
261
+ if (storeCannotCharge(ctx.config, ctx.configPending, payable)) {
262
+ return <screens.PaymentsUnavailable />;
263
+ }
264
+ if (nothingToPayFor(ctx, resuming)) return <screens.EmptyCart />;
265
+ return (
266
+ <EngineWalk
267
+ runtime={runtime}
268
+ screens={screens}
269
+ wiring={wiring}
270
+ ctx={ctx}
271
+ store={store}
272
+ stepFacts={stepFacts}
273
+ offered={offered}
274
+ />
275
+ );
276
+ }
277
+
278
+ /** A gate's verdict, as something to render — or `null` to carry on. */
279
+ function admittedOrScreen(
280
+ admission: GateVerdict,
281
+ ctx: CheckoutContext,
282
+ store: ReturnType<typeof useEngineStore>,
283
+ copy: CheckoutViewCopy,
284
+ ): ReactNode {
285
+ if (admission.kind === "pass") return null;
286
+ // PENDING is not a refusal: a gate that has not heard back yet must not put
287
+ // a curtain in front of a shopper it may be about to admit.
288
+ if (admission.kind === "pending") return <EngineLoading copy={copy} />;
289
+ const Screen = admission.Screen;
290
+ return (
291
+ <Screen
292
+ ctx={ctx}
293
+ error={store.state.error}
294
+ retry={() => store.patch({ error: null, decline: null, reopened: null })}
295
+ />
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Nothing to check out — and the cart has ANSWERED, which is the half FUT-1213
301
+ * added. A settlement pays already-sent items, so its cart is legitimately
302
+ * empty; and the guard holds only until an order exists, after which the
303
+ * order's own lines speak for it.
304
+ *
305
+ * It also stands aside for a shopper coming back from a payment, and that
306
+ * clause is load-bearing rather than defensive: the server empties a paid
307
+ * cart, so an EMPTY basket is exactly what a buyer who paid comes back to.
308
+ * Without it the one shopper whose confirmation matters most would meet "seu
309
+ * carrinho está vazio" instead of the receipt for the money they just sent —
310
+ * the flat flow avoids that only by resuming inside a LAYOUT effect, which a
311
+ * derived walk has no equivalent of.
312
+ */
313
+ function nothingToPayFor(ctx: CheckoutContext, resuming: boolean): boolean {
314
+ if (resuming || ctx.settlement || !ctx.cart.empty) return false;
315
+ if (ctx.cart.identity?.ready === false) return false;
316
+ return ctx.order === null && ctx.outcome === null;
317
+ }
318
+
319
+ /** What `createPaymentFlows` mounts when a host asked for the pipeline. */
320
+ interface PipelineBundle {
321
+ Checkout: PaymentFlows["Checkout"];
322
+ useAdmission(): GateVerdict;
323
+ }
324
+
325
+ /**
326
+ * Build one factory's engine.
327
+ *
328
+ * Every array here is a FACTORY-SCOPE constant — the package's own steps and
329
+ * methods merged once with the host's — because hook order is a function of
330
+ * their membership. A host that hands over a fresh array per render is told
331
+ * so, loudly, in a development build.
332
+ */
333
+ export function buildPipeline(
334
+ runtime: FlowsRuntime,
335
+ screens: CheckoutScreens,
336
+ ): PipelineBundle {
337
+ const methods = Object.freeze([
338
+ ...PACKAGE_METHODS,
339
+ ...(runtime.config.settlementMethods ?? []),
340
+ ]);
341
+ const wiring: PipelineWiring = {
342
+ methods,
343
+ steps: Object.freeze([
344
+ ...packageSteps(runtime, methods),
345
+ ...(runtime.config.steps ?? []),
346
+ ]),
347
+ gates: runtime.config.gates ?? NO_GATES,
348
+ };
349
+
350
+ function Checkout({ settlement }: { settlement?: SettlementCheckout | null }): JSX.Element {
351
+ return (
352
+ <FlowsProvider runtime={runtime}>
353
+ <EngineBody
354
+ runtime={runtime}
355
+ screens={screens}
356
+ wiring={wiring}
357
+ {...(settlement === undefined ? {} : { settlement })}
358
+ />
359
+ </FlowsProvider>
360
+ );
361
+ }
362
+
363
+ /**
364
+ * The same gate list, headless — so the cart drawer's CTA and a buy-now
365
+ * button consume the answer the checkout will give rather than a second
366
+ * one of their own.
367
+ */
368
+ function useAdmission(): GateVerdict {
369
+ useStablePluginArray("gates", runtime.config.gates);
370
+ const base = useCheckoutBase(runtime, runtime.config);
371
+ const facts = usePluginFacts(wiring.gates);
372
+ const resuming = useResumePending(base.ctx);
373
+ return decideAdmission({ gates: wiring.gates, facts, ctx: base.ctx, resuming });
374
+ }
375
+
376
+ return { Checkout, useAdmission };
377
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * THE TWO SETTLEMENT METHODS THE PACKAGE ITSELF OWNS (FUT-1240).
3
+ *
4
+ * Pix and card, as descriptor rows — the same two the picker has always
5
+ * offered, said in the vocabulary a host's own method can be said in. Both
6
+ * raise a charge, so both mount a `pay`-phase pane; a host's in-person method
7
+ * (pay the courier, pay the waiter) declares `raisesCharge: false` and the
8
+ * engine mounts none.
9
+ *
10
+ * Nothing here names a vendor. `offered()` asks the SERVER-published chain
11
+ * what this store can charge, exactly as `offeredMethods` has since FUT-698,
12
+ * and a config still in flight fails OPEN for the picker while the server
13
+ * still fails the charge closed.
14
+ */
15
+ import {
16
+ cardPathAvailable,
17
+ offeredMethods,
18
+ } from "../../components/checkout/method-capability";
19
+ import type { PaymentMethod } from "../../components/checkout/types";
20
+
21
+ import type { CheckoutContext, SettlementMethodDescriptor } from "./types";
22
+
23
+ /** The pane step ids the package's own two methods render into. */
24
+ export const PIX_PANE_STEP = "pix";
25
+ export const CARD_PANE_STEP = "card";
26
+
27
+ /** Whether the store's chain declares this method. `null` config ⇒ fail open. */
28
+ function chainOffers(ctx: CheckoutContext, method: PaymentMethod): boolean {
29
+ const offered = offeredMethods(ctx.config);
30
+ return offered === null || offered.includes(method);
31
+ }
32
+
33
+ export const PIX_METHOD: SettlementMethodDescriptor = {
34
+ id: "PIX",
35
+ raisesCharge: true,
36
+ pane: PIX_PANE_STEP,
37
+ offered(ctx) {
38
+ return chainOffers(ctx, "PIX");
39
+ },
40
+ tile(copy) {
41
+ const method = copy.screens.screens.method;
42
+ return { label: method.pixLabel, hint: method.pixDescription };
43
+ },
44
+ };
45
+
46
+ export const CARD_METHOD: SettlementMethodDescriptor = {
47
+ id: "CARD",
48
+ raisesCharge: true,
49
+ pane: CARD_PANE_STEP,
50
+ offered(ctx) {
51
+ // Both halves, the same pair the picker has always asked: the chain
52
+ // DECLARES card, and this browser has some way of producing an instrument
53
+ // for it (or the provider's own page takes it).
54
+ return chainOffers(ctx, "CARD") && cardPathAvailable(ctx.config);
55
+ },
56
+ tile(copy) {
57
+ const method = copy.screens.screens.method;
58
+ return { label: method.cardLabel, hint: method.cardDescription };
59
+ },
60
+ };
61
+
62
+ /** The package's rows, in picker order. Frozen: this array IS hook order. */
63
+ export const PACKAGE_METHODS: readonly SettlementMethodDescriptor[] = Object.freeze([
64
+ PIX_METHOD,
65
+ CARD_METHOD,
66
+ ]);
67
+
68
+ /** Whether an id is one the package's own screens can render a pane for. */
69
+ export function isPackageMethod(id: string | null): id is PaymentMethod {
70
+ return id === "PIX" || id === "CARD";
71
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * WHERE A SERVER REFUSAL IS RE-RENDERED (FUT-1240).
3
+ *
4
+ * Every refusal the checkout POST can answer with carries a machine `code`.
5
+ * A code belongs to whichever plugin can do something about it: the step that
6
+ * owns the field, or the gate that owns the fact. `DELIVERY_ADDRESS_REQUIRED`
7
+ * is the address step's; `STORE_CLOSED` is the open-store gate's. Routed that
8
+ * way, the shopper meets the form or the curtain that can actually change the
9
+ * answer.
10
+ *
11
+ * What this replaces is a single generic retry. A refusal rendered as "não foi
12
+ * possível, tente novamente" beside a button that re-sends the same request is
13
+ * a loop with no exit — the shopper presses it, the server refuses for the same
14
+ * reason, and nothing on the screen names the reason or offers the remedy.
15
+ *
16
+ * ## A code nobody claimed is still shown
17
+ *
18
+ * The fallback is deliberate and is the reason a host can adopt this
19
+ * incrementally: an unclaimed code renders on the CURRENT step as today's
20
+ * refusal. A host that wants the compile-time guarantee declares its own
21
+ * exhaustive owner map (`REFUSAL_OWNERS satisfies Record<Code, …>`); the
22
+ * package keeps the runtime answer for every host that has not.
23
+ *
24
+ * The same fallback carries a code whose owning GATE is not refusing — see
25
+ * {@link errorForStep}. Routing is about finding the screen that can change the
26
+ * answer; when there is no such screen up, the shopper still has to be told.
27
+ */
28
+ import type { CheckoutError } from "../../components/checkout/types";
29
+
30
+ import type { AnyCheckoutGate, AnyCheckoutStep } from "./types";
31
+
32
+ /** Who answers a refusal code. */
33
+ export type RefusalOwner =
34
+ | { kind: "step"; id: string }
35
+ | { kind: "gate"; id: string }
36
+ | { kind: "retry" };
37
+
38
+ /** Nothing claimed it — the current step shows it, exactly as today. */
39
+ const RETRY: RefusalOwner = { kind: "retry" };
40
+
41
+ /**
42
+ * The plugin whose `answersCodes` names this code.
43
+ *
44
+ * STEPS are asked first. A gate and a step can both legitimately claim a code
45
+ * — a delivery gate that refuses an unserviceable address and an address step
46
+ * that owns the field — and when they do, the one the shopper can TYPE INTO is
47
+ * the more useful screen.
48
+ */
49
+ export function refusalOwner(
50
+ code: string | null | undefined,
51
+ steps: readonly AnyCheckoutStep[],
52
+ gates: readonly AnyCheckoutGate[],
53
+ ): RefusalOwner {
54
+ if (!code) return RETRY;
55
+ const step = steps.find((entry) => entry.answersCodes?.includes(code));
56
+ if (step) return { kind: "step", id: step.id };
57
+ const gate = gates.find((entry) => entry.answersCodes?.includes(code));
58
+ if (gate) return { kind: "gate", id: gate.id };
59
+ return RETRY;
60
+ }
61
+
62
+ /**
63
+ * The error THIS step should render, or `null`.
64
+ *
65
+ * A step-claimed refusal reaches its claimant and nobody else, so a step never
66
+ * renders a complaint about a field it does not draw. Everything else reaches
67
+ * whichever step the shopper is on.
68
+ *
69
+ * ## A GATE's code reaching a step means that gate is PASSING
70
+ *
71
+ * A gate that would refuse curtains the checkout before any step renders, and
72
+ * its own `Screen` is handed the error there — so the walk is reached only with
73
+ * every gate passing, and the only gate-claimed refusal a step ever sees is one
74
+ * no curtain is going to draw. Dropping it here loses it completely: the
75
+ * shopper presses pay, the server refuses, and nothing on the screen changes or
76
+ * names the reason. So it falls back to the unclaimed behaviour instead, which
77
+ * is the one that always reaches somebody.
78
+ *
79
+ * That is not a weaker routing than "a gate's code re-renders the gate" — it is
80
+ * the same routing, with the case the gate cannot answer handed on rather than
81
+ * swallowed. A gate that DOES want the screen refuses, and then it gets it.
82
+ */
83
+ export function errorForStep(
84
+ error: CheckoutError | null,
85
+ owner: RefusalOwner,
86
+ stepId: string,
87
+ currentStepId: string | null,
88
+ ): CheckoutError | null {
89
+ if (!error) return null;
90
+ if (owner.kind === "step") return owner.id === stepId ? error : null;
91
+ return stepId === currentStepId ? error : null;
92
+ }
93
+
94
+ /**
95
+ * A refusal claimed by a STEP re-opens that step, whichever one the shopper
96
+ * was on. That is the whole of "re-rendered THERE": the answer moves the
97
+ * shopper to the screen that can change it, rather than describing it where
98
+ * they happen to be standing.
99
+ */
100
+ export function refusalStepOverride(
101
+ error: CheckoutError | null,
102
+ owner: RefusalOwner,
103
+ ): string | null {
104
+ if (!error || owner.kind !== "step") return null;
105
+ return owner.id;
106
+ }