@12-apps/payments-frontend 1.17.0 → 1.19.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.17.0",
3
+ "version": "1.19.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.17.0",
20
+ "@12-apps/payments-backend": "^1.22.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.19.0",
32
- "@12-apps/typescript-config": "^1.19.0",
31
+ "@12-apps/eslint-config": "^1.20.0",
32
+ "@12-apps/typescript-config": "^1.20.0",
33
33
  "@emotion/react": "^11.14.0",
34
34
  "@emotion/styled": "^11.14.0",
35
35
  "@mui/material": "^6.5.0",
@@ -16,6 +16,7 @@ import {
16
16
  import { useState } from 'react';
17
17
 
18
18
  import type {
19
+ ConnectedOAuthAccount,
19
20
  MaskedProviderConfig,
20
21
  PaymentEnvironment,
21
22
  ProviderDescriptor,
@@ -85,6 +86,54 @@ function connectLabel(displayName: string, connected: boolean, busy: string | nu
85
86
  return connected ? 'Reconectar' : `Conectar com ${displayName}`;
86
87
  }
87
88
 
89
+ /**
90
+ * pt-BR labels for the scopes a store owner actually sees. Unknown scopes fall
91
+ * back to the provider's own spelling — wrong words are worse than raw ones.
92
+ */
93
+ const SCOPE_LABELS: Record<string, string> = {
94
+ 'payments.read': 'Consultar pagamentos',
95
+ 'payments.create': 'Criar cobranças',
96
+ 'payments.refund': 'Estornar pagamentos',
97
+ 'accounts.read': 'Consultar dados da conta',
98
+ };
99
+
100
+ /**
101
+ * WHICH account is connected (FUT-300): identity, granted scopes and the
102
+ * connect date — the difference between "conectado" and "conectado como".
103
+ * Without it, the only way an owner could tell which PagBank account a store
104
+ * charges into was to disconnect and connect again.
105
+ */
106
+ function ConnectedAccountDetails(props: { account: ConnectedOAuthAccount }) {
107
+ const { account } = props;
108
+ const identity = account.accountLabel ?? account.accountId;
109
+ return (
110
+ <Stack spacing={0.5} data-testid="payments-connected-account">
111
+ {identity ? (
112
+ <Typography variant="body2">
113
+ Conta conectada: <strong>{identity}</strong>
114
+ </Typography>
115
+ ) : null}
116
+ {account.connectedAt ? (
117
+ <Typography variant="caption" color="text.secondary">
118
+ {`Conectada em ${new Date(account.connectedAt).toLocaleString('pt-BR')}`}
119
+ </Typography>
120
+ ) : null}
121
+ {account.grantedScopes.length > 0 ? (
122
+ <Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
123
+ {account.grantedScopes.map((scope) => (
124
+ <Chip
125
+ key={scope}
126
+ size="small"
127
+ variant="outlined"
128
+ label={SCOPE_LABELS[scope] ?? scope}
129
+ />
130
+ ))}
131
+ </Stack>
132
+ ) : null}
133
+ </Stack>
134
+ );
135
+ }
136
+
88
137
  /** Header + explanatory copy + any warning banner for the connection. */
89
138
  function ConnectionSummary(props: {
90
139
  displayName: string;
@@ -92,6 +141,7 @@ function ConnectionSummary(props: {
92
141
  connected: boolean;
93
142
  expiresAt: string | null;
94
143
  environment: PaymentEnvironment;
144
+ connectedAccount: ConnectedOAuthAccount | null;
95
145
  }) {
96
146
  return (
97
147
  <>
@@ -118,6 +168,9 @@ function ConnectionSummary(props: {
118
168
  ? 'Sua conta está conectada. O Future Pay cria as cobranças em seu nome — nenhuma chave precisa ser copiada.'
119
169
  : `Conecte sua conta ${props.displayName} autorizando o acesso no site do provedor. Nenhuma chave precisa ser copiada.`}
120
170
  </Typography>
171
+ {props.connected && props.connectedAccount ? (
172
+ <ConnectedAccountDetails account={props.connectedAccount} />
173
+ ) : null}
121
174
  {props.status === 'RECONNECT_REQUIRED' ? (
122
175
  <Alert severity="warning">
123
176
  A autorização expirou ou foi revogada. Reconecte para voltar a receber pagamentos.
@@ -243,6 +296,7 @@ export function ProviderConnection(props: ProviderConnectionProps) {
243
296
  connected={connected}
244
297
  expiresAt={config?.expiresAt ?? null}
245
298
  environment={environment}
299
+ connectedAccount={config?.connectedAccount ?? null}
246
300
  />
247
301
 
248
302
  {error ? <Alert severity="error">{error}</Alert> : null}
@@ -0,0 +1,189 @@
1
+ import { Box } from "@mui/material";
2
+ import { useRef, type JSX } from "react";
3
+
4
+ import type { CheckoutOrder } from "./types";
5
+
6
+ /**
7
+ * The Apple-owned pay button (FUT-472), on `ApplePaySession` — the API Safari
8
+ * ships natively; there is no script to load. This component owns token
9
+ * ACQUISITION: feature-detect, render Apple's button, run the session, and
10
+ * turn an authorized payment into the wallet key (Apple's `token.paymentData`,
11
+ * serialized verbatim). The charge itself belongs to the pane above
12
+ * (`wallet-pane.tsx`), which reports back so the sheet can be completed with
13
+ * the honest status.
14
+ *
15
+ * ## Visa and Mastercard ONLY — a money rule, not a default
16
+ *
17
+ * PagBank currently processes Apple Pay for Visa and Mastercard alone. The
18
+ * sheet's `supportedNetworks` is the one gate that keeps every other card
19
+ * from being OFFERED: a shopper whose wallet holds only an Elo card sees the
20
+ * sheet refuse selection instead of authorizing a payment PagBank then
21
+ * declines. Widening this list is a provider fact change, not a UI choice.
22
+ *
23
+ * ## The fallback is the card form
24
+ *
25
+ * A device without Apple Pay, a store that never declared the wallet, or a
26
+ * merchant validation that cannot run all degrade the same way: no button,
27
+ * and the card form the pane always renders stays. The buyer loses a
28
+ * shortcut, never the ability to pay.
29
+ */
30
+
31
+ /** PagBank processes Apple Pay for these networks ONLY. See module comment. */
32
+ export const APPLE_PAY_SUPPORTED_NETWORKS = ["visa", "masterCard"] as const;
33
+
34
+ /** ApplePaySession API version 3 — the floor for the fields this sheet uses. */
35
+ const APPLE_PAY_VERSION = 3;
36
+
37
+ /** The payment-request slice this checkout builds. */
38
+ export interface ApplePayPaymentRequest {
39
+ countryCode: string;
40
+ currencyCode: string;
41
+ supportedNetworks: readonly string[];
42
+ merchantCapabilities: readonly string[];
43
+ total: { label: string; amount: string };
44
+ }
45
+
46
+ /** The session events/methods this button drives. */
47
+ export interface ApplePaySessionLike {
48
+ onvalidatemerchant: ((event: { validationURL: string }) => void) | null;
49
+ onpaymentauthorized: ((event: { payment: { token: { paymentData: unknown } } }) => void) | null;
50
+ oncancel: (() => void) | null;
51
+ begin(): void;
52
+ abort(): void;
53
+ completeMerchantValidation(merchantSession: unknown): void;
54
+ completePayment(result: { status: number }): void;
55
+ }
56
+
57
+ /** The `window.ApplePaySession` constructor, as far as this button needs it. */
58
+ export interface ApplePaySessionClass {
59
+ new (version: number, request: ApplePayPaymentRequest): ApplePaySessionLike;
60
+ canMakePayments(): boolean;
61
+ STATUS_SUCCESS: number;
62
+ STATUS_FAILURE: number;
63
+ }
64
+
65
+ /** The native constructor, where Safari (or a harness) provides one. */
66
+ function applePaySessionClass(): ApplePaySessionClass | null {
67
+ if (typeof window === "undefined") return null;
68
+ const scope = window as unknown as { ApplePaySession?: ApplePaySessionClass };
69
+ return scope.ApplePaySession ?? null;
70
+ }
71
+
72
+ /**
73
+ * Whether THIS device can show the sheet at all — the synchronous half of the
74
+ * gate (the capability half is `applePayDeclared`). Exported so the pane can
75
+ * decide whether any wallet chrome (the divider) is worth rendering.
76
+ */
77
+ export function applePaySupported(): boolean {
78
+ const Session = applePaySessionClass();
79
+ if (!Session) return false;
80
+ try {
81
+ return Session.canMakePayments();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** The sheet's payment request, from the server-authoritative order total. */
88
+ function paymentRequest(order: CheckoutOrder): ApplePayPaymentRequest {
89
+ return {
90
+ countryCode: "BR",
91
+ currencyCode: "BRL",
92
+ supportedNetworks: APPLE_PAY_SUPPORTED_NETWORKS,
93
+ merchantCapabilities: ["supports3DS"],
94
+ total: { label: "Total do pedido", amount: (order.totalCents / 100).toFixed(2) },
95
+ };
96
+ }
97
+
98
+ export interface ApplePayButtonProps {
99
+ order: CheckoutOrder;
100
+ /**
101
+ * The payment authorized on the sheet — charge this key. Resolves `true`
102
+ * when the charge was accepted (paid, or confirming), `false` on a refusal,
103
+ * so the sheet can be completed with the honest status.
104
+ */
105
+ onAuthorized: (key: string) => Promise<boolean>;
106
+ /** A session failure worth telling the buyer about (not a dismissal). */
107
+ onError: (message: string) => void;
108
+ /**
109
+ * The host's merchant-validation port: exchange `validationURL` for an
110
+ * Apple merchant session, SERVER-SIDE (the merchant identity certificate
111
+ * must never reach a browser). Absent — the external prerequisites are not
112
+ * done, or the host has not wired it — the session aborts with a pt-BR
113
+ * message and the card form remains the way to pay.
114
+ */
115
+ validateMerchant?: (validationURL: string) => Promise<unknown>;
116
+ }
117
+
118
+ /** Wire one session run. Split from the component for the size gate. */
119
+ function runSession(
120
+ Session: ApplePaySessionClass,
121
+ order: CheckoutOrder,
122
+ handlers: ApplePayButtonProps,
123
+ ): void {
124
+ const session = new Session(APPLE_PAY_VERSION, paymentRequest(order));
125
+ session.onvalidatemerchant = (event) => {
126
+ const validate = handlers.validateMerchant;
127
+ if (!validate) {
128
+ session.abort();
129
+ handlers.onError("Não foi possível iniciar o Apple Pay nesta loja. Pague com cartão.");
130
+ return;
131
+ }
132
+ validate(event.validationURL)
133
+ .then((merchantSession) => session.completeMerchantValidation(merchantSession))
134
+ .catch(() => {
135
+ session.abort();
136
+ handlers.onError("Não foi possível iniciar o Apple Pay. Tente novamente ou pague com cartão.");
137
+ });
138
+ };
139
+ session.onpaymentauthorized = (event) => {
140
+ // Apple's `token.paymentData`, serialized VERBATIM — PagBank's `key`.
141
+ const key = JSON.stringify(event.payment.token.paymentData);
142
+ void handlers.onAuthorized(key).then((accepted) => {
143
+ session.completePayment({
144
+ status: accepted ? Session.STATUS_SUCCESS : Session.STATUS_FAILURE,
145
+ });
146
+ });
147
+ };
148
+ // Closing the sheet is a choice, not a failure to report.
149
+ session.oncancel = () => undefined;
150
+ session.begin();
151
+ }
152
+
153
+ /**
154
+ * Renders NOTHING unless this device can pay — the sheet's own
155
+ * `supportedNetworks` then keeps unsupported cards from being offered on a
156
+ * device that can. The pixels are Apple's (`-apple-pay-button` appearance, as
157
+ * their Human Interface Guidelines require); only Safari ever renders this,
158
+ * because only Safari passes the feature-detect.
159
+ */
160
+ export function ApplePayButton(props: ApplePayButtonProps): JSX.Element | null {
161
+ // The latest handlers/order, so a session opened from a click never closes
162
+ // over a stale charge target.
163
+ const current = useRef(props);
164
+ current.current = props;
165
+ const Session = applePaySessionClass();
166
+ if (!Session || !applePaySupported()) return null;
167
+ return (
168
+ <Box
169
+ component="button"
170
+ type="button"
171
+ aria-label="Pagar com Apple Pay"
172
+ data-testid="apple-pay-button"
173
+ onClick={() => runSession(Session, current.current.order, current.current)}
174
+ sx={{
175
+ WebkitAppearance: "-apple-pay-button",
176
+ // Apple draws the button; these only give it room. The fallback
177
+ // colors are unreachable in practice (only Safari gets here) but keep
178
+ // the element visible if the appearance ever fails to apply.
179
+ height: 40,
180
+ width: "100%",
181
+ border: 0,
182
+ borderRadius: 2,
183
+ cursor: "pointer",
184
+ bgcolor: "common.black",
185
+ color: "common.white",
186
+ }}
187
+ />
188
+ );
189
+ }
@@ -46,6 +46,13 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
46
46
  providerConfig?: CheckoutProviderConfig | null;
47
47
  /** Scopes the saved-card list to the store being paid. */
48
48
  tenantSlug?: string;
49
+ /**
50
+ * The host's Apple Pay merchant-validation port (FUT-472): exchange the
51
+ * session's `validationURL` for an Apple merchant session, SERVER-SIDE.
52
+ * Optional — without it the Apple Pay sheet cannot start, and the card form
53
+ * remains the way to pay.
54
+ */
55
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
49
56
  /** Host content shown on the paid confirmation (the storefront's install invite). */
50
57
  confirmationExtra?: ReactNode;
51
58
  /** Design-system slots; unfilled slots render the raw-MUI defaults. */
@@ -118,7 +125,7 @@ function ProgressHeader({ step, completed }: { step: string; completed: Set<stri
118
125
  * card public key are loaded lazily by the card path (order-scoped REST).
119
126
  */
120
127
  function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
121
- const { cart, defaultBuyer, comanda, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, ...ports } = props;
128
+ const { cart, defaultBuyer, comanda, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, ...ports } = props;
122
129
  // Resolved for NO method on purpose (FUT-595): the Dados step opens before
123
130
  // the picker, and the form is filled once — so it asks for the union of what
124
131
  // any chain member may need rather than re-opening after the choice. A chain
@@ -176,6 +183,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
176
183
  onEditBuyer={c.editBuyer}
177
184
  providerConfig={providerConfig}
178
185
  tenantSlug={tenantSlug}
186
+ validateApplePayMerchant={validateApplePayMerchant}
179
187
  onResolved={c.handleResolved}
180
188
  />
181
189
  ) : null}
@@ -71,6 +71,7 @@ function PaymentBody({
71
71
  tenantSlug,
72
72
  onResolved,
73
73
  pollIntervalMs,
74
+ validateApplePayMerchant,
74
75
  }: {
75
76
  order: CheckoutOrder | null;
76
77
  buyer: BuyerInfo;
@@ -79,6 +80,7 @@ function PaymentBody({
79
80
  tenantSlug?: string;
80
81
  onResolved: (status: OrderStatus) => void;
81
82
  pollIntervalMs?: number;
83
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
82
84
  }): JSX.Element | null {
83
85
  const Screen = resolveCheckoutScreen(providerConfig?.chain?.[0]?.checkoutScreen);
84
86
  return (
@@ -90,6 +92,7 @@ function PaymentBody({
90
92
  tenantSlug={tenantSlug}
91
93
  onResolved={onResolved}
92
94
  pollIntervalMs={pollIntervalMs}
95
+ validateApplePayMerchant={validateApplePayMerchant}
93
96
  />
94
97
  );
95
98
  }
@@ -291,6 +294,8 @@ interface PaymentStepProps {
291
294
  /** Scopes the saved-card list to the store being paid (host routing owns it). */
292
295
  tenantSlug?: string;
293
296
  pollIntervalMs?: number;
297
+ /** The host's Apple Pay merchant-validation port (FUT-472) — see the screen contract. */
298
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
294
299
  onResolved: (status: OrderStatus) => void;
295
300
  }
296
301
 
@@ -314,6 +319,7 @@ export function PaymentStep({
314
319
  providerConfig,
315
320
  tenantSlug,
316
321
  pollIntervalMs,
322
+ validateApplePayMerchant,
317
323
  onResolved,
318
324
  }: PaymentStepProps): JSX.Element {
319
325
  const { LoadingState } = useCheckoutComponents();
@@ -342,6 +348,7 @@ export function PaymentStep({
342
348
  tenantSlug={tenantSlug}
343
349
  onResolved={onResolved}
344
350
  pollIntervalMs={pollIntervalMs}
351
+ validateApplePayMerchant={validateApplePayMerchant}
345
352
  />
346
353
 
347
354
  {!order && creating ? (
@@ -26,7 +26,7 @@ import {
26
26
  pollOrderStatus,
27
27
  refreshCardPublicKey,
28
28
  } from "./client";
29
- import type { CheckoutClient } from "./transport";
29
+ import { createCheckoutClient, type CheckoutClient } from "./transport";
30
30
 
31
31
  /** The unbound client: `/api/checkout` on the ambient `fetch`. */
32
32
  const DEFAULT_CLIENT: CheckoutClient = {
@@ -35,6 +35,11 @@ const DEFAULT_CLIENT: CheckoutClient = {
35
35
  charge: (input) => chargeCard(input),
36
36
  chargeWallet: (input) => chargeWallet(input),
37
37
  listInstruments: (tenantSlug) => listSavedCards(tenantSlug),
38
+ // The vault pair (FUT-183) has no `client.ts` free function to bind — it is
39
+ // newer than that module. Built lazily from the default transport instead,
40
+ // which is the same wire: `/api/checkout`, ambient `fetch` resolved per call.
41
+ beginVault: () => createCheckoutClient().beginVault(),
42
+ completeVault: (input) => createCheckoutClient().completeVault(input),
38
43
  refreshBrowserKey: (input) => refreshCardPublicKey(input),
39
44
  };
40
45
 
@@ -196,6 +196,11 @@ export interface GooglePayButtonProps {
196
196
  environment?: "TEST" | "PRODUCTION";
197
197
  /** Injectable API namespace for tests/harnesses; omit to load pay.js. */
198
198
  api?: GooglePayApi | null;
199
+ /**
200
+ * Fired once `isReadyToPay` approved and the button will render — how the
201
+ * pane knows its wallet chrome (the divider) has something to sit above.
202
+ */
203
+ onReady?: () => void;
199
204
  }
200
205
 
201
206
  /**
@@ -211,13 +216,18 @@ export function GooglePayButton({
211
216
  onError,
212
217
  environment = "TEST",
213
218
  api,
219
+ onReady,
214
220
  }: GooglePayButtonProps): JSX.Element | null {
215
221
  const client = useGooglePayClient(api, environment);
216
222
  const container = useRef<HTMLDivElement | null>(null);
217
223
  // The latest handlers/order, so the Google-rendered button — mounted once —
218
224
  // never closes over a stale charge target.
219
- const current = useRef({ order, params, onKey, onError });
220
- current.current = { order, params, onKey, onError };
225
+ const current = useRef({ order, params, onKey, onError, onReady });
226
+ current.current = { order, params, onKey, onError, onReady };
227
+
228
+ useEffect(() => {
229
+ if (client) current.current.onReady?.();
230
+ }, [client]);
221
231
 
222
232
  useEffect(() => {
223
233
  const mount = container.current;
@@ -167,6 +167,24 @@ export function googlePayConfig(
167
167
  return { gateway: params.gateway, gatewayMerchantId: params.gatewayMerchantId };
168
168
  }
169
169
 
170
+ /**
171
+ * Whether the chain head declares Apple Pay (FUT-472) — the capability half
172
+ * of the button's gate; the DEVICE half (`ApplePaySession` exists and can
173
+ * pay) is `applePaySupported()` in `apple-pay-button.tsx`, because it is a
174
+ * browser fact, not a store fact.
175
+ *
176
+ * FAILS CLOSED like {@link googlePayConfig}, and read off the HEAD for the
177
+ * same reason: the Apple token is decrypted with the head merchant's
178
+ * certificate, so only the provider the walk tries first can charge it.
179
+ * Unlike Google there are no client parameters to publish — merchant
180
+ * validation runs server-side through the host's port.
181
+ */
182
+ export function applePayDeclared(config: CheckoutProviderConfig | null): boolean {
183
+ const head = config?.chain?.[0];
184
+ if (!head?.methods.includes("CARD")) return false;
185
+ return head.wallets?.includes("APPLE_PAY") ?? false;
186
+ }
187
+
170
188
  /**
171
189
  * The methods the picker may offer, from the chain's declared capabilities
172
190
  * (FUT-698). `null` config — still loading, or a fetch blip — fails OPEN like
@@ -52,6 +52,14 @@ export interface ProviderCheckoutScreenProps {
52
52
  tenantSlug?: string;
53
53
  /** The shell's polling cadence, passed through so tests can shorten it. */
54
54
  pollIntervalMs?: number;
55
+ /**
56
+ * The host's Apple Pay merchant-validation port (FUT-472): exchange the
57
+ * session's `validationURL` for an Apple merchant session, SERVER-SIDE —
58
+ * the merchant identity certificate must never reach a browser. Optional;
59
+ * absent, the Apple Pay sheet cannot start and the pane says so while the
60
+ * card form stays the way to pay.
61
+ */
62
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
55
63
  /** A terminal status — the shell moves to Confirmação. */
56
64
  onResolved: (status: OrderStatus) => void;
57
65
  }
@@ -2,7 +2,7 @@
2
2
  * The checkout's HTTP transport, as a bound client (FUT-741).
3
3
  *
4
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
5
+ * `fetch` now lives here behind {@link createCheckoutClient}, so the same
6
6
  * calls can be pointed at a different mount, carry a host's auth headers, or —
7
7
  * the reason this exists — be driven through an injected `fetch` that routes
8
8
  * straight into a real `createPaymentFlowsBE` mount. A story or a harness page
@@ -15,6 +15,8 @@
15
15
  * shipped contract in the same release that introduces the factory.
16
16
  */
17
17
 
18
+ import type { BuyerVaultSession, VaultedCardDisplay } from "@12-apps/payments-backend";
19
+
18
20
  import type { SavedCard } from "../../card";
19
21
  import { err, ok, type Result } from "../../result";
20
22
 
@@ -26,6 +28,28 @@ import type {
26
28
  OrderStatus,
27
29
  } from "./types";
28
30
 
31
+ /**
32
+ * The two buyer-vault answer shapes (FUT-478/FUT-183), imported as TYPES from
33
+ * the backend package rather than mirrored: `/cards/begin` and `/cards/complete`
34
+ * are new rows with no older-host degrade story to encode, so a mirror here
35
+ * would only be a copy that can drift from the wire it names. Re-exported for
36
+ * the same reason `PaymentEnvironment` is on the barrel — a host typing its
37
+ * own callback must not need a direct backend dependency.
38
+ */
39
+ export type { BuyerVaultSession, VaultedCardDisplay };
40
+
41
+ /**
42
+ * The browser's two legitimate contributions to `POST /cards/complete`: the
43
+ * session it confirmed, and — for a sessionless PUBLIC_KEY provider — the
44
+ * encrypted card blob. The ownership facts (`reference`, `customerRef`) are
45
+ * answered server-side by the host's vault port and are NOT here on purpose:
46
+ * a body naming them is ignored by the mount.
47
+ */
48
+ export interface CompleteVaultInput {
49
+ sessionId?: string;
50
+ token?: string;
51
+ }
52
+
29
53
  /**
30
54
  * The prefix every shipped buyer checkout posts to today. Exported so a host
31
55
  * (or a test) can state it rather than re-type it, and so a change to it is a
@@ -35,7 +59,10 @@ export const DEFAULT_CHECKOUT_BASE_URL = "/api/checkout";
35
59
 
36
60
  /** Where the `createPaymentFlowsBE` mount lives, and how to reach it. */
37
61
  export interface CheckoutTransport {
38
- /** Prefix for `/config`, `/status`, `/charge`, `/cards`, `/refresh-key`. */
62
+ /**
63
+ * Prefix for `/config`, `/status`, `/charge`, `/cards`, `/cards/begin`,
64
+ * `/cards/complete`, `/refresh-key`.
65
+ */
39
66
  baseUrl?: string;
40
67
  /**
41
68
  * The `fetch` to call. Omitted ⇒ the ambient one, resolved PER CALL so a
@@ -46,7 +73,7 @@ export interface CheckoutTransport {
46
73
  headers?: () => HeadersInit | Promise<HeadersInit>;
47
74
  }
48
75
 
49
- /** The six calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
76
+ /** The eight calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
50
77
  export interface CheckoutClient {
51
78
  getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
52
79
  getStatus(ref: string): Promise<Result<OrderStatus>>;
@@ -54,6 +81,18 @@ export interface CheckoutClient {
54
81
  /** A wallet instrument against the same `/charge` route (FUT-471/472). */
55
82
  chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>>;
56
83
  listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
84
+ /**
85
+ * `POST /cards/begin` (FUT-478): equip the browser to mint an instrument
86
+ * OUTSIDE a purchase. The answer names the tokenization scheme, the public
87
+ * key when the provider has one, and the session to echo to `completeVault`.
88
+ */
89
+ beginVault(): Promise<Result<BuyerVaultSession>>;
90
+ /**
91
+ * `POST /cards/complete`: the provider accepted the card — the server stores
92
+ * the vault token against the caller and answers DISPLAY metadata only. The
93
+ * token that can charge never reaches the browser.
94
+ */
95
+ completeVault(input: CompleteVaultInput): Promise<Result<VaultedCardDisplay>>;
57
96
  refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
58
97
  }
59
98
 
@@ -146,7 +185,20 @@ function flatWalletBody(input: ChargeWalletInput): string {
146
185
  }
147
186
 
148
187
  /**
149
- * The five checkout calls, bound to one transport.
188
+ * The wire body of `POST /cards/complete` ONLY the browser's two facts, and
189
+ * each present only when it exists. `flows-vault.ts` reads exactly these two
190
+ * string fields (`browserVaultFacts`) and ignores everything else, so a field
191
+ * added here without a backend reader would be silently dropped.
192
+ */
193
+ function completeVaultBody(input: CompleteVaultInput): string {
194
+ return JSON.stringify({
195
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
196
+ ...(input.token ? { token: input.token } : {}),
197
+ });
198
+ }
199
+
200
+ /**
201
+ * The checkout calls, bound to one transport.
150
202
  *
151
203
  * Passing no transport reproduces exactly what the free functions in
152
204
  * `client.ts` have always done: `/api/checkout/**` on the ambient `fetch`.
@@ -203,6 +255,14 @@ export function createCheckoutClient(transport: CheckoutTransport = {}): Checkou
203
255
  return result.ok ? result.data : [];
204
256
  },
205
257
 
258
+ beginVault: () => call<BuyerVaultSession>("/cards/begin", { method: "POST" }),
259
+
260
+ completeVault: (input) =>
261
+ call<VaultedCardDisplay>("/cards/complete", {
262
+ method: "POST",
263
+ body: completeVaultBody(input),
264
+ }),
265
+
206
266
  refreshBrowserKey: (input) =>
207
267
  call<{ publicKey: string | null }>("/refresh-key", {
208
268
  method: "POST",
@@ -39,8 +39,13 @@ export interface WalletCharge {
39
39
  pollError: string | null;
40
40
  /** The healthy-poll cap elapsed while still AWAITING (FUT-191). */
41
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>;
42
+ /**
43
+ * Charge the wallet's key. The button calls this once the sheet resolves.
44
+ * Resolves `true` when the charge was ACCEPTED — paid, confirming, or
45
+ * handed to the provider's page — and `false` on a refusal or decline, so a
46
+ * sheet that must be completed with a status (Apple's) can be honest.
47
+ */
48
+ payWithKey(type: CheckoutWalletType, key: string): Promise<boolean>;
44
49
  }
45
50
 
46
51
  /**
@@ -72,7 +77,7 @@ export function useWalletCharge(
72
77
  if (status && status !== "AWAITING_PAYMENT") onResolved(status);
73
78
  }, [status, onResolved]);
74
79
 
75
- const payWithKey = async (type: CheckoutWalletType, key: string): Promise<void> => {
80
+ const payWithKey = async (type: CheckoutWalletType, key: string): Promise<boolean> => {
76
81
  setError(null);
77
82
  setErrorCode(null);
78
83
  setPhase("charging");
@@ -92,7 +97,7 @@ export function useWalletCharge(
92
97
  // holding the money, and a live button under "não pague de novo" is what
93
98
  // the buyer's thumb reaches for.
94
99
  setPhase("idle");
95
- return;
100
+ return false;
96
101
  }
97
102
  // A provider that demands its own page to finish (redirect 3-D Secure,
98
103
  // FUT-698): park the order and hand the buyer over, exactly as the card
@@ -100,15 +105,16 @@ export function useWalletCharge(
100
105
  if (charged.data.hostedCheckoutUrl) {
101
106
  rememberHostedOrder(order);
102
107
  navigate(charged.data.hostedCheckoutUrl);
103
- return;
108
+ return true;
104
109
  }
105
110
  // A business outcome (declined → FAILED) shows the status screen; an
106
111
  // accepted charge begins polling for the async confirmation.
107
112
  if (charged.data.status !== "AWAITING_PAYMENT") {
108
113
  onResolved(charged.data.status);
109
- return;
114
+ return charged.data.status === "PAID";
110
115
  }
111
116
  setPhase("polling");
117
+ return true;
112
118
  };
113
119
 
114
120
  return {