@12-apps/payments-frontend 1.7.0 → 1.8.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.
@@ -9,6 +9,7 @@ import {
9
9
  cardPathAvailable,
10
10
  cardTokenization,
11
11
  offeredMethods,
12
+ selectableMethods,
12
13
  usePreselectSoleMethod,
13
14
  } from "./method-capability";
14
15
  import { MethodPicker } from "./method-picker";
@@ -18,6 +19,7 @@ import { PixView } from "./pix-view";
18
19
  import type {
19
20
  BuyerField,
20
21
  BuyerInfo,
22
+ CheckoutCustomerField,
21
23
  CheckoutOrder,
22
24
  CheckoutProviderConfig,
23
25
  OrderStatus,
@@ -155,6 +157,7 @@ export function DadosStep({
155
157
  errorField,
156
158
  onContinue,
157
159
  cartTotals,
160
+ buyerFields,
158
161
  discountLines,
159
162
  totalOverride,
160
163
  }: {
@@ -167,6 +170,8 @@ export function DadosStep({
167
170
  onContinue: () => void;
168
171
  /** The host cart's own totals — what the pay bar shows in cart mode. */
169
172
  cartTotals: { totalLabel: string; totalItems: number };
173
+ /** What the store's chain declares it needs (FUT-595); absent ⇒ CPF-required. */
174
+ buyerFields?: readonly CheckoutCustomerField[];
170
175
  /**
171
176
  * The saving, itemized under the total the buyer is about to authorize
172
177
  * (FUT-246) — RENDERED BY THE HOST from its cart (the storefront passes its
@@ -177,7 +182,7 @@ export function DadosStep({
177
182
  /** Comanda settlement (FUT-comandas): totals come from the comanda, not the cart. */
178
183
  totalOverride?: { label: string; items: number };
179
184
  }): JSX.Element {
180
- const { ActionBar, Alert, Button, Checkbox, Text } = useCheckoutComponents();
185
+ const { Checkbox } = useCheckoutComponents();
181
186
  const { label: totalLabel, items: totalItems } = displayTotals(totalOverride, cartTotals);
182
187
 
183
188
  return (
@@ -186,6 +191,7 @@ export function DadosStep({
186
191
  <BuyerInfoForm
187
192
  value={buyer}
188
193
  onChange={onBuyerChange}
194
+ fields={buyerFields}
189
195
  fieldError={errorField && createError ? { field: errorField, message: createError } : null}
190
196
  />
191
197
  <Checkbox
@@ -196,32 +202,57 @@ export function DadosStep({
196
202
  />
197
203
  </Box>
198
204
 
199
- <ActionBar dataTestId="checkout-pay-bar">
200
- <Box sx={{ width: "100%", display: "flex", flexDirection: "column", gap: 1.5 }}>
201
- {createError ? (
202
- <Alert variant="danger" title="Não foi possível continuar" description={createError} showIcon data-testid="checkout-error" />
203
- ) : null}
204
- <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
205
- <PayBarTotal totalLabel={totalLabel} totalItems={totalItems}>
206
- {/* Suppressed while settling a comanda: those totals come from
207
- the frozen ticket, not the cart. */}
208
- {totalOverride ? null : discountLines}
209
- </PayBarTotal>
210
- <Box sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.5, minWidth: 0 }}>
211
- <Button variant="solid" color="primary" size="lg" fullWidth onClick={onContinue} dataTestId="checkout-continue">
212
- Continuar
213
- </Button>
214
- <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, color: "text.secondary" }}>
215
- <LockOutlinedIcon sx={{ fontSize: 13 }} />
216
- <Text variant="caption" size="xs" color="secondary" as="span">
217
- Pagamento seguro
218
- </Text>
219
- </Box>
205
+ <DadosPayBar
206
+ totalLabel={totalLabel}
207
+ totalItems={totalItems}
208
+ createError={createError}
209
+ onContinue={onContinue}
210
+ >
211
+ {/* Suppressed while settling a comanda: those totals come from the
212
+ frozen ticket, not the cart. */}
213
+ {totalOverride ? null : discountLines}
214
+ </DadosPayBar>
215
+ </>
216
+ );
217
+ }
218
+
219
+ /** The sticky "Continuar" bar: the refusal, the money, and the one action. */
220
+ function DadosPayBar({
221
+ totalLabel,
222
+ totalItems,
223
+ createError,
224
+ onContinue,
225
+ children,
226
+ }: {
227
+ totalLabel: string;
228
+ totalItems: number;
229
+ createError: string | null;
230
+ onContinue: () => void;
231
+ children?: ReactNode;
232
+ }): JSX.Element {
233
+ const { ActionBar, Alert, Button, Text } = useCheckoutComponents();
234
+ return (
235
+ <ActionBar dataTestId="checkout-pay-bar">
236
+ <Box sx={{ width: "100%", display: "flex", flexDirection: "column", gap: 1.5 }}>
237
+ {createError ? (
238
+ <Alert variant="danger" title="Não foi possível continuar" description={createError} showIcon data-testid="checkout-error" />
239
+ ) : null}
240
+ <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
241
+ <PayBarTotal totalLabel={totalLabel} totalItems={totalItems}>{children}</PayBarTotal>
242
+ <Box sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.5, minWidth: 0 }}>
243
+ <Button variant="solid" color="primary" size="lg" fullWidth onClick={onContinue} dataTestId="checkout-continue">
244
+ Continuar
245
+ </Button>
246
+ <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, color: "text.secondary" }}>
247
+ <LockOutlinedIcon sx={{ fontSize: 13 }} />
248
+ <Text variant="caption" size="xs" color="secondary" as="span">
249
+ Pagamento seguro
250
+ </Text>
220
251
  </Box>
221
252
  </Box>
222
253
  </Box>
223
- </ActionBar>
224
- </>
254
+ </Box>
255
+ </ActionBar>
225
256
  );
226
257
  }
227
258
 
@@ -283,8 +314,9 @@ export function PaymentStep({
283
314
  }: PaymentStepProps): JSX.Element {
284
315
  const { LoadingState } = useCheckoutComponents();
285
316
  const cardUnavailable = !cardPathAvailable(providerConfig ?? null);
317
+ const offered = offeredMethods(providerConfig ?? null);
286
318
  useAutoRaiseOrder(order, method, creating, createError, onGenerate);
287
- usePreselectSoleMethod(cardUnavailable, method, onMethodChange);
319
+ usePreselectSoleMethod(selectableMethods(offered, cardUnavailable), method, onMethodChange);
288
320
 
289
321
  return (
290
322
  <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
@@ -295,7 +327,7 @@ export function PaymentStep({
295
327
  value={method}
296
328
  onChange={onMethodChange}
297
329
  cardUnavailable={cardUnavailable}
298
- offered={offeredMethods(providerConfig ?? null)}
330
+ offered={offered}
299
331
  />
300
332
 
301
333
  <PaymentBody
@@ -0,0 +1,57 @@
1
+ /**
2
+ * WHICH client the checkout screens talk through (FUT-741).
3
+ *
4
+ * Before this, every screen imported the free functions in `client.ts`
5
+ * directly, so the mount they reach was a module-level constant: one prefix,
6
+ * the ambient `fetch`, no headers. That is right for the shipped storefront
7
+ * and impossible for anything else — a story, a harness page or a second host
8
+ * on a different mount could only get at the wire by replacing `globalThis.
9
+ * fetch`, which is a mock of OUR OWN client and the exact blind spot the
10
+ * FUT-740 review's three criticals lived in.
11
+ *
12
+ * So the client is context now, with the module-level one as the default. A
13
+ * tree with no provider behaves exactly as it did; a tree under
14
+ * `createPaymentFlows`'s provider talks through the bound transport.
15
+ *
16
+ * The default is built from the `client.ts` bindings LAZILY (inside each
17
+ * arrow), so a suite that `vi.mock`s that module still intercepts the call.
18
+ */
19
+ import { createContext, useContext, type JSX, type ReactNode } from "react";
20
+
21
+ import {
22
+ chargeCard,
23
+ fetchCheckoutConfig,
24
+ listSavedCards,
25
+ pollOrderStatus,
26
+ refreshCardPublicKey,
27
+ } from "./client";
28
+ import type { CheckoutClient } from "./transport";
29
+
30
+ /** The unbound client: `/api/checkout` on the ambient `fetch`. */
31
+ const DEFAULT_CLIENT: CheckoutClient = {
32
+ getConfig: (tenantSlug) => fetchCheckoutConfig(tenantSlug),
33
+ getStatus: (ref) => pollOrderStatus(ref),
34
+ charge: (input) => chargeCard(input),
35
+ listInstruments: (tenantSlug) => listSavedCards(tenantSlug),
36
+ refreshBrowserKey: (input) => refreshCardPublicKey(input),
37
+ };
38
+
39
+ const CheckoutClientContext = createContext<CheckoutClient | null>(null);
40
+
41
+ /** Point everything below at one bound {@link CheckoutClient}. */
42
+ export function CheckoutClientProvider({
43
+ client,
44
+ children,
45
+ }: {
46
+ client: CheckoutClient;
47
+ children: ReactNode;
48
+ }): JSX.Element {
49
+ return (
50
+ <CheckoutClientContext.Provider value={client}>{children}</CheckoutClientContext.Provider>
51
+ );
52
+ }
53
+
54
+ /** The bound client, or the module default when no provider sits above. */
55
+ export function useCheckoutClientApi(): CheckoutClient {
56
+ return useContext(CheckoutClientContext) ?? DEFAULT_CLIENT;
57
+ }
@@ -4,6 +4,14 @@
4
4
  * provider-protocol read, against the host-mounted `/api/checkout*` surface
5
5
  * (documented in `packages/payments/ADOPTING.md` §3).
6
6
  *
7
+ * Since FUT-741 these are the UNBOUND door onto `createCheckoutClient` — the
8
+ * same five calls, on the default `/api/checkout` prefix and the ambient
9
+ * `fetch`. They stay exported, and stay byte-identical on the wire, because
10
+ * they are what is already running in buyers' browsers and what both pinned
11
+ * wire suites drive. A host that needs a different mount, its own headers or an
12
+ * injected `fetch` reaches for `createPaymentFlows({ transport })` instead;
13
+ * nothing about these functions changes when it does.
14
+ *
7
15
  * Order CREATION and the buyer-profile save are deliberately NOT here: both
8
16
  * are host domain (the cart, the account) and reach the flow as ports
9
17
  * (`createOrder` / `saveBuyerContact` on `CheckoutFlowProps`).
@@ -14,8 +22,9 @@
14
22
  */
15
23
 
16
24
  import type { SavedCard } from "../../card";
17
- import { err, ok, type Result } from "../../result";
25
+ import type { Result } from "../../result";
18
26
 
27
+ import { createCheckoutClient } from "./transport";
19
28
  import type {
20
29
  ChargeCardInput,
21
30
  ChargeOutcome,
@@ -23,91 +32,15 @@ import type {
23
32
  OrderStatus,
24
33
  } from "./types";
25
34
 
26
- // ---------------------------------------------------------------------------
27
- // Transport
28
- // ---------------------------------------------------------------------------
29
-
30
- /** Envelope the API routes return: `{ data }` on success, `{ error }` on failure. */
31
- interface ApiEnvelope<T> {
32
- data?: T;
33
- error?: string;
34
- /**
35
- * Stable machine code for the failure (`checkoutErrorResponse` always sends
36
- * one). Carried through so a surface can PRESENT a refusal for what it is —
37
- * an unresolved charge is not a decline, and rendering it under "não foi
38
- * possível pagar" with a live pay button invites the second payment its own
39
- * text forbids.
40
- */
41
- code?: string;
42
- }
43
-
44
- /**
45
- * A non-2xx envelope as a {@link Result} failure, carrying its machine CODE.
46
- * The message is what the buyer reads; the code is what a surface uses to
47
- * decide how to PRESENT it, which a message cannot be parsed for.
48
- */
49
- function refused<T>(json: ApiEnvelope<T> | null): Result<T> {
50
- return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.", json?.code);
51
- }
52
-
53
- /** Call an API route and normalize the response into a {@link Result}. */
54
- async function requestResult<T>(input: string, init?: RequestInit): Promise<Result<T>> {
55
- try {
56
- const res = await fetch(input, {
57
- ...init,
58
- headers: { "Content-Type": "application/json", ...init?.headers },
59
- });
60
- const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
61
- if (!res.ok) return refused(json);
62
- if (!json || json.data === undefined) {
63
- return err(json?.error ?? "Resposta inválida do servidor.");
64
- }
65
- return ok(json.data);
66
- } catch {
67
- return err("Não foi possível conectar. Verifique sua conexão e tente novamente.");
68
- }
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // Public API
73
- // ---------------------------------------------------------------------------
74
-
75
35
  /**
76
- * What a hosted checkout appended to the return URL when it sent the buyer
77
- * back, or undefined.
78
- *
79
- * InfinitePay's `payment_check` refuses to confirm without BOTH the
80
- * `transaction_nsu` and the invoice `slug`, and neither exists until somebody
81
- * has actually paid — they arrive here, on the redirect. Left in the URL: the
82
- * server treats them as hints, so re-sending them on later polls is harmless.
83
- *
84
- * Note the buyer has to press "Continuar" on the provider's receipt for this
85
- * redirect to happen at all, which is exactly why it is a hint and never the
86
- * mechanism: the webhook, and the server-side reconciliation behind it, are
87
- * what must work when they simply close the tab.
36
+ * The default binding: `/api/checkout` on the ambient `fetch`. Built once, but
37
+ * it resolves `fetch` per call, so a suite that stubs the global still wins.
88
38
  */
89
- function returnedSettlement(): Record<string, string> {
90
- if (typeof window === "undefined") return {};
91
- const params = new URLSearchParams(window.location.search);
92
- // BOTH. Measured against the live API: handle + order_nsu +
93
- // transaction_nsu + slug answers {"success":true,"paid":true,…}, and the
94
- // same call missing EITHER answers {"success":false}. Neither exists before
95
- // the payment, and the slug is not in the link-creation response — the
96
- // redirect and the webhook are the only places both appear together.
97
- const transactionNsu = params.get("transaction_nsu") ?? params.get("transaction_id") ?? "";
98
- const slug = params.get("slug") ?? "";
99
- return {
100
- ...(transactionNsu ? { transactionNsu } : {}),
101
- ...(slug ? { slug } : {}),
102
- };
103
- }
39
+ const defaultClient = createCheckoutClient();
104
40
 
105
41
  /** Poll an order's reconciled status (async provider webhook confirmation). */
106
42
  export async function pollOrderStatus(orderId: string): Promise<Result<OrderStatus>> {
107
- const params = new URLSearchParams({ orderId, ...returnedSettlement() });
108
- return requestResult<OrderStatus>(`/api/checkout/status?${params.toString()}`, {
109
- method: "GET",
110
- });
43
+ return defaultClient.getStatus(orderId);
111
44
  }
112
45
 
113
46
  /**
@@ -119,10 +52,7 @@ export async function pollOrderStatus(orderId: string): Promise<Result<OrderStat
119
52
  export async function fetchCheckoutConfig(
120
53
  tenantSlug: string,
121
54
  ): Promise<Result<CheckoutProviderConfig>> {
122
- return requestResult<CheckoutProviderConfig>(
123
- `/api/checkout/config?tenantSlug=${encodeURIComponent(tenantSlug)}`,
124
- { method: "GET" },
125
- );
55
+ return defaultClient.getConfig(tenantSlug);
126
56
  }
127
57
 
128
58
  /**
@@ -135,10 +65,7 @@ export async function fetchCheckoutConfig(
135
65
  export async function refreshCardPublicKey(input: {
136
66
  orderId: string;
137
67
  }): Promise<Result<{ publicKey: string | null }>> {
138
- return requestResult<{ publicKey: string | null }>("/api/checkout/refresh-key", {
139
- method: "POST",
140
- body: JSON.stringify({ orderId: input.orderId }),
141
- });
68
+ return defaultClient.refreshBrowserKey(input);
142
69
  }
143
70
 
144
71
  /**
@@ -147,29 +74,10 @@ export async function refreshCardPublicKey(input: {
147
74
  * page (3-D Secure, FUT-698) — the caller then hands the buyer over.
148
75
  */
149
76
  export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeOutcome>> {
150
- return requestResult<ChargeOutcome>("/api/checkout/charge", {
151
- method: "POST",
152
- body: JSON.stringify({
153
- orderId: input.orderId,
154
- token: input.token,
155
- // One instrument per provider (FUT-563) — the server hands each provider
156
- // in the chain its own, which is what lets a card charge fail over.
157
- ...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
158
- saveCard: input.saveCard,
159
- cardMeta: input.cardMeta,
160
- taxId: input.taxId,
161
- }),
162
- });
77
+ return defaultClient.charge(input);
163
78
  }
164
79
 
165
80
  /** List saved cards available for reuse (empty on any error — non-blocking). */
166
81
  export async function listSavedCards(tenantSlug?: string): Promise<SavedCard[]> {
167
- // Scoped to the store when known (FUT-697): only cards the store's ACTIVE
168
- // provider can actually charge come back — a PagBank-vaulted card is not
169
- // offered for a Stone charge it would fail (or misroute).
170
- const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
171
- const result = await requestResult<SavedCard[]>(`/api/checkout/cards${query}`, {
172
- method: "GET",
173
- });
174
- return result.ok ? result.data : [];
82
+ return defaultClient.listInstruments(tenantSlug);
175
83
  }
@@ -128,16 +128,38 @@ export function offeredMethods(config: CheckoutProviderConfig | null): PaymentMe
128
128
  }
129
129
 
130
130
  /**
131
- * When the card path is unavailable, PIX is the only choice left — choose it
132
- * (FUT-697 review): a one-option radiogroup that still demands a tap is a
133
- * click that buys the buyer nothing.
131
+ * The methods a buyer can actually pick right now.
132
+ *
133
+ * `null` offered still loading, or a fetch blip — fails OPEN and offers both;
134
+ * the server still refuses the charge closed. A CARD tile with no in-browser
135
+ * path is not selectable, so it does not count towards a "sole" method either.
134
136
  */
135
- export function usePreselectSoleMethod(
137
+ export function selectableMethods(
138
+ offered: PaymentMethod[] | null,
136
139
  cardUnavailable: boolean,
140
+ ): PaymentMethod[] {
141
+ return (offered ?? ["PIX", "CARD"]).filter(
142
+ (method) => !(method === "CARD" && cardUnavailable),
143
+ );
144
+ }
145
+
146
+ /**
147
+ * A SOLE remaining method is not a choice — take it (FUT-697 review, widened by
148
+ * FUT-741).
149
+ *
150
+ * A one-option radiogroup that still demands a tap is a click that buys the
151
+ * buyer nothing. It used to fire only when the CARD tile was disabled, which
152
+ * missed the other shape entirely: a store whose chain declares PIX alone
153
+ * renders one tile, nothing preselects it, and the buyer sits on a picker with
154
+ * a single option and no payment below it.
155
+ */
156
+ export function usePreselectSoleMethod(
157
+ selectable: readonly PaymentMethod[],
137
158
  method: PaymentMethod | null,
138
159
  onMethodChange: (method: PaymentMethod) => void,
139
160
  ): void {
161
+ const sole = selectable.length === 1 ? selectable[0] : undefined;
140
162
  useEffect(() => {
141
- if (cardUnavailable && method === null) onMethodChange("PIX");
142
- }, [cardUnavailable, method, onMethodChange]);
163
+ if (method === null && sole) onMethodChange(sole);
164
+ }, [sole, method, onMethodChange]);
143
165
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * WHERE a hosted handover sends the buyer (FUT-741).
3
+ *
4
+ * The handover is a full navigation to ANOTHER ORIGIN — a redirect provider's
5
+ * page, or a 3-DS challenge — so it can never be the host's router. But it is
6
+ * still a thing some hosts must observe: log it, confirm it, or render an
7
+ * interstitial around it. Left as a bare `window.location.assign` inside the
8
+ * controller it was none of those, and a browser that blocks or stalls the
9
+ * navigation left the buyer on a page with nothing on it.
10
+ *
11
+ * So it is a port with a default. No provider ⇒ exactly today's behaviour.
12
+ */
13
+ import { createContext, useContext, type JSX, type ReactNode } from "react";
14
+
15
+ /** Take the buyer to another origin. */
16
+ export type CheckoutNavigate = (url: string) => void;
17
+
18
+ /** The unchanged default: a full navigation, in this tab. */
19
+ const DEFAULT_NAVIGATE: CheckoutNavigate = (url) => {
20
+ window.location.assign(url);
21
+ };
22
+
23
+ const CheckoutNavigateContext = createContext<CheckoutNavigate | null>(null);
24
+
25
+ export function CheckoutNavigateProvider({
26
+ navigate,
27
+ children,
28
+ }: {
29
+ navigate: CheckoutNavigate;
30
+ children: ReactNode;
31
+ }): JSX.Element {
32
+ return (
33
+ <CheckoutNavigateContext.Provider value={navigate}>{children}</CheckoutNavigateContext.Provider>
34
+ );
35
+ }
36
+
37
+ /** The host's navigate port, or `window.location.assign`. */
38
+ export function useCheckoutNavigate(): CheckoutNavigate {
39
+ return useContext(CheckoutNavigateContext) ?? DEFAULT_NAVIGATE;
40
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The checkout's HTTP transport, as a bound client (FUT-741).
3
+ *
4
+ * Everything `client.ts` used to do with a hard-coded prefix and the ambient
5
+ * `fetch` now lives here behind {@link createCheckoutClient}, so the same five
6
+ * calls can be pointed at a different mount, carry a host's auth headers, or —
7
+ * the reason this exists — be driven through an injected `fetch` that routes
8
+ * straight into a real `createPaymentFlowsBE` mount. A story or a harness page
9
+ * can then exercise the WIRE rather than a mock of our own client, which is the
10
+ * only place the FUT-740 review found its three criticals.
11
+ *
12
+ * `baseUrl` defaults to `/api/checkout` VERBATIM — not normalized, not
13
+ * re-derived. The published client already posts those exact paths, and both
14
+ * pinned wire suites drive them; a prefix that is "cleaned up" here breaks the
15
+ * shipped contract in the same release that introduces the factory.
16
+ */
17
+
18
+ import type { SavedCard } from "../../card";
19
+ import { err, ok, type Result } from "../../result";
20
+
21
+ import type {
22
+ ChargeCardInput,
23
+ ChargeOutcome,
24
+ CheckoutProviderConfig,
25
+ OrderStatus,
26
+ } from "./types";
27
+
28
+ /**
29
+ * The prefix every shipped buyer checkout posts to today. Exported so a host
30
+ * (or a test) can state it rather than re-type it, and so a change to it is a
31
+ * change to one named constant with this comment attached.
32
+ */
33
+ export const DEFAULT_CHECKOUT_BASE_URL = "/api/checkout";
34
+
35
+ /** Where the `createPaymentFlowsBE` mount lives, and how to reach it. */
36
+ export interface CheckoutTransport {
37
+ /** Prefix for `/config`, `/status`, `/charge`, `/cards`, `/refresh-key`. */
38
+ baseUrl?: string;
39
+ /**
40
+ * The `fetch` to call. Omitted ⇒ the ambient one, resolved PER CALL so a
41
+ * suite that stubs the global after the client was built still sees its stub.
42
+ */
43
+ fetchImpl?: typeof fetch;
44
+ /** Extra headers per request (a bearer token, a tenant header). */
45
+ headers?: () => HeadersInit | Promise<HeadersInit>;
46
+ }
47
+
48
+ /** The five calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
49
+ export interface CheckoutClient {
50
+ getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
51
+ getStatus(ref: string): Promise<Result<OrderStatus>>;
52
+ charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
53
+ listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
54
+ refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
55
+ }
56
+
57
+ /** Envelope the API routes return: `{ data }` on success, `{ error }` on failure. */
58
+ interface ApiEnvelope<T> {
59
+ data?: T;
60
+ error?: string;
61
+ /**
62
+ * Stable machine code for the failure (`checkoutErrorResponse` always sends
63
+ * one). Carried through so a surface can PRESENT a refusal for what it is —
64
+ * an unresolved charge is not a decline, and rendering it under "não foi
65
+ * possível pagar" with a live pay button invites the second payment its own
66
+ * text forbids.
67
+ */
68
+ code?: string;
69
+ }
70
+
71
+ /**
72
+ * A non-2xx envelope as a {@link Result} failure, carrying its machine CODE.
73
+ * The message is what the buyer reads; the code is what a surface uses to
74
+ * decide how to PRESENT it, which a message cannot be parsed for.
75
+ */
76
+ function refused<T>(json: ApiEnvelope<T> | null): Result<T> {
77
+ return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.", json?.code);
78
+ }
79
+
80
+ /**
81
+ * What a hosted checkout appended to the return URL when it sent the buyer
82
+ * back, or undefined.
83
+ *
84
+ * InfinitePay's `payment_check` refuses to confirm without BOTH the
85
+ * `transaction_nsu` and the invoice `slug`, and neither exists until somebody
86
+ * has actually paid — they arrive here, on the redirect. Left in the URL: the
87
+ * server treats them as hints, so re-sending them on later polls is harmless.
88
+ *
89
+ * Note the buyer has to press "Continuar" on the provider's receipt for this
90
+ * redirect to happen at all, which is exactly why it is a hint and never the
91
+ * mechanism: the webhook, and the server-side reconciliation behind it, are
92
+ * what must work when they simply close the tab.
93
+ */
94
+ function returnedSettlement(): Record<string, string> {
95
+ if (typeof window === "undefined") return {};
96
+ const params = new URLSearchParams(window.location.search);
97
+ // BOTH. Measured against the live API: handle + order_nsu +
98
+ // transaction_nsu + slug answers {"success":true,"paid":true,…}, and the
99
+ // same call missing EITHER answers {"success":false}. Neither exists before
100
+ // the payment, and the slug is not in the link-creation response — the
101
+ // redirect and the webhook are the only places both appear together.
102
+ const transactionNsu = params.get("transaction_nsu") ?? params.get("transaction_id") ?? "";
103
+ const slug = params.get("slug") ?? "";
104
+ return {
105
+ ...(transactionNsu ? { transactionNsu } : {}),
106
+ ...(slug ? { slug } : {}),
107
+ };
108
+ }
109
+
110
+ /** The ambient `fetch`, wrapped so it is never invoked detached from its global. */
111
+ function ambientFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
112
+ return globalThis.fetch(input, init);
113
+ }
114
+
115
+ /**
116
+ * The five checkout calls, bound to one transport.
117
+ *
118
+ * Passing no transport reproduces exactly what the free functions in
119
+ * `client.ts` have always done: `/api/checkout/**` on the ambient `fetch`.
120
+ */
121
+ export function createCheckoutClient(transport: CheckoutTransport = {}): CheckoutClient {
122
+ const baseUrl = transport.baseUrl ?? DEFAULT_CHECKOUT_BASE_URL;
123
+
124
+ /** Call a checkout route and normalize the response into a {@link Result}. */
125
+ async function call<T>(route: string, init?: RequestInit): Promise<Result<T>> {
126
+ // Resolved here, not captured at build time: a suite (or a story) that
127
+ // replaces the global afterwards must still be the one that answers.
128
+ const doFetch = transport.fetchImpl ?? ambientFetch;
129
+ try {
130
+ const extra = transport.headers ? await transport.headers() : undefined;
131
+ const res = await doFetch(`${baseUrl}${route}`, {
132
+ ...init,
133
+ headers: { "Content-Type": "application/json", ...extra, ...init?.headers },
134
+ });
135
+ const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
136
+ if (!res.ok) return refused(json);
137
+ if (!json || json.data === undefined) {
138
+ return err(json?.error ?? "Resposta inválida do servidor.");
139
+ }
140
+ return ok(json.data);
141
+ } catch {
142
+ return err("Não foi possível conectar. Verifique sua conexão e tente novamente.");
143
+ }
144
+ }
145
+
146
+ return {
147
+ getConfig: (tenantSlug) =>
148
+ call<CheckoutProviderConfig>(`/config?tenantSlug=${encodeURIComponent(tenantSlug)}`, {
149
+ method: "GET",
150
+ }),
151
+
152
+ getStatus: (ref) =>
153
+ call<OrderStatus>(
154
+ `/status?${new URLSearchParams({ orderId: ref, ...returnedSettlement() }).toString()}`,
155
+ { method: "GET" },
156
+ ),
157
+
158
+ charge: (input) =>
159
+ call<ChargeOutcome>("/charge", {
160
+ method: "POST",
161
+ // The FLAT body the shipped client has always sent. Pinned from both
162
+ // ends by `charge-wire.contract.test.ts`; nothing here may re-nest it.
163
+ body: JSON.stringify({
164
+ orderId: input.orderId,
165
+ token: input.token,
166
+ // One instrument per provider (FUT-563) — the server hands each
167
+ // provider in the chain its own, which is what lets a card charge
168
+ // fail over.
169
+ ...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
170
+ saveCard: input.saveCard,
171
+ cardMeta: input.cardMeta,
172
+ taxId: input.taxId,
173
+ }),
174
+ }),
175
+
176
+ listInstruments: async (tenantSlug) => {
177
+ // Scoped to the store when known (FUT-697): only cards the store's ACTIVE
178
+ // provider can actually charge come back — a PagBank-vaulted card is not
179
+ // offered for a Stone charge it would fail (or misroute).
180
+ const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
181
+ const result = await call<SavedCard[]>(`/cards${query}`, { method: "GET" });
182
+ return result.ok ? result.data : [];
183
+ },
184
+
185
+ refreshBrowserKey: (input) =>
186
+ call<{ publicKey: string | null }>("/refresh-key", {
187
+ method: "POST",
188
+ body: JSON.stringify({ orderId: input.orderId }),
189
+ }),
190
+ };
191
+ }