@12-apps/payments-frontend 3.21.4 → 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 (59) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/basket.ts +85 -0
  3. package/src/components/checkout/card-outcome.ts +81 -0
  4. package/src/components/checkout/card-view.tsx +62 -22
  5. package/src/components/checkout/checkout-actions.ts +387 -0
  6. package/src/components/checkout/checkout-flow.tsx +126 -24
  7. package/src/components/checkout/checkout-steps.tsx +149 -174
  8. package/src/components/checkout/checkout-totals.tsx +51 -0
  9. package/src/components/checkout/client-context.tsx +3 -0
  10. package/src/components/checkout/confirmation-wait.ts +97 -0
  11. package/src/components/checkout/dados-step.tsx +141 -0
  12. package/src/components/checkout/decline.ts +48 -0
  13. package/src/components/checkout/en-US.ts +41 -0
  14. package/src/components/checkout/failure-codes.ts +82 -0
  15. package/src/components/checkout/hosted-return.ts +190 -206
  16. package/src/components/checkout/hosted-store.ts +291 -0
  17. package/src/components/checkout/payment-error-panel.tsx +9 -3
  18. package/src/components/checkout/payment-status-parts.tsx +311 -0
  19. package/src/components/checkout/payment-status.tsx +69 -264
  20. package/src/components/checkout/pix-view.tsx +97 -8
  21. package/src/components/checkout/poll-loop.ts +5 -3
  22. package/src/components/checkout/providers/types.ts +20 -3
  23. package/src/components/checkout/pt-BR.ts +42 -0
  24. package/src/components/checkout/screens-copy.ts +14 -0
  25. package/src/components/checkout/screens-en-US.ts +1 -0
  26. package/src/components/checkout/screens-pt-BR.ts +3 -0
  27. package/src/components/checkout/transport.ts +21 -1
  28. package/src/components/checkout/types.ts +35 -0
  29. package/src/components/checkout/use-card-checkout.ts +34 -33
  30. package/src/components/checkout/use-checkout-controller.ts +68 -274
  31. package/src/components/checkout/use-hosted-resume.ts +326 -0
  32. package/src/components/checkout/use-payment-polling.ts +58 -7
  33. package/src/components/checkout/use-wallet-charge.ts +24 -1
  34. package/src/components/checkout/view-copy.ts +70 -0
  35. package/src/components/checkout/wallet-pane.tsx +9 -1
  36. package/src/flows/catalog-exit.ts +33 -0
  37. package/src/flows/create-payment-flows.tsx +19 -1
  38. package/src/flows/pipeline/actions.tsx +104 -0
  39. package/src/flows/pipeline/admission.ts +55 -0
  40. package/src/flows/pipeline/context.ts +140 -0
  41. package/src/flows/pipeline/derive-step.ts +234 -0
  42. package/src/flows/pipeline/engine-actions.ts +257 -0
  43. package/src/flows/pipeline/engine-chrome.tsx +123 -0
  44. package/src/flows/pipeline/engine-state.ts +107 -0
  45. package/src/flows/pipeline/engine.tsx +377 -0
  46. package/src/flows/pipeline/methods.ts +71 -0
  47. package/src/flows/pipeline/refusal-routing.ts +106 -0
  48. package/src/flows/pipeline/slices.ts +110 -0
  49. package/src/flows/pipeline/stable-plugins.ts +72 -0
  50. package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
  51. package/src/flows/pipeline/steps/index.ts +54 -0
  52. package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
  53. package/src/flows/pipeline/steps/status-step.tsx +41 -0
  54. package/src/flows/pipeline/types.ts +232 -0
  55. package/src/flows/public.ts +78 -0
  56. package/src/flows/screens-hosted.tsx +55 -5
  57. package/src/flows/screens-pay.tsx +6 -1
  58. package/src/flows/types.ts +25 -2
  59. package/src/index.ts +29 -19
@@ -22,6 +22,8 @@ import { createCheckoutClient } from "../components/checkout/transport";
22
22
  import type { CheckoutProviderConfig, SettlementCheckout } from "../components/checkout/types";
23
23
  import { useCheckoutController } from "../components/checkout/use-checkout-controller";
24
24
 
25
+ import { buildPipeline } from "./pipeline/engine";
26
+ import { pipelineRequested } from "./pipeline/types";
25
27
  import { FlowsProvider, useResolvedConfig, type FlowsRuntime } from "./runtime";
26
28
  import { buyerScreens } from "./screens-buyer";
27
29
  import { hostedScreens } from "./screens-hosted";
@@ -150,6 +152,8 @@ function buildUseCheckout(runtime: FlowsRuntime): () => CheckoutController {
150
152
  return function useCheckout(): CheckoutController {
151
153
  const { config } = useResolvedConfig(runtime);
152
154
  const defaults = runtime.config.useBuyerDefaults?.() ?? {};
155
+ const cart = runtime.config.useCart();
156
+ const scope = { tenantSlug: runtime.useTenantSlug() };
153
157
  return useCheckoutController(
154
158
  {
155
159
  createOrder: ports.createPayable,
@@ -162,6 +166,11 @@ function buildUseCheckout(runtime: FlowsRuntime): () => CheckoutController {
162
166
  // Resolved for NO method: the gate runs on the Dados step, before the
163
167
  // picker, and FUT-595's rule is to collect the union up front.
164
168
  buyerFieldsFor(config?.chain, null),
169
+ scope.tenantSlug,
170
+ // WHICH basket, so a payment raised from another one cannot resume itself
171
+ // over it (FUT-1213). The host answers on its cart view; a host that does
172
+ // not gets the pre-1213 behaviour.
173
+ cart.identity,
165
174
  );
166
175
  };
167
176
  }
@@ -196,12 +205,21 @@ export function createPaymentFlows(config: PaymentFlowsConfig): PaymentFlows {
196
205
  );
197
206
  }
198
207
 
208
+ // THE ADDITIVE SWITCH (FUT-1240). A host that registered a step, a gate, a
209
+ // settlement method, an intent, an open payable, an exit or a settle
210
+ // callback gets the pipeline; a host that registered none gets the flat
211
+ // three-step flow, unchanged, down to its test ids. `useAdmission` is built
212
+ // either way — with no gates registered it passes, which is the honest
213
+ // answer for a host that has declared no admission rules.
214
+ const pipeline = buildPipeline(runtime, screens);
215
+
199
216
  return {
200
- Checkout: buildCheckout(runtime, screens),
217
+ Checkout: pipelineRequested(config) ? pipeline.Checkout : buildCheckout(runtime, screens),
201
218
  Provider,
202
219
  screens,
203
220
  useCheckout: buildUseCheckout(runtime),
204
221
  useCheckoutConfig: () => useResolvedConfig(runtime),
222
+ useAdmission: pipeline.useAdmission,
205
223
  client: runtime.client,
206
224
  };
207
225
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * WHAT A STEP CAN DO, as a context (FUT-1240).
3
+ *
4
+ * A step's `render` is handed only what §4.2 declares — its context, its
5
+ * facts, its slice, and the two navigations. Everything a step needs to CHANGE
6
+ * (choose a method, place the order, report a terminal status, edit the buyer)
7
+ * belongs to the engine, and the engine is the only writer of each.
8
+ *
9
+ * It arrives as a React context rather than as a closure, for one mechanical
10
+ * reason: the registered arrays must be identity-stable across renders (see
11
+ * `stable-plugins.ts`), so a step object cannot be rebuilt each render to
12
+ * capture this render's callbacks. A context is read where it is used, in a
13
+ * component, and leaves the step objects frozen.
14
+ */
15
+ import { createContext, useContext, type JSX, type ReactNode } from "react";
16
+
17
+ import type { CheckoutDecline } from "../../components/checkout/decline";
18
+ import type {
19
+ BuyerInfo,
20
+ CheckoutError,
21
+ CheckoutOrder,
22
+ OrderStatus,
23
+ } from "../../components/checkout/types";
24
+ import type { CheckoutViewCopy } from "../../components/checkout/view-copy";
25
+ import type { CheckoutScreens } from "../types";
26
+
27
+ import type { AnySettlementMethod, CheckoutContext } from "./types";
28
+
29
+ /** The engine's writers, plus the two tables a step renders from. */
30
+ export interface PipelineActions {
31
+ /** Every screen the factory built, already bound to transport and slots. */
32
+ screens: CheckoutScreens;
33
+ /** The words. The engine's own two live under `copy.pipeline`. */
34
+ copy: CheckoutViewCopy;
35
+ /** Every registered settlement method, in picker order. */
36
+ methods: readonly AnySettlementMethod[];
37
+ /** The subset this shopper is actually offered, in the same order. */
38
+ offered: readonly AnySettlementMethod[];
39
+ /** The shopper picked a way to settle. Raises the payable unless a Review owns that. */
40
+ choose(methodId: string): void;
41
+ /** Raise the payable for the chosen method — a `Review`'s own action. */
42
+ place(): void;
43
+ /** A payable is being raised right now. */
44
+ placing: boolean;
45
+ /** "Continuar" on the buyer-details step: gate, persist, advance. */
46
+ continueFromDados(): void;
47
+ setBuyer(buyer: BuyerInfo): void;
48
+ saveProfile: boolean;
49
+ setSaveProfile(save: boolean): void;
50
+ /** A terminal status, carrying the refusal when the charge produced one. */
51
+ resolve(status: OrderStatus, decline?: CheckoutDecline | null): void;
52
+ /**
53
+ * Take up an order this visit did not raise — a resumed on-page charge.
54
+ * Separate from {@link PipelineActions.place} because nothing is being
55
+ * created: the charge exists, and what changes is only which order the walk
56
+ * is about.
57
+ */
58
+ adoptOrder(order: CheckoutOrder): void;
59
+ /** Leave for the host's catalog. */
60
+ exitToCatalog(): void;
61
+ /** The refusal the engine currently holds, whoever claimed it. */
62
+ error: CheckoutError | null;
63
+ /** Reopen the buyer-details step — the payer block's "alterar". */
64
+ editBuyer: (() => void) | undefined;
65
+ }
66
+
67
+ const PipelineActionsContext = createContext<PipelineActions | null>(null);
68
+
69
+ /** The engine's actions. Throws outside the engine, on purpose. */
70
+ export function usePipelineActions(): PipelineActions {
71
+ const actions = useContext(PipelineActionsContext);
72
+ if (!actions) {
73
+ throw new Error(
74
+ "usePipelineActions() was called outside the checkout pipeline. A step's " +
75
+ "render only runs inside <Checkout />; mounting one on its own is what " +
76
+ "`flows.screens.*` is for.",
77
+ );
78
+ }
79
+ return actions;
80
+ }
81
+
82
+ /** Supplied once by the engine, above every step. */
83
+ export function PipelineActionsProvider({
84
+ actions,
85
+ children,
86
+ }: {
87
+ actions: PipelineActions;
88
+ children: ReactNode;
89
+ }): JSX.Element {
90
+ return (
91
+ <PipelineActionsContext.Provider value={actions}>
92
+ {children}
93
+ </PipelineActionsContext.Provider>
94
+ );
95
+ }
96
+
97
+ /** The descriptor for a chosen method, or `undefined` when nobody registered it. */
98
+ export function descriptorFor(
99
+ methods: readonly AnySettlementMethod[],
100
+ ctx: CheckoutContext,
101
+ ): AnySettlementMethod | undefined {
102
+ if (ctx.method === null) return undefined;
103
+ return methods.find((entry) => entry.id === ctx.method);
104
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * MAY THIS SHOPPER CHECK OUT — one answer, offered headless (FUT-1240).
3
+ *
4
+ * The gates run in ARRAY ORDER and the first non-`pass` verdict wins. Order
5
+ * matters and is the host's to choose: a gate that would curtain the screen
6
+ * must not speak before the gate that is still waiting for the cart, or a
7
+ * shopper meets "loja fechada" because a fact had not arrived yet.
8
+ *
9
+ * Exported as `flows.useAdmission()` so the cart drawer's CTA and a
10
+ * buy-now button consume the SAME list the checkout does. Two surfaces
11
+ * deciding this separately is how a storefront ends up with a drawer that
12
+ * offers a checkout the checkout itself refuses.
13
+ */
14
+ import { hostedCheckoutReturnPending } from "../../components/checkout/hosted-return";
15
+
16
+ import type { AnyCheckoutGate, CheckoutContext, GateVerdict } from "./types";
17
+
18
+ /** Nothing said otherwise. */
19
+ const PASS: GateVerdict = { kind: "pass" };
20
+
21
+ /**
22
+ * The gates' verdict, given the facts each of them returned.
23
+ *
24
+ * Pure: the hooks run in the caller's body, in array order, and their answers
25
+ * arrive here as a list. That is what makes this testable without a renderer
26
+ * and what keeps hook order a property of the ARRAY rather than of the
27
+ * verdicts.
28
+ */
29
+ export function decideAdmission(input: {
30
+ gates: readonly AnyCheckoutGate[];
31
+ facts: readonly unknown[];
32
+ ctx: CheckoutContext;
33
+ /** A hand-off from this tab is still waiting — see `standsAsideForResume`. */
34
+ resuming: boolean;
35
+ }): GateVerdict {
36
+ for (const [at, gate] of input.gates.entries()) {
37
+ // A gate that stands aside for a resume is standing aside from the one
38
+ // route where money gets confirmed. Skipped for that visit only.
39
+ if (input.resuming && gate.standsAsideForResume) continue;
40
+ const verdict = gate.decide(input.ctx, input.facts[at]);
41
+ if (verdict.kind !== "pass") return verdict;
42
+ }
43
+ return PASS;
44
+ }
45
+
46
+ /**
47
+ * Whether a hand-off from this tab is still waiting to be resolved.
48
+ *
49
+ * Asked with the SAME slug and basket the resume asks with, so a gate and the
50
+ * flow behind it cannot disagree about whose return this is — the property
51
+ * `hostedCheckoutReturnPending`'s own doc argues for at length.
52
+ */
53
+ export function resumePending(ctx: CheckoutContext): boolean {
54
+ return hostedCheckoutReturnPending(ctx.tenantSlug, ctx.cart.identity);
55
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * THE CONTEXT EVERY PLUGIN READS, built once from the host's own hooks
3
+ * (FUT-1240).
4
+ *
5
+ * One builder, used by BOTH the engine and `flows.useAdmission()`, so the
6
+ * headless admission the cart drawer asks and the checkout the shopper reaches
7
+ * cannot disagree about the same shopper. That was the whole harm behind
8
+ * "three exemption sets": every surface answered "may this store take money"
9
+ * from its own reading of its own facts.
10
+ *
11
+ * Every field is server-owned or parked. Nothing here is React state the
12
+ * engine happens to hold — the engine layers its own on top with
13
+ * {@link withCheckoutState}, and only there.
14
+ */
15
+ import type { CheckoutOrder } from "../../components/checkout/types";
16
+
17
+ import { useResolvedConfig, type FlowsRuntime } from "../runtime";
18
+
19
+ import type { CheckoutContext, CheckoutPipelineConfig } from "./types";
20
+
21
+ /** No host `useIntent` ⇒ no one-click, no resume request, no preset method. */
22
+ const NO_INTENT: CheckoutContext["intent"] = Object.freeze({
23
+ oneClick: false,
24
+ resuming: false,
25
+ presetMethod: null,
26
+ });
27
+
28
+ /** No host `useOpenPayable` ⇒ the server is never asked; the park still answers. */
29
+ const NO_OPEN_PAYABLE: { order: CheckoutOrder | null; pending: boolean } = Object.freeze({
30
+ order: null,
31
+ pending: false,
32
+ });
33
+
34
+ /**
35
+ * The two reads only a HOST can answer: what the address bar asked for, and
36
+ * what the server says is already in flight.
37
+ *
38
+ * Its own function so the defaulting stays in one place — and so the base
39
+ * builder below reads as a list of facts rather than as a chain of `??`.
40
+ */
41
+ function usePipelineReads(pipeline: CheckoutPipelineConfig): {
42
+ intent: CheckoutContext["intent"];
43
+ openPayable: { order: CheckoutOrder | null; pending: boolean };
44
+ } {
45
+ const intent = pipeline.useIntent?.() ?? NO_INTENT;
46
+ const openPayable = pipeline.useOpenPayable?.() ?? NO_OPEN_PAYABLE;
47
+ return { intent, openPayable };
48
+ }
49
+
50
+ /** The base context plus the two reads a caller may need on their own. */
51
+ interface CheckoutBase {
52
+ ctx: CheckoutContext;
53
+ openPayable: { order: CheckoutOrder | null; pending: boolean };
54
+ /** The buyer's saved details are still being fetched. */
55
+ buyerPending: boolean;
56
+ }
57
+
58
+ /**
59
+ * The host's facts, as a context.
60
+ *
61
+ * `method`, `order`, `outcome` and `slices` are at their resting values here:
62
+ * an admission decision is about the SHOPPER and the STORE, never about how
63
+ * far into a payment somebody is. The engine supplies the rest.
64
+ */
65
+ export function useCheckoutBase(
66
+ runtime: FlowsRuntime,
67
+ pipeline: CheckoutPipelineConfig,
68
+ ): CheckoutBase {
69
+ const cart = runtime.config.useCart();
70
+ const defaults = runtime.config.useBuyerDefaults?.() ?? {};
71
+ const settlement = runtime.config.useSettlement?.() ?? null;
72
+ const { config, pending } = useResolvedConfig(runtime);
73
+ const tenantSlug = runtime.useTenantSlug();
74
+ const { intent, openPayable } = usePipelineReads(pipeline);
75
+ return {
76
+ ctx: {
77
+ ...(tenantSlug === undefined ? {} : { tenantSlug }),
78
+ config,
79
+ configPending: pending,
80
+ cart,
81
+ settlement,
82
+ ...buyerFacts(defaults),
83
+ ...AT_REST,
84
+ order: openPayable.order,
85
+ intent,
86
+ },
87
+ openPayable,
88
+ buyerPending: defaults.pending ?? false,
89
+ };
90
+ }
91
+
92
+ /** Nothing has been chosen, raised or answered yet. */
93
+ const AT_REST = Object.freeze({
94
+ method: null,
95
+ outcome: null,
96
+ slices: Object.freeze({}),
97
+ } as const);
98
+
99
+ /** The buyer half, with the two absences that mean "the host wired none". */
100
+ function buyerFacts(defaults: {
101
+ buyer?: CheckoutContext["buyer"];
102
+ taxIdOnFile?: boolean;
103
+ }): Pick<CheckoutContext, "buyer" | "taxIdOnFile"> {
104
+ return {
105
+ buyer: defaults.buyer ?? {},
106
+ taxIdOnFile: defaults.taxIdOnFile ?? false,
107
+ };
108
+ }
109
+
110
+ /** What the ENGINE knows and the base does not. */
111
+ interface CheckoutEngineState {
112
+ buyer: CheckoutContext["buyer"];
113
+ method: string | null;
114
+ order: CheckoutOrder | null;
115
+ outcome: CheckoutContext["outcome"];
116
+ slices: Readonly<Record<string, unknown>>;
117
+ }
118
+
119
+ /**
120
+ * The base context with the engine's own state laid over it.
121
+ *
122
+ * The BUYER is overlaid rather than merged: the shopper may have typed a CPF
123
+ * for this purchase over the one on file, and the whole of `checkout-skip-dados`
124
+ * turns on that replacement being visible to every later step.
125
+ */
126
+ export function withCheckoutState(
127
+ base: CheckoutContext,
128
+ state: CheckoutEngineState,
129
+ ): CheckoutContext {
130
+ return {
131
+ ...base,
132
+ buyer: state.buyer,
133
+ method: state.method,
134
+ // The just-raised order wins over whatever the server or the park offered:
135
+ // it is the one this visit is actually paying.
136
+ order: state.order ?? base.order,
137
+ outcome: state.outcome,
138
+ slices: state.slices,
139
+ };
140
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * WHICH STEP THE SHOPPER IS ON — derived, never remembered (FUT-1240).
3
+ *
4
+ * The flat controller holds `useState<Step>`, so a reload, a discarded tab or
5
+ * a return from a provider's own page all forget where the shopper was. Here
6
+ * the answer is a function of facts the SERVER owns (the cart, the buyer's
7
+ * `hasTaxId`, the open payable) plus the steps' own declared slices, so it is
8
+ * the same answer before and after the page goes away.
9
+ *
10
+ * ## It reproduces `useCheckoutNav` exactly, and that is the point
11
+ *
12
+ * `checkout-actions.ts`'s nav has three rules that look like details and are
13
+ * not, because the money path walks through them:
14
+ *
15
+ * - back off the payment step lands on Dados — UNLESS the buyer has a CPF on
16
+ * file and has never opened Dados, in which case that step is not part of
17
+ * their flow and the only honest destination is the catalog;
18
+ * - "alterar" (`editBuyer`) exists ONLY for a buyer whose Dados was skipped,
19
+ * and opening it makes Dados part of their flow from then on;
20
+ * - back off the CONFIRMATION is the catalog, always. The flat nav maps back
21
+ * to Dados only from `payment`; every other step goes to the menu.
22
+ *
23
+ * The first two fall out of one general rule here: **back re-opens the PREVIOUS
24
+ * APPLYING step, and exits when there is none.** A skipped Dados does not
25
+ * apply, so it is not the previous applying step, so back exits — which is the
26
+ * second rule verbatim. `editBuyer` flips the Dados slice's `opened`, after
27
+ * which Dados applies and back returns to it — which is the first.
28
+ *
29
+ * The third does NOT, and stating it separately is the whole of {@link
30
+ * deriveNav}'s `terminal`. A paid Pix order leaves its pane APPLYING — an order
31
+ * exists, nothing handed over — and merely COMPLETE, so the previous applying
32
+ * step behind the confirmation is the payment surface for money that already
33
+ * moved. Re-opening it puts a live pay button in front of a shopper who has
34
+ * paid, which is the hazard `ADOPTING.md` records under one owner paying four
35
+ * times.
36
+ */
37
+ import type {
38
+ AnyCheckoutStep,
39
+ AnySettlementMethod,
40
+ CheckoutContext,
41
+ CheckoutStepPhase,
42
+ } from "./types";
43
+
44
+ /** Phases in walk order. A step's `order` breaks ties inside one phase. */
45
+ const PHASE_ORDER: readonly CheckoutStepPhase[] = [
46
+ "details",
47
+ "before-pay",
48
+ "pay",
49
+ "after-pay",
50
+ ];
51
+
52
+ /** Facts, by step id — the engine runs every `useFacts()` once, in array order. */
53
+ type StepFacts = Readonly<Record<string, unknown>>;
54
+
55
+ /** What a walk over the registered steps produced. */
56
+ interface DerivedStep {
57
+ /** The step to render, or `null` when nothing applies yet. */
58
+ step: AnyCheckoutStep | null;
59
+ /** Every step that applies to THIS shopper, in walk order. */
60
+ applying: readonly AnyCheckoutStep[];
61
+ /** `step`'s index in {@link applying}, or `-1`. */
62
+ index: number;
63
+ }
64
+
65
+ /**
66
+ * The registered steps in walk order.
67
+ *
68
+ * Sorted stably: two steps in the same phase with the same `order` keep the
69
+ * order their arrays were merged in, so a host appending a step never reshuffles
70
+ * the package's own.
71
+ */
72
+ function orderedSteps(steps: readonly AnyCheckoutStep[]): AnyCheckoutStep[] {
73
+ return steps
74
+ .map((step, at) => ({ step, at }))
75
+ .sort((left, right) => {
76
+ const phase =
77
+ PHASE_ORDER.indexOf(left.step.phase) - PHASE_ORDER.indexOf(right.step.phase);
78
+ if (phase !== 0) return phase;
79
+ const order = (left.step.order ?? 0) - (right.step.order ?? 0);
80
+ return order !== 0 ? order : left.at - right.at;
81
+ })
82
+ .map((entry) => entry.step);
83
+ }
84
+
85
+ /**
86
+ * THE NO-CHARGE RULE, stated once.
87
+ *
88
+ * A settlement method whose `raisesCharge` is `false` mounts no payment
89
+ * surface after it is chosen: no Dados-for-the-charge, no method pane, no
90
+ * poll. Enforced here rather than in each lane, because "each lane" is exactly
91
+ * how a delivery checkout ended up rendering a PIX pane for a shopper paying
92
+ * the courier.
93
+ *
94
+ * Unknown method ⇒ the rule does not fire. An id nobody registered cannot be
95
+ * asserted to raise no charge, and refusing the pay phase on a guess would
96
+ * strand a shopper mid-payment.
97
+ */
98
+ export function raisesCharge(
99
+ method: string | null,
100
+ methods: readonly AnySettlementMethod[],
101
+ ): boolean {
102
+ if (method === null) return true;
103
+ const descriptor = methods.find((entry) => entry.id === method);
104
+ return descriptor ? descriptor.raisesCharge : true;
105
+ }
106
+
107
+ /**
108
+ * A step's slice, with the step's own `initial` standing in when the context
109
+ * carries none.
110
+ *
111
+ * The engine seeds every slice at mount, so in a live checkout this fallback
112
+ * never fires. It exists because `complete()` is a step author's function and
113
+ * must never be handed `undefined` where its type says `S` — a walk that
114
+ * throws while deciding where the shopper is would take the whole checkout
115
+ * with it, and the cause would be an absent key.
116
+ */
117
+ export function sliceFor(step: AnyCheckoutStep, ctx: CheckoutContext): unknown {
118
+ const value = ctx.slices[step.id];
119
+ if (value !== undefined) return value;
120
+ return step.slice ? step.slice.initial(ctx) : undefined;
121
+ }
122
+
123
+ /** What `deriveStep` is asked. */
124
+ interface DeriveStepInput {
125
+ steps: readonly AnyCheckoutStep[];
126
+ ctx: CheckoutContext;
127
+ facts: StepFacts;
128
+ methods: readonly AnySettlementMethod[];
129
+ /** A step the shopper navigated BACK to; it wins while it still applies. */
130
+ reopened?: string | null;
131
+ }
132
+
133
+ /**
134
+ * THE PANE RULE, stated once — the other half of the no-charge one.
135
+ *
136
+ * A `pay`-phase step that some registered method named as its
137
+ * {@link AnySettlementMethod.pane} belongs to THAT method: it applies while
138
+ * that method is the chosen one and never otherwise. A `pay` step nobody named
139
+ * — the hand-off interstitial, which is about the ORDER rather than about the
140
+ * method — is untouched by this.
141
+ *
142
+ * Stated here rather than as a `ctx.method === "PIX"` inside each pane, because
143
+ * a descriptor field that decides nothing is config that lies: `pane` was
144
+ * declared, documented and read nowhere while the two panes hard-coded the very
145
+ * ids it names.
146
+ */
147
+ function paneApplies(
148
+ step: AnyCheckoutStep,
149
+ ctx: CheckoutContext,
150
+ methods: readonly AnySettlementMethod[],
151
+ ): boolean {
152
+ if (!methods.some((entry) => entry.pane === step.id)) return true;
153
+ const chosen = methods.find((entry) => entry.id === ctx.method);
154
+ return chosen?.pane === step.id;
155
+ }
156
+
157
+ /** The steps that apply to this shopper, with the no-charge rule already applied. */
158
+ export function applyingSteps(input: DeriveStepInput): AnyCheckoutStep[] {
159
+ const { ctx, facts, methods } = input;
160
+ const charges = raisesCharge(ctx.method, methods);
161
+ return orderedSteps(input.steps).filter((step) => {
162
+ if (step.phase === "pay") {
163
+ if (!charges) return false;
164
+ if (!paneApplies(step, ctx, methods)) return false;
165
+ }
166
+ return step.applies(ctx, facts[step.id]);
167
+ });
168
+ }
169
+
170
+ /**
171
+ * The current step: the first applying step whose `complete()` is false, plus
172
+ * the one explicit override — a step the shopper pressed back into.
173
+ *
174
+ * All complete ⇒ the LAST applying step, which is the terminal one. The
175
+ * package's own confirmation answers `complete: false` forever, so this is a
176
+ * safety net rather than a path: a walk whose every step is finished has
177
+ * nowhere else to put the shopper.
178
+ */
179
+ export function deriveStep(input: DeriveStepInput): DerivedStep {
180
+ const applying = applyingSteps(input);
181
+ const reopened = input.reopened ?? null;
182
+ const back = reopened === null ? -1 : applying.findIndex((step) => step.id === reopened);
183
+ if (back !== -1) return { step: applying[back] ?? null, applying, index: back };
184
+ const at = applying.findIndex(
185
+ (step) => !step.complete(input.ctx, input.facts[step.id], sliceFor(step, input.ctx)),
186
+ );
187
+ if (at !== -1) return { step: applying[at] ?? null, applying, index: at };
188
+ const last = applying.length - 1;
189
+ return { step: applying[last] ?? null, applying, index: last };
190
+ }
191
+
192
+ /** The ports `deriveNav` drives — the engine's own writers, named. */
193
+ interface NavPorts {
194
+ /** Mark a step as the one the shopper went back to. */
195
+ reopen(stepId: string): void;
196
+ /** Open the buyer-details step for a shopper whose CPF made it skippable. */
197
+ openDados(): void;
198
+ /** Leave checkout for the host's catalog. */
199
+ exitToCatalog(): void;
200
+ }
201
+
202
+ /**
203
+ * `back` and `editBuyer`, reproducing `useCheckoutNav` (FUT-465, FUT-1216
204
+ * risk 2).
205
+ *
206
+ * `editBuyer` is `undefined` unless Dados was SKIPPED — the payer block keys
207
+ * off its presence, so the decision lives here rather than being re-derived by
208
+ * every caller, exactly as it did in the flat controller.
209
+ *
210
+ * `terminal` is `ctx.outcome !== null`, and it is the flat nav's "from `status`
211
+ * you go to the menu" — see the third rule at the top of this file. It is asked
212
+ * of the OUTCOME rather than of the last step's identity so that a host's own
213
+ * confirmation, and a FAILED or EXPIRED one, answer the same way: once this
214
+ * checkout has an outcome there is nothing behind it a shopper should be sent
215
+ * back into.
216
+ */
217
+ export function deriveNav(input: {
218
+ applying: readonly AnyCheckoutStep[];
219
+ index: number;
220
+ taxIdOnFile: boolean;
221
+ /** The walk has an outcome — `ctx.outcome !== null`. */
222
+ terminal: boolean;
223
+ ports: NavPorts;
224
+ }): { back(): void; editBuyer: (() => void) | undefined } {
225
+ const { applying, index, ports } = input;
226
+ const previous = input.terminal || index <= 0 ? undefined : applying[index - 1];
227
+ return {
228
+ back() {
229
+ if (previous) ports.reopen(previous.id);
230
+ else ports.exitToCatalog();
231
+ },
232
+ editBuyer: input.taxIdOnFile ? ports.openDados : undefined,
233
+ };
234
+ }