@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 +4 -4
- package/src/components/ProviderConnection.tsx +54 -0
- package/src/components/checkout/apple-pay-button.tsx +189 -0
- package/src/components/checkout/checkout-flow.tsx +9 -1
- package/src/components/checkout/checkout-steps.tsx +7 -0
- package/src/components/checkout/client-context.tsx +6 -1
- package/src/components/checkout/google-pay-button.tsx +12 -2
- package/src/components/checkout/method-capability.ts +18 -0
- package/src/components/checkout/providers/types.ts +8 -0
- package/src/components/checkout/transport.ts +64 -4
- package/src/components/checkout/use-wallet-charge.ts +12 -6
- package/src/components/checkout/wallet-pane.tsx +50 -24
- package/src/components/platform/ConnectApplicationPanel.tsx +77 -0
- package/src/components/platform/ConnectEnvironmentCard.tsx +175 -0
- package/src/components/platform/HomologacaoGuideCard.tsx +86 -0
- package/src/components/platform/HomologacaoOutcomeCard.tsx +170 -0
- package/src/components/platform/PlatformHomologacao.tsx +107 -0
- package/src/flows/copy.ts +28 -4
- package/src/flows/create-payment-flows.tsx +5 -1
- package/src/flows/screens-vault.tsx +280 -0
- package/src/flows/types.ts +20 -1
- package/src/flows/use-add-card.ts +183 -0
- package/src/index.ts +35 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
32
|
-
"@12-apps/typescript-config": "^1.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
43
|
-
|
|
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<
|
|
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 {
|