@12-apps/payments-frontend 1.16.0 → 1.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "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.",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "storybook:build": "storybook build"
18
18
  },
19
19
  "dependencies": {
20
- "@12-apps/payments-backend": "^1.16.0",
20
+ "@12-apps/payments-backend": "^1.17.0",
21
21
  "react-qr-code": "^2.2.0"
22
22
  },
23
23
  "peerDependencies": {
@@ -28,8 +28,8 @@
28
28
  "react-dom": ">=19.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@12-apps/eslint-config": "^1.18.0",
32
- "@12-apps/typescript-config": "^1.18.0",
31
+ "@12-apps/eslint-config": "^1.19.0",
32
+ "@12-apps/typescript-config": "^1.19.0",
33
33
  "@emotion/react": "^11.14.0",
34
34
  "@emotion/styled": "^11.14.0",
35
35
  "@mui/material": "^6.5.0",
@@ -20,6 +20,7 @@ import { createContext, useContext, type JSX, type ReactNode } from "react";
20
20
 
21
21
  import {
22
22
  chargeCard,
23
+ chargeWallet,
23
24
  fetchCheckoutConfig,
24
25
  listSavedCards,
25
26
  pollOrderStatus,
@@ -32,6 +33,7 @@ const DEFAULT_CLIENT: CheckoutClient = {
32
33
  getConfig: (tenantSlug) => fetchCheckoutConfig(tenantSlug),
33
34
  getStatus: (ref) => pollOrderStatus(ref),
34
35
  charge: (input) => chargeCard(input),
36
+ chargeWallet: (input) => chargeWallet(input),
35
37
  listInstruments: (tenantSlug) => listSavedCards(tenantSlug),
36
38
  refreshBrowserKey: (input) => refreshCardPublicKey(input),
37
39
  };
@@ -28,6 +28,7 @@ import { createCheckoutClient } from "./transport";
28
28
  import type {
29
29
  ChargeCardInput,
30
30
  ChargeOutcome,
31
+ ChargeWalletInput,
31
32
  CheckoutProviderConfig,
32
33
  OrderStatus,
33
34
  } from "./types";
@@ -77,6 +78,15 @@ export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeO
77
78
  return defaultClient.charge(input);
78
79
  }
79
80
 
81
+ /**
82
+ * Charge a wallet-minted instrument against an order (FUT-471/472) — the same
83
+ * `/charge` route as {@link chargeCard}, carrying `wallet: { type, key }` in
84
+ * place of a card token.
85
+ */
86
+ export async function chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>> {
87
+ return defaultClient.chargeWallet(input);
88
+ }
89
+
80
90
  /** List saved cards available for reuse (empty on any error — non-blocking). */
81
91
  export async function listSavedCards(tenantSlug?: string): Promise<SavedCard[]> {
82
92
  return defaultClient.listInstruments(tenantSlug);
@@ -0,0 +1,253 @@
1
+ import { Box } from "@mui/material";
2
+ import { useEffect, useRef, useState, type JSX } from "react";
3
+
4
+ import type { CheckoutOrder } from "./types";
5
+
6
+ /**
7
+ * The Google-branded pay button (FUT-471), per Google's four-step web guide —
8
+ * and ONLY the four steps. This component owns token ACQUISITION: load
9
+ * `pay.js`, construct a `PaymentsClient`, gate rendering on `isReadyToPay`,
10
+ * render the button Google's brand rules require via `createButton`, and turn
11
+ * `loadPaymentData` into the wallet key
12
+ * (`paymentData.paymentMethodData.tokenizationData.token`). What happens to
13
+ * the key — the charge, the polling, the outcome — belongs to the pane above
14
+ * (`wallet-pane.tsx`), so this file never talks to the wire.
15
+ *
16
+ * The `tokenizationSpecification` is `{ type: 'PAYMENT_GATEWAY', gateway,
17
+ * gatewayMerchantId }`, both parameters published by the store's chain head
18
+ * (`googlePayConfig`) — no vendor name is spelled here.
19
+ */
20
+
21
+ /** The slice of Google's `PaymentsClient` this button drives. */
22
+ export interface GooglePaymentsClient {
23
+ isReadyToPay(request: Record<string, unknown>): Promise<{ result: boolean }>;
24
+ createButton(options: {
25
+ onClick: () => void;
26
+ buttonSizeMode?: string;
27
+ buttonLocale?: string;
28
+ }): HTMLElement;
29
+ loadPaymentData(request: Record<string, unknown>): Promise<GooglePaymentData>;
30
+ }
31
+
32
+ /** The one path of the payment data this checkout reads. */
33
+ export interface GooglePaymentData {
34
+ paymentMethodData: { tokenizationData: { token: string } };
35
+ }
36
+
37
+ /** The `google.payments.api` namespace, as far as this button needs it. */
38
+ export interface GooglePayApi {
39
+ PaymentsClient: new (options: { environment: "TEST" | "PRODUCTION" }) => GooglePaymentsClient;
40
+ }
41
+
42
+ /** What the store's chain head published for the tokenizationSpecification. */
43
+ export interface GooglePayGatewayParams {
44
+ gateway: string;
45
+ gatewayMerchantId: string;
46
+ }
47
+
48
+ /** Google's script, loaded once per page. */
49
+ const PAY_JS_URL = "https://pay.google.com/gp/p/js/pay.js";
50
+
51
+ /**
52
+ * Card networks offered to Google: the intersection of Google's
53
+ * `allowedCardNetworks` enum and what PagBank's card acquiring processes.
54
+ * A network the gateway would refuse must not be offered on the sheet.
55
+ */
56
+ const ALLOWED_CARD_NETWORKS = ["AMEX", "ELO", "MASTERCARD", "VISA"];
57
+
58
+ /** Both auth methods of Google's guide: tokenized device cards and PAN_ONLY. */
59
+ const ALLOWED_AUTH_METHODS = ["PAN_ONLY", "CRYPTOGRAM_3DS"];
60
+
61
+ /** The `google.payments.api` global, when a script (or a harness) installed it. */
62
+ function installedApi(): GooglePayApi | null {
63
+ if (typeof window === "undefined") return null;
64
+ const scope = window as unknown as { google?: { payments?: { api?: GooglePayApi } } };
65
+ return scope.google?.payments?.api ?? null;
66
+ }
67
+
68
+ /** The in-flight (or settled) pay.js load — one script tag per page, ever. */
69
+ const loader: { pending: Promise<GooglePayApi | null> | null } = { pending: null };
70
+
71
+ /**
72
+ * Step 1 of the guide: load `pay.js` and hand back the API namespace. Answers
73
+ * `null` — never throws — when the script cannot load: an offline CDN must
74
+ * degrade to "no button", not to a crashed checkout. A pre-installed global
75
+ * (another button on the page, or an e2e harness) is used without a network
76
+ * request.
77
+ */
78
+ function loadGooglePayApi(): Promise<GooglePayApi | null> {
79
+ const installed = installedApi();
80
+ if (installed) return Promise.resolve(installed);
81
+ if (typeof document === "undefined") return Promise.resolve(null);
82
+ loader.pending ??= new Promise((resolve) => {
83
+ const script = document.createElement("script");
84
+ script.src = PAY_JS_URL;
85
+ script.async = true;
86
+ script.onload = () => resolve(installedApi());
87
+ script.onerror = () => resolve(null);
88
+ document.head.appendChild(script);
89
+ });
90
+ return loader.pending;
91
+ }
92
+
93
+ /** Step 2's probe: may this browser/device pay at all? */
94
+ function isReadyToPayRequest(): Record<string, unknown> {
95
+ return {
96
+ apiVersion: 2,
97
+ apiVersionMinor: 0,
98
+ allowedPaymentMethods: [
99
+ {
100
+ type: "CARD",
101
+ parameters: {
102
+ allowedAuthMethods: ALLOWED_AUTH_METHODS,
103
+ allowedCardNetworks: ALLOWED_CARD_NETWORKS,
104
+ },
105
+ },
106
+ ],
107
+ };
108
+ }
109
+
110
+ /** Step 4's request: the same card method, now carrying gateway + price. */
111
+ function paymentDataRequest(
112
+ params: GooglePayGatewayParams,
113
+ order: CheckoutOrder,
114
+ ): Record<string, unknown> {
115
+ return {
116
+ apiVersion: 2,
117
+ apiVersionMinor: 0,
118
+ allowedPaymentMethods: [
119
+ {
120
+ type: "CARD",
121
+ parameters: {
122
+ allowedAuthMethods: ALLOWED_AUTH_METHODS,
123
+ allowedCardNetworks: ALLOWED_CARD_NETWORKS,
124
+ },
125
+ tokenizationSpecification: {
126
+ type: "PAYMENT_GATEWAY",
127
+ parameters: {
128
+ gateway: params.gateway,
129
+ gatewayMerchantId: params.gatewayMerchantId,
130
+ },
131
+ },
132
+ },
133
+ ],
134
+ transactionInfo: {
135
+ totalPriceStatus: "FINAL",
136
+ // Integer cents to Google's decimal string — the one money conversion
137
+ // in this file, from the server-authoritative order total.
138
+ totalPrice: (order.totalCents / 100).toFixed(2),
139
+ currencyCode: "BRL",
140
+ countryCode: "BR",
141
+ },
142
+ };
143
+ }
144
+
145
+ /** The buyer closed the sheet — a choice, not a failure to report. */
146
+ function sheetDismissed(error: unknown): boolean {
147
+ return (
148
+ typeof error === "object" &&
149
+ error !== null &&
150
+ (error as { statusCode?: unknown }).statusCode === "CANCELED"
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Resolve the client and ask `isReadyToPay` — the gate that decides whether
156
+ * the button exists at all. `api` is injectable for tests and harnesses;
157
+ * `undefined` means "load pay.js".
158
+ */
159
+ function useGooglePayClient(
160
+ api: GooglePayApi | null | undefined,
161
+ environment: "TEST" | "PRODUCTION",
162
+ ): GooglePaymentsClient | null {
163
+ const [client, setClient] = useState<GooglePaymentsClient | null>(null);
164
+ useEffect(() => {
165
+ const alive = { current: true };
166
+ void (api === undefined ? loadGooglePayApi() : Promise.resolve(api)).then((resolved) => {
167
+ if (!alive.current || !resolved) return;
168
+ const paymentsClient = new resolved.PaymentsClient({ environment });
169
+ paymentsClient
170
+ .isReadyToPay(isReadyToPayRequest())
171
+ .then((answer) => {
172
+ if (alive.current && answer.result) setClient(paymentsClient);
173
+ })
174
+ .catch(() => undefined);
175
+ });
176
+ return () => {
177
+ alive.current = false;
178
+ };
179
+ }, [api, environment]);
180
+ return client;
181
+ }
182
+
183
+ export interface GooglePayButtonProps {
184
+ order: CheckoutOrder;
185
+ /** The chain head's published gateway parameters (`googlePayConfig`). */
186
+ params: GooglePayGatewayParams;
187
+ /** The sheet resolved — charge this key. */
188
+ onKey: (key: string) => void;
189
+ /** The sheet failed for a reason worth telling the buyer (not a dismissal). */
190
+ onError: (message: string) => void;
191
+ /**
192
+ * Google's environment. Defaults to TEST — production requires the external
193
+ * Google Pay registration (see the ticket), and TEST tokens exercise the
194
+ * whole path against PagBank's sandbox with fictitious instruments.
195
+ */
196
+ environment?: "TEST" | "PRODUCTION";
197
+ /** Injectable API namespace for tests/harnesses; omit to load pay.js. */
198
+ api?: GooglePayApi | null;
199
+ }
200
+
201
+ /**
202
+ * Renders NOTHING until `isReadyToPay` says this browser can pay — per the
203
+ * guide, the buyer must never see a Google Pay button that cannot work. The
204
+ * button element itself comes from `createButton` (brand rules); this
205
+ * component only gives it a mount point.
206
+ */
207
+ export function GooglePayButton({
208
+ order,
209
+ params,
210
+ onKey,
211
+ onError,
212
+ environment = "TEST",
213
+ api,
214
+ }: GooglePayButtonProps): JSX.Element | null {
215
+ const client = useGooglePayClient(api, environment);
216
+ const container = useRef<HTMLDivElement | null>(null);
217
+ // The latest handlers/order, so the Google-rendered button — mounted once —
218
+ // never closes over a stale charge target.
219
+ const current = useRef({ order, params, onKey, onError });
220
+ current.current = { order, params, onKey, onError };
221
+
222
+ useEffect(() => {
223
+ const mount = container.current;
224
+ if (!client || !mount) return undefined;
225
+ const button = client.createButton({
226
+ onClick: () => {
227
+ const { order: forOrder, params: forParams, onKey: emit, onError: fail } = current.current;
228
+ client
229
+ .loadPaymentData(paymentDataRequest(forParams, forOrder))
230
+ .then((data) => emit(data.paymentMethodData.tokenizationData.token))
231
+ .catch((error: unknown) => {
232
+ if (sheetDismissed(error)) return;
233
+ fail("Não foi possível concluir o pagamento com o Google Pay. Tente novamente ou pague com cartão.");
234
+ });
235
+ },
236
+ buttonSizeMode: "fill",
237
+ buttonLocale: "pt",
238
+ });
239
+ mount.replaceChildren(button);
240
+ return () => {
241
+ mount.replaceChildren();
242
+ };
243
+ }, [client]);
244
+
245
+ if (!client) return null;
246
+ return (
247
+ <Box
248
+ ref={container}
249
+ data-testid="google-pay-button"
250
+ sx={{ minHeight: 40, "& > *": { width: "100%" } }}
251
+ />
252
+ );
253
+ }
@@ -140,6 +140,33 @@ function toCardLink(link: CheckoutChainLink): CardChainLink {
140
140
  };
141
141
  }
142
142
 
143
+ /**
144
+ * Everything the Google Pay button needs to mint a token (FUT-471), or `null`
145
+ * when the button must not render.
146
+ *
147
+ * Read off the chain HEAD only, like the checkout screen: the wallet token is
148
+ * minted against ONE gateway's `gatewayMerchantId`, so only the provider the
149
+ * walk tries first can charge it — a tail entry's declaration cannot be
150
+ * honoured from this browser.
151
+ *
152
+ * FAILS CLOSED, deliberately the opposite of `offeredMethods`' fail-open: a
153
+ * missing config only costs the buyer a button they still have the card form
154
+ * without, while rendering one on a store that cannot charge wallets sends a
155
+ * buyer through the wallet sheet into a guaranteed refusal. Every clause
156
+ * narrows toward NOT rendering: no config, no chain, a head that cannot CARD,
157
+ * no `GOOGLE_PAY` declaration, no gateway parameters, no merchant id.
158
+ */
159
+ export function googlePayConfig(
160
+ config: CheckoutProviderConfig | null,
161
+ ): { gateway: string; gatewayMerchantId: string } | null {
162
+ const head = config?.chain?.[0];
163
+ if (!head?.methods.includes("CARD")) return null;
164
+ if (!head.wallets?.includes("GOOGLE_PAY")) return null;
165
+ const params = head.googlePay;
166
+ if (!params?.gatewayMerchantId) return null;
167
+ return { gateway: params.gateway, gatewayMerchantId: params.gatewayMerchantId };
168
+ }
169
+
143
170
  /**
144
171
  * The methods the picker may offer, from the chain's declared capabilities
145
172
  * (FUT-698). `null` config — still loading, or a fetch blip — fails OPEN like
@@ -12,37 +12,22 @@
12
12
  */
13
13
  import type { JSX } from "react";
14
14
 
15
- import { CardView } from "../card-view";
16
- import { cardChain, cardTokenization } from "../method-capability";
17
15
  import { PixView } from "../pix-view";
16
+ import { WalletCardPane } from "../wallet-pane";
18
17
 
19
18
  import type { ProviderCheckoutScreenProps } from "./types";
20
19
 
21
- export function PixAndCardScreen({
22
- order,
23
- buyer,
24
- config,
25
- tenantSlug,
26
- onResolved,
27
- pollIntervalMs,
28
- }: ProviderCheckoutScreenProps): JSX.Element | null {
20
+ export function PixAndCardScreen(props: ProviderCheckoutScreenProps): JSX.Element | null {
21
+ const { order, onResolved, pollIntervalMs } = props;
29
22
  if (order?.method === "PIX") {
30
23
  return <PixView order={order} onResolved={onResolved} pollIntervalMs={pollIntervalMs} />;
31
24
  }
32
25
  if (order?.method === "CARD") {
33
- return (
34
- <CardView
35
- order={order}
36
- buyer={buyer}
37
- providerConfig={cardTokenization(config)}
38
- // The whole chain (FUT-563): one instrument is minted per provider so
39
- // the charge survives the first one failing, with nothing re-typed.
40
- providerChain={cardChain(config)}
41
- tenantSlug={tenantSlug}
42
- onResolved={onResolved}
43
- pollIntervalMs={pollIntervalMs}
44
- />
45
- );
26
+ // The card pane, with its wallet fast lane above the form (FUT-471/472).
27
+ // The pane reads the chain itself (FUT-563: one instrument per provider,
28
+ // so the charge survives the first one failing with nothing re-typed) and
29
+ // renders exactly the old CardView for a store with no wallet.
30
+ return <WalletCardPane {...props} order={order} />;
46
31
  }
47
32
  // No order yet — the shell is still showing the picker, and raises one as
48
33
  // soon as a method is chosen.
@@ -21,6 +21,7 @@ import { err, ok, type Result } from "../../result";
21
21
  import type {
22
22
  ChargeCardInput,
23
23
  ChargeOutcome,
24
+ ChargeWalletInput,
24
25
  CheckoutProviderConfig,
25
26
  OrderStatus,
26
27
  } from "./types";
@@ -45,11 +46,13 @@ export interface CheckoutTransport {
45
46
  headers?: () => HeadersInit | Promise<HeadersInit>;
46
47
  }
47
48
 
48
- /** The five calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
49
+ /** The six calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
49
50
  export interface CheckoutClient {
50
51
  getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
51
52
  getStatus(ref: string): Promise<Result<OrderStatus>>;
52
53
  charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
54
+ /** A wallet instrument against the same `/charge` route (FUT-471/472). */
55
+ chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>>;
53
56
  listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
54
57
  refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
55
58
  }
@@ -112,6 +115,36 @@ function ambientFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Res
112
115
  return globalThis.fetch(input, init);
113
116
  }
114
117
 
118
+ /**
119
+ * The FLAT card-charge body the shipped client has always sent. Pinned from
120
+ * both ends by `charge-wire.contract.test.ts`; nothing here may re-nest it.
121
+ */
122
+ function flatChargeBody(input: ChargeCardInput): string {
123
+ return JSON.stringify({
124
+ orderId: input.orderId,
125
+ token: input.token,
126
+ // One instrument per provider (FUT-563) — the server hands each provider
127
+ // in the chain its own, which is what lets a card charge fail over.
128
+ ...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
129
+ saveCard: input.saveCard,
130
+ cardMeta: input.cardMeta,
131
+ taxId: input.taxId,
132
+ });
133
+ }
134
+
135
+ /**
136
+ * The same flat wire with `wallet` in place of `token` (FUT-471): the mount's
137
+ * draft reader takes either, and a body naming both would carry two
138
+ * instruments for one charge. Pinned by `wallet-wire.contract.test.ts`.
139
+ */
140
+ function flatWalletBody(input: ChargeWalletInput): string {
141
+ return JSON.stringify({
142
+ orderId: input.orderId,
143
+ wallet: input.wallet,
144
+ taxId: input.taxId,
145
+ });
146
+ }
147
+
115
148
  /**
116
149
  * The five checkout calls, bound to one transport.
117
150
  *
@@ -156,22 +189,10 @@ export function createCheckoutClient(transport: CheckoutTransport = {}): Checkou
156
189
  ),
157
190
 
158
191
  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
- }),
192
+ call<ChargeOutcome>("/charge", { method: "POST", body: flatChargeBody(input) }),
193
+
194
+ chargeWallet: (input) =>
195
+ call<ChargeOutcome>("/charge", { method: "POST", body: flatWalletBody(input) }),
175
196
 
176
197
  listInstruments: async (tenantSlug) => {
177
198
  // Scoped to the store when known (FUT-697): only cards the store's ACTIVE
@@ -12,6 +12,14 @@
12
12
  /** Payment methods offered at checkout. Mirrors `Payment.method` (FUT-42). */
13
13
  export type PaymentMethod = "PIX" | "CARD";
14
14
 
15
+ /**
16
+ * The digital wallets a chain entry can charge (FUT-471/472). Mirrors the
17
+ * backend's `WalletType` — a wallet is not a fourth method: it is another way
18
+ * of producing the CARD instrument, so it rides the CARD tile and the CARD
19
+ * charge.
20
+ */
21
+ export type CheckoutWalletType = "GOOGLE_PAY" | "APPLE_PAY";
22
+
15
23
  /**
16
24
  * Order lifecycle, aligned with the FUT-43 backend:
17
25
  * - `AWAITING_PAYMENT` — order created, charge raised, not yet reconciled (the
@@ -198,6 +206,20 @@ export interface ChargeCardInput {
198
206
  taxId?: string;
199
207
  }
200
208
 
209
+ /**
210
+ * Input to charge a WALLET instrument against an order (FUT-471/472) —
211
+ * `ChargeCardInput`'s wallet sibling, posted to the same `/charge` route. One
212
+ * shape for both wallets because the wire is one shape: `key` is whatever the
213
+ * wallet handed the browser (Google's `tokenizationData.token`, Apple's
214
+ * serialized `token.paymentData`), forwarded verbatim and never persisted.
215
+ */
216
+ export interface ChargeWalletInput {
217
+ orderId: string;
218
+ wallet: { type: CheckoutWalletType; key: string };
219
+ /** Buyer CPF for the provider charge (`customer.tax_id`); never persisted. */
220
+ taxId?: string;
221
+ }
222
+
201
223
  /** A buyer field a provider asks for, as `/config` publishes it (FUT-595). */
202
224
  export interface CheckoutCustomerField {
203
225
  key: "name" | "email" | "taxId" | "phone";
@@ -236,6 +258,25 @@ export interface CheckoutChainLink {
236
258
  * never shown a field for.
237
259
  */
238
260
  customerSchema?: CheckoutCustomerField[];
261
+ /**
262
+ * The digital wallets THIS entry's card charge accepts (FUT-471/472).
263
+ *
264
+ * Optional, and the DEGRADE DIRECTION IS CLOSED — the opposite of
265
+ * `customerSchema`'s above, because the stakes invert: a chain that asks for
266
+ * nothing produces a refused charge, but a wallet button on a store whose
267
+ * server cannot charge wallets produces a buyer who authorized a payment on
268
+ * the wallet sheet and was then refused. Absent (an older host) means NO
269
+ * wallet buttons, which is exactly what that host's charge route supports.
270
+ */
271
+ wallets?: CheckoutWalletType[];
272
+ /**
273
+ * Google Pay's `PAYMENT_GATEWAY` tokenizationSpecification parameters for
274
+ * THIS entry (FUT-471), or absent/`null` when it cannot run Google Pay.
275
+ * `gatewayMerchantId: null` means the connection carries no merchant id yet
276
+ * — the button must not render, because a token minted against a missing id
277
+ * charges nobody.
278
+ */
279
+ googlePay?: { gateway: string; gatewayMerchantId: string | null } | null;
239
280
  /**
240
281
  * The buyer screen THIS provider's flow needs (FUT-596) — an opaque id the
241
282
  * adapter declares and `providers/registry.ts` resolves to a component.
@@ -0,0 +1,123 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ import { useCheckoutClientApi } from "./client-context";
4
+ import { UNRESOLVED_CODE } from "./failure-codes";
5
+ import { rememberHostedOrder } from "./hosted-return";
6
+ import { useCheckoutNavigate } from "./navigate-context";
7
+ import type { BuyerInfo, CheckoutOrder, CheckoutWalletType, OrderStatus } from "./types";
8
+ import { usePaymentPolling } from "./use-payment-polling";
9
+
10
+ /**
11
+ * The submit half of a WALLET payment (FUT-471/472) — `useCardSubmit`'s
12
+ * sibling, minus everything a wallet does not have: no form to validate, no
13
+ * instrument to mint per provider (the wallet key is chain-head-bound, see the
14
+ * backend's `core/card-instrument.ts`), no vault opt-in. What remains is the
15
+ * same money path: charge → classify the outcome → poll for the async
16
+ * confirmation → bubble the terminal status up.
17
+ *
18
+ * One hook for BOTH wallets, because the wire is one shape — the buttons only
19
+ * differ in how they acquire the key.
20
+ */
21
+
22
+ /**
23
+ * Where the wallet payment stands. `idle` renders the buttons and the card
24
+ * form; anything else replaces them — a live "pay" control under a charge that
25
+ * may already be holding the buyer's money is the double-payment invitation
26
+ * the card view already refuses to render.
27
+ */
28
+ export type WalletPhase = "idle" | "charging" | "polling";
29
+
30
+ /** Everything a wallet pane renders — state plus the one submit entry point. */
31
+ export interface WalletCharge {
32
+ phase: WalletPhase;
33
+ /** The refusal to show above the form, when the charge came back refused. */
34
+ error: string | null;
35
+ /** The refusal's machine code — an UNRESOLVED charge is not a decline. */
36
+ errorCode: string | null;
37
+ /** The charge is unresolved: no pay control may render (FUT-563). */
38
+ unresolved: boolean;
39
+ pollError: string | null;
40
+ /** The healthy-poll cap elapsed while still AWAITING (FUT-191). */
41
+ pollTimedOut: boolean;
42
+ /** Charge the wallet's key. The button calls this once the sheet resolves. */
43
+ payWithKey(type: CheckoutWalletType, key: string): Promise<void>;
44
+ }
45
+
46
+ /**
47
+ * Healthy-poll cap for the wallet AWAITING wait — the card path's own cap
48
+ * (FUT-191): 36 polls ≈ 90 s at the 2500 ms default interval.
49
+ */
50
+ const WALLET_AWAITING_POLL_CAP = 36;
51
+
52
+ /** The wallet charge state machine. See the module comment. */
53
+ export function useWalletCharge(
54
+ order: CheckoutOrder,
55
+ buyer: BuyerInfo,
56
+ onResolved: (status: OrderStatus) => void,
57
+ pollIntervalMs = 2500,
58
+ ): WalletCharge {
59
+ const [phase, setPhase] = useState<WalletPhase>("idle");
60
+ const [error, setError] = useState<string | null>(null);
61
+ const [errorCode, setErrorCode] = useState<string | null>(null);
62
+ const client = useCheckoutClientApi();
63
+ const navigate = useCheckoutNavigate();
64
+
65
+ const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
66
+ enabled: phase === "polling",
67
+ intervalMs: pollIntervalMs,
68
+ maxHealthyPolls: WALLET_AWAITING_POLL_CAP,
69
+ });
70
+
71
+ useEffect(() => {
72
+ if (status && status !== "AWAITING_PAYMENT") onResolved(status);
73
+ }, [status, onResolved]);
74
+
75
+ const payWithKey = async (type: CheckoutWalletType, key: string): Promise<void> => {
76
+ setError(null);
77
+ setErrorCode(null);
78
+ setPhase("charging");
79
+
80
+ const charged = await client.chargeWallet({
81
+ orderId: order.orderId,
82
+ wallet: { type, key },
83
+ // The CPF the Dados step collected — the provider's required-field gate
84
+ // reads it from the charge, the payable row has nowhere to keep it.
85
+ taxId: buyer.taxId,
86
+ });
87
+ if (!charged.ok) {
88
+ setError(charged.error);
89
+ setErrorCode(charged.code ?? null);
90
+ // Back to idle — but an UNRESOLVED code sets `unresolved`, and the pane
91
+ // reads that as "render NO pay control" (FUT-563): some provider may be
92
+ // holding the money, and a live button under "não pague de novo" is what
93
+ // the buyer's thumb reaches for.
94
+ setPhase("idle");
95
+ return;
96
+ }
97
+ // A provider that demands its own page to finish (redirect 3-D Secure,
98
+ // FUT-698): park the order and hand the buyer over, exactly as the card
99
+ // path does. `phase` stays as-is — the tab is navigating away.
100
+ if (charged.data.hostedCheckoutUrl) {
101
+ rememberHostedOrder(order);
102
+ navigate(charged.data.hostedCheckoutUrl);
103
+ return;
104
+ }
105
+ // A business outcome (declined → FAILED) shows the status screen; an
106
+ // accepted charge begins polling for the async confirmation.
107
+ if (charged.data.status !== "AWAITING_PAYMENT") {
108
+ onResolved(charged.data.status);
109
+ return;
110
+ }
111
+ setPhase("polling");
112
+ };
113
+
114
+ return {
115
+ phase,
116
+ error,
117
+ errorCode,
118
+ unresolved: errorCode === UNRESOLVED_CODE,
119
+ pollError,
120
+ pollTimedOut,
121
+ payWithKey,
122
+ };
123
+ }
@@ -0,0 +1,161 @@
1
+ import { Box, Divider } from "@mui/material";
2
+ import { useState, type JSX } from "react";
3
+
4
+ import { CardView } from "./card-view";
5
+ import { GooglePayButton } from "./google-pay-button";
6
+ import { cardChain, cardTokenization, googlePayConfig } from "./method-capability";
7
+ import type { ProviderCheckoutScreenProps } from "./providers/types";
8
+ import { useCheckoutComponents } from "./ui";
9
+ import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
10
+
11
+ /**
12
+ * The CARD pane with its wallet fast lane (FUT-471/472).
13
+ *
14
+ * A wallet is not a fourth method — it is another way of producing the CARD
15
+ * instrument — so it renders INSIDE the card pane, above the form, and only
16
+ * when the chain head both declared the wallet capability and published the
17
+ * parameters the browser needs (`googlePayConfig`, fail-closed). A store with
18
+ * no wallet renders exactly the card view it always did.
19
+ *
20
+ * One pane owns BOTH submit paths' visibility so they cannot invite a double
21
+ * payment: while a wallet charge is in flight or being confirmed, the card
22
+ * form and every wallet button are REPLACED by the processing state — the same
23
+ * rule `card-view.tsx` applies to its own pay bar, one level up.
24
+ */
25
+
26
+ /** Post-submit confirmation, error > timeout > spinner — the card view's order. */
27
+ function WalletProcessing({ wallet }: { wallet: WalletCharge }): JSX.Element {
28
+ const { Alert, LoadingState } = useCheckoutComponents();
29
+ if (wallet.pollError) {
30
+ return (
31
+ <Alert
32
+ variant="danger"
33
+ title="Não foi possível confirmar o pagamento"
34
+ description={wallet.pollError}
35
+ showIcon
36
+ data-testid="wallet-poll-error"
37
+ />
38
+ );
39
+ }
40
+ if (wallet.pollTimedOut) {
41
+ return (
42
+ <Alert
43
+ variant="warning"
44
+ title="O pagamento está demorando mais que o esperado"
45
+ description="Você pode aguardar ou verificar seu pedido em instantes — não realize um novo pagamento."
46
+ showIcon
47
+ data-testid="wallet-poll-timeout"
48
+ />
49
+ );
50
+ }
51
+ return (
52
+ <LoadingState
53
+ variant="spinner"
54
+ size="md"
55
+ message="Processando pagamento…"
56
+ dataTestId="wallet-processing"
57
+ />
58
+ );
59
+ }
60
+
61
+ /**
62
+ * An UNRESOLVED wallet charge (FUT-563): some provider may be holding the
63
+ * buyer's money, so the pane shows the warning and NO pay control of any kind
64
+ * — no wallet button, no card form. Same presentation rule as the card view's
65
+ * own unresolved state.
66
+ */
67
+ function WalletUnresolved({ message }: { message: string }): JSX.Element {
68
+ const { Alert } = useCheckoutComponents();
69
+ return (
70
+ <Alert
71
+ variant="warning"
72
+ title="Estamos confirmando seu pagamento"
73
+ description={message}
74
+ showIcon
75
+ data-testid="wallet-unresolved"
76
+ />
77
+ );
78
+ }
79
+
80
+ /**
81
+ * The wallet buttons the store's chain head supports, or null when there are
82
+ * none. Split out so the pane below stays under the function-size gate.
83
+ */
84
+ function WalletButtons({
85
+ props,
86
+ wallet,
87
+ onSheetError,
88
+ }: {
89
+ props: ProviderCheckoutScreenProps & { order: NonNullable<ProviderCheckoutScreenProps["order"]> };
90
+ wallet: WalletCharge;
91
+ onSheetError: (message: string) => void;
92
+ }): JSX.Element | null {
93
+ const { Text } = useCheckoutComponents();
94
+ const googlePay = googlePayConfig(props.config);
95
+ if (!googlePay) return null;
96
+ return (
97
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
98
+ <GooglePayButton
99
+ order={props.order}
100
+ params={googlePay}
101
+ onKey={(key) => void wallet.payWithKey("GOOGLE_PAY", key)}
102
+ onError={onSheetError}
103
+ />
104
+ <Divider>
105
+ <Text variant="caption" size="xs" color="secondary" as="span">
106
+ ou pague com cartão
107
+ </Text>
108
+ </Divider>
109
+ </Box>
110
+ );
111
+ }
112
+
113
+ /** The CARD pane: wallet fast lane above, the card form below. */
114
+ export function WalletCardPane(
115
+ props: ProviderCheckoutScreenProps & {
116
+ order: NonNullable<ProviderCheckoutScreenProps["order"]>;
117
+ },
118
+ ): JSX.Element {
119
+ const { Alert } = useCheckoutComponents();
120
+ const { order, buyer, config, tenantSlug, onResolved, pollIntervalMs } = props;
121
+ const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs);
122
+ // A sheet failure the wallet reported before any charge existed (pay.js
123
+ // refused, the sheet errored) — shown beside the form, which stays usable.
124
+ const [sheetError, setSheetError] = useState<string | null>(null);
125
+
126
+ if (wallet.phase !== "idle") return <WalletProcessing wallet={wallet} />;
127
+ if (wallet.unresolved) return <WalletUnresolved message={wallet.error ?? ""} />;
128
+
129
+ return (
130
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
131
+ <WalletButtons props={props} wallet={wallet} onSheetError={setSheetError} />
132
+ {wallet.error ? (
133
+ <Alert
134
+ variant="danger"
135
+ title="Não foi possível pagar"
136
+ description={wallet.error}
137
+ showIcon
138
+ data-testid="wallet-error"
139
+ />
140
+ ) : null}
141
+ {sheetError && !wallet.error ? (
142
+ <Alert
143
+ variant="danger"
144
+ title="Não foi possível pagar"
145
+ description={sheetError}
146
+ showIcon
147
+ data-testid="wallet-sheet-error"
148
+ />
149
+ ) : null}
150
+ <CardView
151
+ order={order}
152
+ buyer={buyer}
153
+ providerConfig={cardTokenization(config)}
154
+ providerChain={cardChain(config)}
155
+ tenantSlug={tenantSlug}
156
+ onResolved={onResolved}
157
+ pollIntervalMs={pollIntervalMs}
158
+ />
159
+ </Box>
160
+ );
161
+ }
package/src/index.ts CHANGED
@@ -84,6 +84,20 @@ export {
84
84
  export { type CheckoutHostPorts } from './components/checkout/use-checkout-controller';
85
85
  export { PaymentsUnavailable } from './components/checkout/payments-unavailable';
86
86
  export { fetchCheckoutConfig } from './components/checkout/client';
87
+ // ---------------------------------------------------------------------------
88
+ // Digital wallets (FUT-471/472) — the Google-branded button and the capability
89
+ // read it is gated on. `CheckoutFlow` wires these automatically; they are
90
+ // exported for hosts composing their own pixels.
91
+ // ---------------------------------------------------------------------------
92
+ export {
93
+ GooglePayButton,
94
+ type GooglePayApi,
95
+ type GooglePayButtonProps,
96
+ type GooglePaymentData,
97
+ type GooglePaymentsClient,
98
+ type GooglePayGatewayParams,
99
+ } from './components/checkout/google-pay-button';
100
+ export { googlePayConfig } from './components/checkout/method-capability';
87
101
  export {
88
102
  CheckoutComponentsProvider,
89
103
  type CheckoutActionBarProps,
@@ -103,11 +117,13 @@ export {
103
117
  type BuyerContact,
104
118
  type BuyerField,
105
119
  type BuyerInfo,
120
+ type ChargeWalletInput,
106
121
  type CheckoutChainLink,
107
122
  type CheckoutCustomerField,
108
123
  type CheckoutError,
109
124
  type CheckoutOrder,
110
125
  type CheckoutProviderConfig,
126
+ type CheckoutWalletType,
111
127
  type ComandaCheckout,
112
128
  type CreateOrderRequest,
113
129
  type CreateOrderResult,