@12-apps/payments-frontend 3.21.4 → 3.22.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 (31) 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 +341 -0
  6. package/src/components/checkout/checkout-flow.tsx +112 -18
  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/dados-step.tsx +141 -0
  11. package/src/components/checkout/decline.ts +48 -0
  12. package/src/components/checkout/en-US.ts +33 -0
  13. package/src/components/checkout/hosted-return.ts +190 -206
  14. package/src/components/checkout/hosted-store.ts +269 -0
  15. package/src/components/checkout/payment-status-parts.tsx +311 -0
  16. package/src/components/checkout/payment-status.tsx +69 -264
  17. package/src/components/checkout/providers/types.ts +20 -3
  18. package/src/components/checkout/pt-BR.ts +33 -0
  19. package/src/components/checkout/screens-copy.ts +14 -0
  20. package/src/components/checkout/screens-en-US.ts +1 -0
  21. package/src/components/checkout/screens-pt-BR.ts +3 -0
  22. package/src/components/checkout/transport.ts +21 -1
  23. package/src/components/checkout/types.ts +24 -0
  24. package/src/components/checkout/use-card-checkout.ts +31 -31
  25. package/src/components/checkout/use-checkout-controller.ts +51 -271
  26. package/src/components/checkout/use-hosted-resume.ts +326 -0
  27. package/src/components/checkout/view-copy.ts +32 -0
  28. package/src/components/checkout/wallet-pane.tsx +3 -0
  29. package/src/flows/create-payment-flows.tsx +7 -0
  30. package/src/flows/screens-hosted.tsx +19 -2
  31. package/src/index.ts +22 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.21.4",
3
+ "version": "3.22.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
@@ -22,7 +22,7 @@
22
22
  "storybook:build": "storybook build"
23
23
  },
24
24
  "dependencies": {
25
- "@12-apps/payments-backend": "^4.26.1",
25
+ "@12-apps/payments-backend": "^4.27.0",
26
26
  "react-qr-code": "^2.2.0"
27
27
  },
28
28
  "peerDependencies": {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * WHAT IS IN THE BASKET, as one comparable fact (FUT-1213).
3
+ *
4
+ * A parked checkout has to be able to ask "is this still the basket the order
5
+ * was raised from?" when the buyer comes back — and the answer cannot be the
6
+ * cart's ID. Emptying a cart ("Esvaziar carrinho") keeps the row: the same id
7
+ * comes back holding nothing, then holding something else entirely, and a
8
+ * comparison on the id says "same basket" for a shopper who has thrown the old
9
+ * one away and started again.
10
+ *
11
+ * So the identity is the LINES: which ones, and how many of each. Two baskets
12
+ * with the same lines in a different order are the same basket; one extra unit
13
+ * of one line is not.
14
+ *
15
+ * The HOST computes it, because the host owns the cart — this module only says
16
+ * what shape the answer takes and how to build one from lines, so that every
17
+ * adopter's signature is built the same way and a package-side comparison can
18
+ * mean something.
19
+ */
20
+
21
+ /** One line of the host's basket, reduced to what identity depends on. */
22
+ export interface CheckoutBasketLine {
23
+ /** The line's own stable handle — a cart-line id, never a product id alone. */
24
+ id: string;
25
+ quantity: number;
26
+ }
27
+
28
+ /**
29
+ * The lines' signature, or `null` for an empty basket.
30
+ *
31
+ * `null` rather than `""` because an EMPTY basket is a meaningful state on this
32
+ * path rather than a missing answer: the server closes a cart when its order is
33
+ * paid, so an empty basket is exactly what a buyer who paid comes back to.
34
+ *
35
+ * Sorted before joining, so the answer does not depend on the order the host
36
+ * happens to hold its lines in — a re-fetch that returns them differently
37
+ * sorted must not read as a different basket.
38
+ *
39
+ * The id is ENCODED before it is joined. `id + "x" + quantity` on a `|` join is
40
+ * ambiguous the moment an id can contain either character: `[a×1, b×2]` and the
41
+ * single line `["ax1|b" × 2]` produce the same string, and two different baskets
42
+ * that compare equal is a resume over the wrong one. Unreachable with the cuid
43
+ * and uuid ids every adopter has today — and this is exported for hosts whose
44
+ * ids nobody here has seen, so it is encoded rather than argued about.
45
+ */
46
+ export function basketSignature(lines: readonly CheckoutBasketLine[]): string | null {
47
+ if (lines.length === 0) return null;
48
+ return lines
49
+ .map((line) => `${encodeURIComponent(line.id)}x${line.quantity}`)
50
+ .sort()
51
+ .join("|");
52
+ }
53
+
54
+ /**
55
+ * The basket in front of the checkout right now, as the resume rule reads it.
56
+ *
57
+ * `ready` is not a nicety. The host's cart is fetched, so the first render of a
58
+ * checkout has an EMPTY cart that is merely unloaded — and "empty" is the one
59
+ * state the rule resumes on unconditionally (it is the paid buyer's normal
60
+ * state). Deciding against an unseeded cart would resume every abandoned
61
+ * hand-off on every mount, which is the bug this whole rule exists to remove.
62
+ * So the decision waits.
63
+ */
64
+ export interface CheckoutBasketIdentity {
65
+ /** The lines' signature; `null` ⇒ the basket is empty. */
66
+ signature: string | null;
67
+ /** False while the host's cart is still loading — decide nothing yet. */
68
+ ready: boolean;
69
+ }
70
+
71
+ /**
72
+ * The signature to PARK with an order, or nothing.
73
+ *
74
+ * `undefined` — meaning "no basket was recorded" — for a host that names none
75
+ * AND for a cart that has not answered yet. The second is the one worth
76
+ * stating: a loading cart reports `signature: null`, which is the value that
77
+ * means EMPTY, and an order parked as "raised from an empty basket" would later
78
+ * be compared against the real one and read as a different basket. Recording
79
+ * nothing is honest and degrades to the pre-1213 resume; recording `null` would
80
+ * be a fact we do not have.
81
+ */
82
+ export function parkedBasket(basket: CheckoutBasketIdentity | undefined): string | null | undefined {
83
+ if (!basket || !basket.ready) return undefined;
84
+ return basket.signature;
85
+ }
@@ -0,0 +1,81 @@
1
+ import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
2
+ import type { CheckoutDecline } from "./decline";
3
+ import { rememberHostedOrder } from "./hosted-return";
4
+ import type { CheckoutNavigate } from "./navigate-context";
5
+ import type { ChargeOutcome, CheckoutOrder, OnCheckoutResolved } from "./types";
6
+
7
+ /**
8
+ * WHAT HAPPENS TO A CHARGE'S ANSWER — the two endings a card submit can have
9
+ * that are not "keep polling".
10
+ *
11
+ * Split out of `./use-card-checkout.ts`, which is at its size ceiling. Both
12
+ * halves are about the same moment: the provider has answered, and the buyer
13
+ * is either being sent somewhere or being told something.
14
+ */
15
+
16
+ /** Whose store and which basket a challenge's parked order belongs to. */
17
+ export interface ChallengeScope {
18
+ tenantSlug?: string;
19
+ basket?: CheckoutBasketIdentity;
20
+ }
21
+
22
+ /**
23
+ * Hand the buyer to the provider's authentication page (FUT-698) — Stripe's
24
+ * redirect-based 3-D Secure. Park the order and navigate, the same trip a
25
+ * redirect provider's link takes (FUT-556): the return lands back on this
26
+ * checkout route, where the hosted-resume machinery polls the parked order.
27
+ *
28
+ * IT PARKS WITH THE SAME FACTS the other hand-off does, and that is a fix
29
+ * rather than a tidy-up: this call site named neither the store nor the basket,
30
+ * and both absences are read as "no opinion" by the resume — `belongsHere`
31
+ * passes an entry with no slug at ANY store, and the basket rule passes an
32
+ * entry with no basket against ANY basket. So a 3-D Secure challenge the buyer
33
+ * abandoned was exempt from both the multi-tenant scoping (FUT-556) and the
34
+ * basket binding (FUT-1213): it resumed over whatever checkout mounted next.
35
+ */
36
+ export function handOverToChallenge(
37
+ order: CheckoutOrder,
38
+ url: string,
39
+ navigate: CheckoutNavigate,
40
+ scope: ChallengeScope = {},
41
+ ): void {
42
+ // PARK FIRST. The navigation may not come back to a live SPA at all, and a
43
+ // return trip that finds nothing parked lands the buyer on a blank
44
+ // confirmation after they have paid.
45
+ const basket = parkedBasket(scope.basket);
46
+ rememberHostedOrder(order, {
47
+ ...(scope.tenantSlug ? { tenantSlug: scope.tenantSlug } : {}),
48
+ ...(basket === undefined ? {} : { basket }),
49
+ handoff: true,
50
+ });
51
+ navigate(url);
52
+ }
53
+
54
+ /**
55
+ * The refusal a charge answer carries, or nothing (FUT-1145).
56
+ *
57
+ * `undefined` for an outcome with neither field — an older server, a provider
58
+ * whose adapter classifies nothing — so the caller makes the same one-argument
59
+ * call it always did and every screen below behaves exactly as before.
60
+ */
61
+ function declineOf(outcome: ChargeOutcome): CheckoutDecline | undefined {
62
+ const reason = outcome.declineReason;
63
+ const retriable = outcome.retriable;
64
+ if (reason === undefined && retriable === undefined) return undefined;
65
+ return {
66
+ ...(reason === undefined ? {} : { reason }),
67
+ ...(retriable === undefined ? {} : { retriable }),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Bubble a terminal status up, carrying the refusal when there is one.
73
+ *
74
+ * Called with ONE argument when the server said nothing new, so a host on an
75
+ * older mount produces exactly the call it always did.
76
+ */
77
+ export function reportResolved(outcome: ChargeOutcome, onResolved: OnCheckoutResolved): void {
78
+ const refusal = declineOf(outcome);
79
+ if (refusal) onResolved(outcome.status, refusal);
80
+ else onResolved(outcome.status);
81
+ }
@@ -11,8 +11,9 @@ import {
11
11
  import { useCheckoutCopy } from "./copy-context";
12
12
  import { UNRESOLVED_CODE } from "./failure-codes";
13
13
  import { StalledWait } from "./stalled-wait";
14
+ import type { CheckoutBasketIdentity } from "./basket";
14
15
  import type { CardChainLink } from "./method-capability";
15
- import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
16
+ import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved } from "./types";
16
17
  import { useCheckoutComponents } from "./ui";
17
18
  import { useCardCheckout, type CardCheckout } from "./use-card-checkout";
18
19
 
@@ -97,6 +98,40 @@ function ChargeFailure({
97
98
  );
98
99
  }
99
100
 
101
+ /**
102
+ * What the buyer pays WITH: the saved cards they may reuse, and the form for a
103
+ * new one.
104
+ *
105
+ * The picker is absent when there is nothing saved, and the form is absent
106
+ * while a saved card is selected — so a buyer retrying a refused card (FUT-1145)
107
+ * lands on the form, because nothing is preselected for them.
108
+ */
109
+ function CardInstrumentFields({ card }: { card: CardCheckout }): JSX.Element {
110
+ return (
111
+ <>
112
+ {card.savedCards.length > 0 ? (
113
+ <SavedCardsPicker
114
+ savedCards={card.savedCards}
115
+ selection={card.selection}
116
+ onSelect={card.setSelection}
117
+ />
118
+ ) : null}
119
+
120
+ {card.usingNewCard ? (
121
+ <NewCardForm
122
+ card={card.card}
123
+ fieldErrors={card.fieldErrors}
124
+ brand={card.brand}
125
+ saveCard={card.saveCard}
126
+ setCard={card.setCard}
127
+ setFieldErrors={card.setFieldErrors}
128
+ onSaveCardChange={card.setSaveCard}
129
+ />
130
+ ) : null}
131
+ </>
132
+ );
133
+ }
134
+
100
135
  /**
101
136
  * Card payment view (FUT-58). Card data is validated + formatted client-side, then
102
137
  * tokenized (mock PagBank JS SDK) so the PAN never reaches our server; only the
@@ -115,6 +150,8 @@ export function CardView({
115
150
  tenantSlug,
116
151
  onResolved,
117
152
  pollIntervalMs = 2500,
153
+ freshInstrument = false,
154
+ basket,
118
155
  }: {
119
156
  order: CheckoutOrder;
120
157
  buyer?: BuyerInfo;
@@ -128,12 +165,33 @@ export function CardView({
128
165
  providerChain?: CardChainLink[];
129
166
  /** Scopes the saved-card list to cards the store's provider can charge. */
130
167
  tenantSlug?: string;
131
- onResolved: (status: OrderStatus) => void;
168
+ onResolved: OnCheckoutResolved;
132
169
  pollIntervalMs?: number;
170
+ /**
171
+ * Preselect NOTHING from the saved list (FUT-1145): the buyer is back here
172
+ * because a card was refused, and the card this would otherwise choose for
173
+ * them is that one.
174
+ */
175
+ freshInstrument?: boolean;
176
+ /**
177
+ * WHICH basket this checkout is for (FUT-1213) — parked with the order when
178
+ * a 3-D Secure challenge sends the buyer to the provider's page.
179
+ */
180
+ basket?: CheckoutBasketIdentity;
133
181
  }): JSX.Element {
134
182
  const { Text } = useCheckoutComponents();
135
183
  const copy = useCheckoutCopy().screens.card;
136
- const cc = useCardCheckout(order, buyer, providerConfig, onResolved, pollIntervalMs, tenantSlug, providerChain);
184
+ const cc = useCardCheckout(
185
+ order,
186
+ buyer,
187
+ providerConfig,
188
+ onResolved,
189
+ pollIntervalMs,
190
+ tenantSlug,
191
+ providerChain,
192
+ freshInstrument,
193
+ { tenantSlug, basket },
194
+ );
137
195
  // A charge NOBODY can confirm yet is not a decline (FUT-563). Some provider
138
196
  // may be holding the buyer's money, so it gets its own presentation: the
139
197
  // danger heading "Não foi possível pagar" contradicts the body's "não pague
@@ -150,25 +208,7 @@ export function CardView({
150
208
  {copy.heading}
151
209
  </Text>
152
210
 
153
- {cc.savedCards.length > 0 ? (
154
- <SavedCardsPicker
155
- savedCards={cc.savedCards}
156
- selection={cc.selection}
157
- onSelect={cc.setSelection}
158
- />
159
- ) : null}
160
-
161
- {cc.usingNewCard ? (
162
- <NewCardForm
163
- card={cc.card}
164
- fieldErrors={cc.fieldErrors}
165
- brand={cc.brand}
166
- saveCard={cc.saveCard}
167
- setCard={cc.setCard}
168
- setFieldErrors={cc.setFieldErrors}
169
- onSaveCardChange={cc.setSaveCard}
170
- />
171
- ) : null}
211
+ <CardInstrumentFields card={cc} />
172
212
 
173
213
  {cc.error ? <ChargeFailure message={cc.error} unresolved={unresolved} /> : null}
174
214
 
@@ -0,0 +1,341 @@
1
+ import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
2
+
3
+ import { buyerGateError } from "./buyer-gate";
4
+ import type { CheckoutDecline } from "./decline";
5
+ import { forgetHostedOrder, rememberHostedOrder } from "./hosted-return";
6
+ import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
7
+ import type { CheckoutNavigate } from "./navigate-context";
8
+ import type { CheckoutScreensCopy } from "./screens-copy";
9
+ import type {
10
+ BuyerContact,
11
+ BuyerField,
12
+ BuyerInfo,
13
+ CheckoutCustomerField,
14
+ CheckoutOrder,
15
+ CreateOrderRequest,
16
+ CreateOrderResult,
17
+ OrderStatus,
18
+ PaymentMethod,
19
+ } from "./types";
20
+ import { useResumeEffect, type HostedResume } from "./use-hosted-resume";
21
+
22
+ /**
23
+ * The controller's own moving parts, one concern per hook.
24
+ *
25
+ * Split out of `./use-checkout-controller.ts` when the resumed leg grew a
26
+ * decision (FUT-1213), a general parked entry (FUT-1140) and a way out
27
+ * (FUT-1146) and that file's one exported function reached its size gate. The
28
+ * seam is the obvious one: everything here is state a step reads, and nothing
29
+ * here knows the ORDER the steps come in.
30
+ */
31
+
32
+ /** Where the flow can be — the step ids, which are the flow's own contract. */
33
+ export type Step = "dados" | "payment" | "status";
34
+
35
+ /**
36
+ * The flow's navigation actions, split out of {@link useCheckoutController} for
37
+ * the 80-line per-function gate.
38
+ *
39
+ * `back` is where "the Dados step was skipped" has to be honoured: going back
40
+ * off Pagamento normally lands on Dados, but for a buyer with a CPF on file
41
+ * that step is not part of their flow, so the menu is the only honest
42
+ * destination — until they open it themselves via `editBuyer` ("alterar" on the
43
+ * payer block), after which it IS part of their flow and back returns to it.
44
+ */
45
+ export function useCheckoutNav(
46
+ taxIdOnFile: boolean,
47
+ goToMenu: () => void,
48
+ setStep: Dispatch<SetStateAction<Step>>,
49
+ ): { back: () => void; editBuyer: (() => void) | undefined } {
50
+ const [dadosOpened, setDadosOpened] = useState(false);
51
+ const openDados = useCallback(() => {
52
+ setDadosOpened(true);
53
+ setStep("dados");
54
+ }, [setStep]);
55
+ const back = useCallback(() => {
56
+ setStep((current) => {
57
+ if (current === "payment" && (!taxIdOnFile || dadosOpened)) return "dados";
58
+ goToMenu();
59
+ return current;
60
+ });
61
+ }, [dadosOpened, goToMenu, setStep, taxIdOnFile]);
62
+ // Undefined unless Dados was skipped — the payer block keys off its presence,
63
+ // so the decision lives here rather than being re-derived by every caller.
64
+ return { back, editBuyer: taxIdOnFile ? openDados : undefined };
65
+ }
66
+
67
+ /**
68
+ * What the resumed leg contributes to the controller's surface.
69
+ *
70
+ * All of it is inert for a checkout that never left this tab and never raised
71
+ * anything: with nothing parked the poll is disabled, so the error stays null,
72
+ * the bound never elapses, and neither action has a wait to act on.
73
+ */
74
+ export function resumeSurface(resume: HostedResume): {
75
+ resumeTimedOut: boolean;
76
+ resumeError: string | null;
77
+ resumeCheckAgain: () => void;
78
+ resumeRelease: (() => void) | undefined;
79
+ resumeReleasing: boolean;
80
+ } {
81
+ return {
82
+ resumeTimedOut: resume.timedOut,
83
+ resumeError: resume.error,
84
+ resumeCheckAgain: resume.checkAgain,
85
+ resumeRelease: resume.release,
86
+ resumeReleasing: resume.releasing,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Open the flow on whatever was resumed, and close it again if the buyer
92
+ * releases the order (FUT-1140/FUT-1146).
93
+ *
94
+ * A LAYOUT effect, through `useResumeEffect`: the decision cannot be made
95
+ * during render (it waits for the host's cart), and an ordinary effect runs
96
+ * after paint — so a buyer coming back from a payment would see one frame of
97
+ * the Dados or Pagamento step before their confirmation replaced it.
98
+ */
99
+ export function useResumedCheckout(
100
+ resume: HostedResume,
101
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>,
102
+ setStep: Dispatch<SetStateAction<Step>>,
103
+ setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>,
104
+ setMethod: Dispatch<SetStateAction<PaymentMethod | null>>,
105
+ ): void {
106
+ const { order, step, released } = resume;
107
+ useResumeEffect(() => {
108
+ if (!order || !step) return;
109
+ setOrder(order);
110
+ setStep(step);
111
+ // The METHOD comes back with it, which matters for a resume that lands on
112
+ // the payment step: the picker keys off it, and a shopper looking at the
113
+ // PIX code they were already paying must not also be asked to choose how
114
+ // to pay. Set through the raw setter on purpose — the public `setMethod`
115
+ // drops the order on a change, which is the opposite of resuming one.
116
+ setMethod(order.method);
117
+ }, [order, step, setOrder, setStep, setMethod]);
118
+ useResumeEffect(() => {
119
+ if (!released) return;
120
+ // Back to a checkout they can actually use, with the basket they are
121
+ // holding. The order they released is gone from this screen; whether it is
122
+ // gone at the provider is the server's answer, not ours.
123
+ setOrder(null);
124
+ setFinalStatus(null);
125
+ setStep("payment");
126
+ }, [released, setOrder, setStep, setFinalStatus]);
127
+ }
128
+
129
+ /**
130
+ * The end of a checkout: tell the host, and let the parked entry go.
131
+ *
132
+ * PAID fires the host's `onPaid` port. FUT-601 made the SERVER empty the cart
133
+ * inside the confirmation transaction — but nothing told the SPA, whose cart
134
+ * provider survives every checkout route change and kept counting the items the
135
+ * buyer had just bought. A FAILED or EXPIRED order fires nothing: that shopper
136
+ * still has a basket to retry with, and the host must not be told otherwise.
137
+ *
138
+ * The parked entry is dropped on ANY terminal status (FUT-1140). It exists to
139
+ * carry one payment across a torn-down SPA; once that payment has an answer,
140
+ * resuming it could only re-show an outcome the buyer has already been given.
141
+ */
142
+ export function useSettledPort(settled: OrderStatus | null, onPaid: (() => void) | undefined): void {
143
+ useEffect(() => {
144
+ if (!settled || settled === "AWAITING_PAYMENT") return;
145
+ forgetHostedOrder();
146
+ if (settled === "PAID") onPaid?.();
147
+ }, [settled, onPaid]);
148
+ }
149
+
150
+ /**
151
+ * The create-order refusal the steps render: what to say, which field to
152
+ * highlight, and the machine CODE that decides how it is presented — an
153
+ * unresolved charge is not a failed one, and the Pagamento step must not offer
154
+ * it a "Tentar novamente" (FUT-563). One hook so the three always move
155
+ * together; they were three `useState`s that could be cleared apart.
156
+ */
157
+ export function useCreateFailure() {
158
+ const [message, setMessage] = useState<string | null>(null);
159
+ const [field, setField] = useState<BuyerField | null>(null);
160
+ const [code, setCode] = useState<string | null>(null);
161
+ const clear = useCallback(() => {
162
+ setMessage(null);
163
+ setField(null);
164
+ setCode(null);
165
+ }, []);
166
+ const fail = useCallback(
167
+ (next: { message: string; field?: BuyerField | null; code?: string }) => {
168
+ setMessage(next.message);
169
+ setField(next.field ?? null);
170
+ setCode(next.code ?? null);
171
+ },
172
+ [],
173
+ );
174
+ return { message, field, code, clear, fail };
175
+ }
176
+
177
+
178
+ /**
179
+ * "Continuar" on the Dados step: gate on what the store's chain demands, then
180
+ * PERSIST the buyer's details before advancing.
181
+ *
182
+ * The write happens HERE and not when a payment is raised, because everything
183
+ * after this step can fail — no provider configured, a declined card, an
184
+ * abandoned PIX, a closed tab — and the details must survive all of it.
185
+ * Fire-and-forget on purpose: making the buyer wait on the write, or blocking
186
+ * them when it fails, would trade the bug for a worse one. Gated on the
187
+ * "salvar meus dados" consent (LGPD), which is what the checkbox means.
188
+ */
189
+ export function useGoToPayment(input: {
190
+ buyer: BuyerInfo;
191
+ buyerFields: readonly CheckoutCustomerField[];
192
+ taxIdOnFile: boolean;
193
+ saveProfile: boolean;
194
+ saveBuyerContact: ((contact: BuyerContact) => void) | undefined;
195
+ validation: CheckoutScreensCopy["validation"];
196
+ failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null }) => void };
197
+ setStep: Dispatch<SetStateAction<Step>>;
198
+ }): () => void {
199
+ const { buyer, buyerFields, taxIdOnFile, saveProfile, saveBuyerContact } = input;
200
+ const { validation, failure, setStep } = input;
201
+ return useCallback(() => {
202
+ failure.clear();
203
+ const complaint = buyerGateError(validation, buyer, buyerFields, taxIdOnFile);
204
+ if (complaint) {
205
+ failure.fail(complaint);
206
+ return;
207
+ }
208
+ if (saveProfile) {
209
+ saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
210
+ }
211
+ setStep("payment");
212
+ }, [buyer, buyerFields, saveProfile, taxIdOnFile, failure, saveBuyerContact, validation, setStep]);
213
+ }
214
+
215
+ /**
216
+ * "Tentar novamente" after a refusal — and WHICH order it goes against.
217
+ *
218
+ * A RETRIABLE decline keeps the order (FUT-1145). Minting a new one for every
219
+ * refused card leaves a trail of failed orders in the buyer's own history for
220
+ * one purchase they are still trying to make, and the money rule is unchanged
221
+ * either way: the server refuses a second charge on a payable it has already
222
+ * settled. `freshInstrument` is the other half — the saved card that just
223
+ * failed is not chosen for them again.
224
+ */
225
+ export function useRetryAction(input: {
226
+ decline: CheckoutDecline | null;
227
+ order: CheckoutOrder | null;
228
+ clearError: () => void;
229
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
230
+ setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
231
+ setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
232
+ setStep: Dispatch<SetStateAction<Step>>;
233
+ setFreshInstrument: Dispatch<SetStateAction<boolean>>;
234
+ }): () => void {
235
+ const { decline, order, clearError, setOrder, setDecline } = input;
236
+ const { setFinalStatus, setStep, setFreshInstrument } = input;
237
+ return useCallback(() => {
238
+ setFinalStatus(null);
239
+ clearError();
240
+ setStep("payment");
241
+ if (decline?.retriable && order) {
242
+ setFreshInstrument(true);
243
+ return;
244
+ }
245
+ setOrder(null);
246
+ setDecline(null);
247
+ forgetHostedOrder();
248
+ }, [
249
+ clearError, decline, order, setDecline, setFinalStatus, setFreshInstrument, setOrder, setStep,
250
+ ]);
251
+ }
252
+
253
+ /**
254
+ * Hand the buyer to a redirect provider's own page, if that is where this
255
+ * charge settles (FUT-556).
256
+ *
257
+ * Called BEFORE the order is stored: storing it first would render the PIX or
258
+ * card step for a provider that returned neither, which is the dead end this
259
+ * fixes.
260
+ *
261
+ * A full navigation rather than the host's router, because the destination is
262
+ * another origin. The return trip comes back to this same checkout route
263
+ * carrying `transaction_nsu` + `slug`, which the status poll already reads.
264
+ *
265
+ * @returns true when the buyer is on their way and the caller must stop.
266
+ */
267
+ function handOverToProvider(
268
+ order: CheckoutOrder,
269
+ navigate: CheckoutNavigate,
270
+ tenantSlug?: string,
271
+ basket?: CheckoutBasketIdentity,
272
+ ): boolean {
273
+ if (!order.hostedCheckoutUrl) return false;
274
+ // PARK FIRST, navigate second. The order is the only thing the return trip
275
+ // has to rehydrate from, and the navigation may tear this SPA down before
276
+ // any later write lands.
277
+ //
278
+ // The STORE goes with it: one tab holds one slot, and on a multi-tenant
279
+ // storefront every store shares an origin. Without the slug, abandoning this
280
+ // hand-off and opening another store's checkout resumed THIS order there.
281
+ //
282
+ // So does the BASKET (FUT-1213): a hand-off nobody completed must not resume
283
+ // itself over the shopper's next basket, and the only way to tell the two
284
+ // apart later is to record which basket this one was raised from.
285
+ rememberHostedOrder(order, {
286
+ tenantSlug,
287
+ basket: parkedBasket(basket),
288
+ handoff: true,
289
+ });
290
+ navigate(order.hostedCheckoutUrl);
291
+ return true;
292
+ }
293
+
294
+ /**
295
+ * Raise the order for a chosen method, and decide what happens to it.
296
+ *
297
+ * Three outcomes, in order: a refusal the step renders, a HAND-OFF that leaves
298
+ * this page for the provider's own, and an order raised here.
299
+ */
300
+ export function useStartPayment(input: {
301
+ buyer: BuyerInfo;
302
+ saveProfile: boolean;
303
+ createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
304
+ navigate: CheckoutNavigate;
305
+ tenantSlug: string | undefined;
306
+ basket: CheckoutBasketIdentity | undefined;
307
+ failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null; code?: string }) => void };
308
+ setCreating: Dispatch<SetStateAction<boolean>>;
309
+ setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
310
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
311
+ setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
312
+ }): (chosen: PaymentMethod, override?: BuyerInfo) => Promise<void> {
313
+ const { buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure } = input;
314
+ const { setCreating, setDecline, setOrder, setFinalStatus } = input;
315
+ return useCallback(
316
+ async (chosen: PaymentMethod, override?: BuyerInfo) => {
317
+ failure.clear();
318
+ setDecline(null);
319
+ setCreating(true);
320
+ const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
321
+ setCreating(false);
322
+ if (!result.ok) {
323
+ failure.fail(result.error);
324
+ return;
325
+ }
326
+ if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
327
+ // PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
328
+ // discards this tab while the shopper is in their bank app, and the SPA
329
+ // that comes back has never heard of the order it raised — so the buyer
330
+ // meets an empty cart and a retry button instead of the confirmation for
331
+ // the payment they just made.
332
+ rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
333
+ setOrder(result.data);
334
+ setFinalStatus(null);
335
+ },
336
+ [
337
+ buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
338
+ setCreating, setDecline, setOrder, setFinalStatus,
339
+ ],
340
+ );
341
+ }