@12-apps/payments-frontend 3.22.0 → 3.23.1
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.
- package/package.json +2 -2
- package/src/components/checkout/checkout-actions.ts +55 -9
- package/src/components/checkout/checkout-flow.tsx +15 -7
- package/src/components/checkout/confirmation-wait.ts +97 -0
- package/src/components/checkout/en-US.ts +8 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-store.ts +23 -1
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/pt-BR.ts +9 -0
- package/src/components/checkout/resolution-actions.ts +43 -0
- package/src/components/checkout/types.ts +11 -0
- package/src/components/checkout/use-card-checkout.ts +3 -2
- package/src/components/checkout/use-checkout-controller.ts +23 -16
- package/src/components/checkout/use-payment-polling.ts +58 -7
- package/src/components/checkout/use-wallet-charge.ts +24 -1
- package/src/components/checkout/view-copy.ts +38 -0
- package/src/components/checkout/wallet-pane.tsx +6 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +12 -1
- package/src/flows/pipeline/actions.tsx +104 -0
- package/src/flows/pipeline/admission.ts +55 -0
- package/src/flows/pipeline/context.ts +140 -0
- package/src/flows/pipeline/derive-step.ts +234 -0
- package/src/flows/pipeline/engine-actions.ts +257 -0
- package/src/flows/pipeline/engine-chrome.tsx +123 -0
- package/src/flows/pipeline/engine-state.ts +107 -0
- package/src/flows/pipeline/engine.tsx +377 -0
- package/src/flows/pipeline/methods.ts +71 -0
- package/src/flows/pipeline/refusal-routing.ts +106 -0
- package/src/flows/pipeline/slices.ts +110 -0
- package/src/flows/pipeline/stable-plugins.ts +72 -0
- package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
- package/src/flows/pipeline/steps/index.ts +54 -0
- package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
- package/src/flows/pipeline/steps/status-step.tsx +41 -0
- package/src/flows/pipeline/types.ts +232 -0
- package/src/flows/public.ts +78 -0
- package/src/flows/screens-hosted.tsx +37 -4
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +7 -19
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THE ENGINE DOES WHEN A STEP ASKS (FUT-1240).
|
|
3
|
+
*
|
|
4
|
+
* One writer per fact, and every one of them here: choosing a method, raising
|
|
5
|
+
* the payable, passing the buyer-details gate, reporting a terminal status,
|
|
6
|
+
* navigating back and leaving. A step calls these; nothing else writes.
|
|
7
|
+
*
|
|
8
|
+
* The payable is raised in ONE place, for every settlement method. What
|
|
9
|
+
* differs for a method that raises no charge is not the call — it is that the
|
|
10
|
+
* engine parks nothing and treats the placement itself as the settlement, so
|
|
11
|
+
* the walk goes straight to the confirmation with no pane in between.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useMemo } from "react";
|
|
14
|
+
|
|
15
|
+
import { buyerFieldsFor } from "../../components/checkout/buyer-fields";
|
|
16
|
+
import { buyerGateError } from "../../components/checkout/buyer-gate";
|
|
17
|
+
import { parkedBasket } from "../../components/checkout/basket";
|
|
18
|
+
import type { CheckoutDecline } from "../../components/checkout/decline";
|
|
19
|
+
import { forgetHostedOrder, rememberHostedOrder } from "../../components/checkout/hosted-return";
|
|
20
|
+
import { handOffMethod, offeredMethods } from "../../components/checkout/method-capability";
|
|
21
|
+
import type {
|
|
22
|
+
BuyerInfo,
|
|
23
|
+
CheckoutOrder,
|
|
24
|
+
CreateOrderRequest,
|
|
25
|
+
OrderStatus,
|
|
26
|
+
PaymentMethod,
|
|
27
|
+
} from "../../components/checkout/types";
|
|
28
|
+
import { useCatalogExit } from "../catalog-exit";
|
|
29
|
+
import type { FlowsRuntime } from "../runtime";
|
|
30
|
+
|
|
31
|
+
import { applyingSteps, raisesCharge, sliceFor } from "./derive-step";
|
|
32
|
+
import type { EngineStore } from "./engine-state";
|
|
33
|
+
import { isPackageMethod } from "./methods";
|
|
34
|
+
import {
|
|
35
|
+
BUYER_FIELD_CODE,
|
|
36
|
+
DADOS_STEP_ID,
|
|
37
|
+
METHOD_STEP_ID,
|
|
38
|
+
dadosSliceOf,
|
|
39
|
+
} from "./steps";
|
|
40
|
+
import type {
|
|
41
|
+
AnyCheckoutGate,
|
|
42
|
+
AnyCheckoutStep,
|
|
43
|
+
AnySettlementMethod,
|
|
44
|
+
CheckoutContext,
|
|
45
|
+
} from "./types";
|
|
46
|
+
|
|
47
|
+
/** Everything one factory's engine closes over. Built once, never per render. */
|
|
48
|
+
export interface PipelineWiring {
|
|
49
|
+
steps: readonly AnyCheckoutStep[];
|
|
50
|
+
gates: readonly AnyCheckoutGate[];
|
|
51
|
+
methods: readonly AnySettlementMethod[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `method` the WIRE carries for a settlement this package cannot name.
|
|
56
|
+
*
|
|
57
|
+
* `method` is what the CHAIN can be asked to charge, so a host's registered id
|
|
58
|
+
* is never one of its values: the chain's own hand-off method is what the
|
|
59
|
+
* server will honour, exactly as it is for a store whose buyer was never asked.
|
|
60
|
+
*
|
|
61
|
+
* WHICH settlement the buyer actually chose rides {@link settlementOnWire}
|
|
62
|
+
* beside it. It has to: `method` alone makes a courier, a waiter and a Pix
|
|
63
|
+
* charge the same request, and `raisesCharge: false` is the capability this
|
|
64
|
+
* step exists for.
|
|
65
|
+
*/
|
|
66
|
+
function wireMethod(id: string, ctx: CheckoutContext): PaymentMethod {
|
|
67
|
+
return isPackageMethod(id) ? id : handOffMethod(offeredMethods(ctx.config));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The chosen id, for a settlement the two package methods do not cover.
|
|
72
|
+
*
|
|
73
|
+
* ABSENT for `PIX` and `CARD` — a host reading only `method` reads exactly the
|
|
74
|
+
* request it read before this field existed, which is the whole of the wire
|
|
75
|
+
* staying additive. A step that wants to say something else still can: its
|
|
76
|
+
* `contribute` returns `Partial<CreateOrderRequest>`, and the contributions are
|
|
77
|
+
* folded over this base.
|
|
78
|
+
*/
|
|
79
|
+
function settlementOnWire(id: string): Pick<CreateOrderRequest, "settlementMethod"> {
|
|
80
|
+
return isPackageMethod(id) ? {} : { settlementMethod: id };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The create request: the method, the buyer, and whatever each step contributes. */
|
|
84
|
+
function createRequest(input: {
|
|
85
|
+
ctx: CheckoutContext;
|
|
86
|
+
applying: readonly AnyCheckoutStep[];
|
|
87
|
+
stepFacts: Readonly<Record<string, unknown>>;
|
|
88
|
+
saveProfile: boolean;
|
|
89
|
+
methodId: string;
|
|
90
|
+
}): CreateOrderRequest {
|
|
91
|
+
const base: CreateOrderRequest = {
|
|
92
|
+
method: wireMethod(input.methodId, input.ctx),
|
|
93
|
+
buyer: input.ctx.buyer,
|
|
94
|
+
saveProfile: input.saveProfile,
|
|
95
|
+
...settlementOnWire(input.methodId),
|
|
96
|
+
};
|
|
97
|
+
return input.applying.reduce<CreateOrderRequest>(
|
|
98
|
+
(request, step) => ({
|
|
99
|
+
...request,
|
|
100
|
+
...step.contribute?.(input.ctx, input.stepFacts[step.id], sliceFor(step, input.ctx)),
|
|
101
|
+
}),
|
|
102
|
+
base,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What the writers need from the render they were built in. */
|
|
107
|
+
interface EngineWritersInput {
|
|
108
|
+
runtime: FlowsRuntime;
|
|
109
|
+
wiring: PipelineWiring;
|
|
110
|
+
store: EngineStore;
|
|
111
|
+
ctx: CheckoutContext;
|
|
112
|
+
stepFacts: Readonly<Record<string, unknown>>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The engine's writers. */
|
|
116
|
+
interface EngineWriters {
|
|
117
|
+
choose(methodId: string): void;
|
|
118
|
+
place(): void;
|
|
119
|
+
continueFromDados(): void;
|
|
120
|
+
setBuyer(buyer: BuyerInfo): void;
|
|
121
|
+
setSaveProfile(save: boolean): void;
|
|
122
|
+
resolve(status: OrderStatus, decline?: CheckoutDecline | null): void;
|
|
123
|
+
adoptOrder(order: CheckoutOrder): void;
|
|
124
|
+
exitToCatalog(): void;
|
|
125
|
+
reopen(stepId: string): void;
|
|
126
|
+
openDados(): void;
|
|
127
|
+
retry(): void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Raise the payable, park it when it can be resumed, and record what came back. */
|
|
131
|
+
function usePlaceOrder(input: EngineWritersInput): (methodId?: string) => Promise<void> {
|
|
132
|
+
const { runtime, store, ctx, wiring, stepFacts } = input;
|
|
133
|
+
const saveProfile = store.state.saveProfile;
|
|
134
|
+
return useCallback(
|
|
135
|
+
async (methodId?: string) => {
|
|
136
|
+
const chosen = methodId ?? ctx.method;
|
|
137
|
+
if (chosen === null) return;
|
|
138
|
+
store.patch({ placing: true, error: null, decline: null });
|
|
139
|
+
// The walk is re-derived FOR THE CHOSEN METHOD, not read off the render
|
|
140
|
+
// that offered the picker: a step whose `applies` asks which settlement
|
|
141
|
+
// this is would otherwise be absent from the request that settles it.
|
|
142
|
+
// ONE context, used for both halves. Deriving the walk against the chosen
|
|
143
|
+
// method and then building the request against the un-overridden `ctx` let
|
|
144
|
+
// a step's `contribute` read `ctx.method === null` on the immediate-place
|
|
145
|
+
// path, which is the path a method with no Review takes — so the field
|
|
146
|
+
// ADOPTING.md says a step may set "when the settlement has a finer name
|
|
147
|
+
// than the tile does" was written from a context that did not know it.
|
|
148
|
+
const applyingCtx = { ...ctx, method: chosen };
|
|
149
|
+
const applying = applyingSteps({
|
|
150
|
+
steps: wiring.steps,
|
|
151
|
+
ctx: applyingCtx,
|
|
152
|
+
facts: stepFacts,
|
|
153
|
+
methods: wiring.methods,
|
|
154
|
+
});
|
|
155
|
+
const request = createRequest({
|
|
156
|
+
ctx: applyingCtx,
|
|
157
|
+
applying,
|
|
158
|
+
stepFacts,
|
|
159
|
+
saveProfile,
|
|
160
|
+
methodId: chosen,
|
|
161
|
+
});
|
|
162
|
+
const result = await runtime.config.ports.createPayable(request);
|
|
163
|
+
if (!result.ok) {
|
|
164
|
+
store.patch({ placing: false, error: result.error });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const charges = raisesCharge(chosen, wiring.methods);
|
|
168
|
+
// PARKED ON EVERY RAISE (FUT-1140), with the STORE and the BASKET — a
|
|
169
|
+
// low-memory phone discards this tab while the shopper is in their bank
|
|
170
|
+
// app. A settlement that raises no charge has nothing to come back to.
|
|
171
|
+
if (charges) {
|
|
172
|
+
rememberHostedOrder(result.data, {
|
|
173
|
+
...(ctx.tenantSlug === undefined ? {} : { tenantSlug: ctx.tenantSlug }),
|
|
174
|
+
basket: parkedBasket(ctx.cart.identity),
|
|
175
|
+
handoff: false,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
store.patch({
|
|
179
|
+
placing: false,
|
|
180
|
+
order: result.data,
|
|
181
|
+
// Placing IS the settlement for a no-charge method, so its own status
|
|
182
|
+
// is already the outcome — there is no pane and no poll to wait for.
|
|
183
|
+
outcome: charges ? null : result.data.status,
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
[runtime, store, ctx, wiring, stepFacts, saveProfile],
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** "Continuar" on the buyer-details step: gate on the chain's demands, then persist. */
|
|
191
|
+
function useContinueFromDados(input: EngineWritersInput): () => void {
|
|
192
|
+
const { runtime, store, ctx } = input;
|
|
193
|
+
const copy = runtime.config.copy.views.screens.screens.validation;
|
|
194
|
+
const fields = useMemo(() => buyerFieldsFor(ctx.config?.chain, null), [ctx.config]);
|
|
195
|
+
return useCallback(() => {
|
|
196
|
+
const complaint = buyerGateError(copy, ctx.buyer, fields, ctx.taxIdOnFile);
|
|
197
|
+
if (complaint) {
|
|
198
|
+
store.patch({
|
|
199
|
+
error: { code: BUYER_FIELD_CODE, message: complaint.message, field: complaint.field },
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// The write happens HERE and not when a payment is raised: everything after
|
|
204
|
+
// this step can fail, and the details must survive all of it (FUT-465).
|
|
205
|
+
if (store.state.saveProfile) {
|
|
206
|
+
runtime.config.ports.saveBuyerContact?.({
|
|
207
|
+
...(ctx.buyer.name === undefined ? {} : { name: ctx.buyer.name }),
|
|
208
|
+
...(ctx.buyer.phone === undefined ? {} : { phone: ctx.buyer.phone }),
|
|
209
|
+
...(ctx.buyer.taxId === undefined ? {} : { taxId: ctx.buyer.taxId }),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
store.setSlice(DADOS_STEP_ID, { ...dadosSliceOf(ctx), done: true });
|
|
213
|
+
store.patch({ reopened: null, error: null });
|
|
214
|
+
}, [copy, ctx, fields, runtime, store]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Every writer, bound to this render's context. */
|
|
218
|
+
export function useEngineWriters(input: EngineWritersInput): EngineWriters {
|
|
219
|
+
const { runtime, store, ctx, wiring } = input;
|
|
220
|
+
const placeOrder = usePlaceOrder(input);
|
|
221
|
+
const continueFromDados = useContinueFromDados(input);
|
|
222
|
+
const exitToCatalog = useCatalogExit(runtime);
|
|
223
|
+
const choose = useCallback(
|
|
224
|
+
(methodId: string) => {
|
|
225
|
+
// A different method means the order raised for the previous one is
|
|
226
|
+
// gone — and the parked entry with it, scoped to THIS store so another
|
|
227
|
+
// store's checkout in the same tab keeps its own.
|
|
228
|
+
store.setSlice(METHOD_STEP_ID, { chosen: methodId });
|
|
229
|
+
store.patch({ order: null, error: null, decline: null, reopened: null });
|
|
230
|
+
forgetHostedOrder(ctx.tenantSlug);
|
|
231
|
+
const descriptor = wiring.methods.find((entry) => entry.id === methodId);
|
|
232
|
+
if (!descriptor?.Review) void placeOrder(methodId);
|
|
233
|
+
},
|
|
234
|
+
[store, ctx.tenantSlug, wiring.methods, placeOrder],
|
|
235
|
+
);
|
|
236
|
+
return useMemo(
|
|
237
|
+
() => ({
|
|
238
|
+
choose,
|
|
239
|
+
continueFromDados,
|
|
240
|
+
exitToCatalog,
|
|
241
|
+
place: () => void placeOrder(),
|
|
242
|
+
setBuyer: (buyer: BuyerInfo) =>
|
|
243
|
+
store.patch({ buyer, buyerTouched: true, error: null }),
|
|
244
|
+
setSaveProfile: (saveProfile: boolean) => store.patch({ saveProfile }),
|
|
245
|
+
resolve: (status: OrderStatus, decline?: CheckoutDecline | null) =>
|
|
246
|
+
store.patch({ outcome: status, decline: decline ?? null, reopened: null, error: null }),
|
|
247
|
+
adoptOrder: (order: CheckoutOrder) => store.patch({ order, error: null }),
|
|
248
|
+
reopen: (stepId: string) => store.patch({ reopened: stepId }),
|
|
249
|
+
openDados: () => {
|
|
250
|
+
store.setSlice(DADOS_STEP_ID, { opened: true, done: false });
|
|
251
|
+
store.patch({ reopened: null, error: null });
|
|
252
|
+
},
|
|
253
|
+
retry: () => store.patch({ error: null, decline: null, reopened: null }),
|
|
254
|
+
}),
|
|
255
|
+
[choose, continueFromDados, exitToCatalog, placeOrder, store],
|
|
256
|
+
);
|
|
257
|
+
}
|