@12-apps/payments-frontend 3.45.0 → 3.46.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 +2 -2
- package/src/card/pagbank-test-cards.ts +17 -1
- package/src/card/three-ds.ts +166 -0
- package/src/card/tokenize.ts +6 -1
- package/src/components/checkout/client-context.tsx +1 -0
- package/src/components/checkout/debit-charge.ts +60 -0
- package/src/components/checkout/debit-types.ts +31 -0
- package/src/components/checkout/list/charge-runners.tsx +5 -0
- package/src/components/checkout/list/choice-list.tsx +17 -8
- package/src/components/checkout/list/choice.ts +9 -0
- package/src/components/checkout/list/debit-copy.ts +32 -0
- package/src/components/checkout/list/en-US.ts +8 -0
- package/src/components/checkout/list/list-face.tsx +11 -2
- package/src/components/checkout/list/online-content.tsx +28 -12
- package/src/components/checkout/list/payment-list-step.tsx +13 -1
- package/src/components/checkout/list/pt-BR.ts +8 -0
- package/src/components/checkout/list/types.ts +11 -1
- package/src/components/checkout/list/use-capability.ts +16 -4
- package/src/components/checkout/list/use-list-state.ts +26 -7
- package/src/components/checkout/method-capability.ts +4 -2
- package/src/components/checkout/transport.ts +16 -5
- package/src/components/checkout/types.ts +4 -4
- package/src/components/checkout/use-card-checkout.ts +34 -13
- package/src/components/checkout/wallet-pane.tsx +1 -0
- package/src/components/platform/HomologacaoEvidenceCard.tsx +82 -27
- package/src/components/platform/en-US.ts +5 -5
- package/src/components/platform/pt-BR.ts +5 -5
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.46.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"storybook:build": "storybook build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@12-apps/payments-backend": "^4.
|
|
26
|
+
"@12-apps/payments-backend": "^4.37.0",
|
|
27
27
|
"react-qr-code": "^2.2.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* encrypts them and only the encrypted blob leaves it.
|
|
8
8
|
*/
|
|
9
9
|
interface PagBankTestCard {
|
|
10
|
-
scenario: "approved" | "declined";
|
|
10
|
+
scenario: "approved" | "declined" | "debit";
|
|
11
11
|
brand: string;
|
|
12
12
|
number: string;
|
|
13
13
|
holder: string;
|
|
@@ -44,6 +44,22 @@ export const PAGBANK_TEST_CARDS: readonly PagBankTestCard[] = [
|
|
|
44
44
|
},
|
|
45
45
|
];
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* PagBank's 3-D Secure sandbox test card (FUT-3755), charged as DEBIT. The
|
|
49
|
+
* sandbox answers it by AMOUNT: authenticated for R$ 27,01
|
|
50
|
+
* (`PAGBANK_SANDBOX_DEBIT_CENTS`) it is AUTHENTICATED with no challenge and
|
|
51
|
+
* then PAID, so the evidence run needs no one to type a code.
|
|
52
|
+
*/
|
|
53
|
+
export const PAGBANK_3DS_TEST_CARD: PagBankTestCard = {
|
|
54
|
+
scenario: "debit",
|
|
55
|
+
brand: "VISA",
|
|
56
|
+
number: "4000000000002701",
|
|
57
|
+
holder: "Jose da Silva",
|
|
58
|
+
expMonth: "12",
|
|
59
|
+
expYear: TEST_EXPIRY_YEAR,
|
|
60
|
+
securityCode: "123",
|
|
61
|
+
};
|
|
62
|
+
|
|
47
63
|
/** `4539 62** **** 2097` — the first six and last four, as a receipt prints them. */
|
|
48
64
|
export function maskTestCard(number: string): string {
|
|
49
65
|
return `${number.slice(0, 4)} ${number.slice(4, 6)}** **** ${number.slice(-4)}`;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ensurePagBankSdk } from "./tokenize";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PagBank's 3-D Secure, in the buyer's browser (FUT-3754) — the step a DEBIT
|
|
5
|
+
* card charge cannot skip.
|
|
6
|
+
*
|
|
7
|
+
* Per "Criar e pagar com autenticação 3DS do PagBank": the SDK is pointed at a
|
|
8
|
+
* session the SERVER minted with the seller's token (`setUp`), then
|
|
9
|
+
* `authenticate3DS` runs the issuer's check — silently, or with a challenge the
|
|
10
|
+
* SDK draws itself — and answers an `id` that the debit charge carries as
|
|
11
|
+
* `authentication_method: { type: "THREEDS", id }`.
|
|
12
|
+
*
|
|
13
|
+
* What the statuses mean for a DEBIT card, and therefore what this answers:
|
|
14
|
+
* AUTH_FLOW_COMPLETED the id may be charged (authenticated or not — the
|
|
15
|
+
* issuer decides, PagBank still processes it)
|
|
16
|
+
* AUTH_NOT_SUPPORTED "para débito, a transação deve ser finalizada": the
|
|
17
|
+
* card cannot be charged as debit here
|
|
18
|
+
* CHANGE_PAYMENT_METHOD PagBank refused the authentication outright
|
|
19
|
+
* `REQUIRE_CHALLENGE` is intermediate — the SDK opens the challenge itself when
|
|
20
|
+
* no `beforeChallenge` is given — so it never reaches the caller.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The SDK's request, field for field. Only the shape this package sends. */
|
|
24
|
+
export interface PagBankThreeDSRequest {
|
|
25
|
+
data: {
|
|
26
|
+
customer: {
|
|
27
|
+
name: string;
|
|
28
|
+
email: string;
|
|
29
|
+
phones: { country: string; area: string; number: string; type: "MOBILE" }[];
|
|
30
|
+
};
|
|
31
|
+
paymentMethod: {
|
|
32
|
+
type: "DEBIT_CARD" | "CREDIT_CARD";
|
|
33
|
+
installments: number;
|
|
34
|
+
card: { encrypted: string };
|
|
35
|
+
};
|
|
36
|
+
amount: { value: number; currency: "BRL" };
|
|
37
|
+
billingAddress: PagBankThreeDSAddress;
|
|
38
|
+
dataOnly: false;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PagBankThreeDSAddress {
|
|
43
|
+
street: string;
|
|
44
|
+
number: string;
|
|
45
|
+
complement?: string;
|
|
46
|
+
regionCode: string;
|
|
47
|
+
country: "BRA";
|
|
48
|
+
city: string;
|
|
49
|
+
postalCode: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PagBankThreeDSResult {
|
|
53
|
+
status: "AUTH_FLOW_COMPLETED" | "AUTH_NOT_SUPPORTED" | "CHANGE_PAYMENT_METHOD" | "REQUIRE_CHALLENGE";
|
|
54
|
+
authenticationStatus?: "AUTHENTICATED" | "NOT_AUTHENTICATED";
|
|
55
|
+
id?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Everything a debit authentication needs, in this package's own words. */
|
|
59
|
+
export interface ThreeDSInput {
|
|
60
|
+
/** From the server's `3ds-session` answer. */
|
|
61
|
+
session: string;
|
|
62
|
+
environment: "SANDBOX" | "PRODUCTION";
|
|
63
|
+
/** The card, already encrypted with the seller's public key. */
|
|
64
|
+
encryptedCard: string;
|
|
65
|
+
amountCents: number;
|
|
66
|
+
customer: { name: string; email: string; phone: string };
|
|
67
|
+
billingAddress: Omit<PagBankThreeDSAddress, "country">;
|
|
68
|
+
/**
|
|
69
|
+
* Give up after this long, answering `ERROR`. Only for a run nobody types
|
|
70
|
+
* into (the homologation's no-challenge test card): a buyer's challenge may
|
|
71
|
+
* honestly take minutes, so the checkout passes none.
|
|
72
|
+
*/
|
|
73
|
+
timeoutMs?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Why a debit card could not be authenticated — a CODE; the caller words it. */
|
|
77
|
+
export type ThreeDSFailure =
|
|
78
|
+
| "SDK_UNAVAILABLE"
|
|
79
|
+
| "NOT_SUPPORTED"
|
|
80
|
+
| "REFUSED"
|
|
81
|
+
| "INVALID_DATA"
|
|
82
|
+
| "ERROR";
|
|
83
|
+
|
|
84
|
+
type ThreeDSOutcome =
|
|
85
|
+
| { ok: true; authenticationId: string; authenticated: boolean }
|
|
86
|
+
| { ok: false; failure: ThreeDSFailure };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A Brazilian phone as the SDK's `{ country, area, number }`; null when it
|
|
90
|
+
* cannot be split. PagBank requires at least one MOBILE phone on the request.
|
|
91
|
+
*/
|
|
92
|
+
function mobileOf(phone: string): PagBankThreeDSRequest["data"]["customer"]["phones"][number] | null {
|
|
93
|
+
const digits = phone.replace(/\D/g, "");
|
|
94
|
+
const local = digits.length >= 12 && digits.startsWith("55") ? digits.slice(2) : digits;
|
|
95
|
+
if (local.length < 10 || local.length > 11) return null;
|
|
96
|
+
return { country: "55", area: local.slice(0, 2), number: local.slice(2), type: "MOBILE" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The SDK's request, or null when the buyer's data cannot fill it. */
|
|
100
|
+
export function threeDSRequestOf(input: ThreeDSInput): PagBankThreeDSRequest | null {
|
|
101
|
+
const mobile = mobileOf(input.customer.phone);
|
|
102
|
+
if (!mobile) return null;
|
|
103
|
+
return {
|
|
104
|
+
data: {
|
|
105
|
+
customer: { name: input.customer.name.trim(), email: input.customer.email.trim(), phones: [mobile] },
|
|
106
|
+
paymentMethod: { type: "DEBIT_CARD", installments: 1, card: { encrypted: input.encryptedCard } },
|
|
107
|
+
amount: { value: input.amountCents, currency: "BRL" },
|
|
108
|
+
billingAddress: {
|
|
109
|
+
...input.billingAddress,
|
|
110
|
+
postalCode: input.billingAddress.postalCode.replace(/\D/g, ""),
|
|
111
|
+
country: "BRA",
|
|
112
|
+
},
|
|
113
|
+
dataOnly: false,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What the SDK answered, as this package's outcome. */
|
|
119
|
+
export function threeDSOutcomeOf(result: PagBankThreeDSResult): ThreeDSOutcome {
|
|
120
|
+
if (result.status === "AUTH_FLOW_COMPLETED" && result.id) {
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
authenticationId: result.id,
|
|
124
|
+
authenticated: result.authenticationStatus === "AUTHENTICATED",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (result.status === "AUTH_NOT_SUPPORTED") return { ok: false, failure: "NOT_SUPPORTED" };
|
|
128
|
+
if (result.status === "CHANGE_PAYMENT_METHOD") return { ok: false, failure: "REFUSED" };
|
|
129
|
+
return { ok: false, failure: "ERROR" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The SDK's answer, or a rejection once `ms` have passed (never, without `ms`). */
|
|
133
|
+
function withTimeout<T>(pending: Promise<T>, ms: number | undefined): Promise<T> {
|
|
134
|
+
if (ms === undefined) return pending;
|
|
135
|
+
return new Promise<T>((resolve, reject) => {
|
|
136
|
+
const timer = setTimeout(() => reject(new Error("3-D Secure timed out")), ms);
|
|
137
|
+
pending.then(
|
|
138
|
+
(value) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
resolve(value);
|
|
141
|
+
},
|
|
142
|
+
(error: unknown) => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Authenticate a debit card with PagBank's SDK. Never throws. */
|
|
151
|
+
export async function authenticatePagBankDebit(input: ThreeDSInput): Promise<ThreeDSOutcome> {
|
|
152
|
+
const request = threeDSRequestOf(input);
|
|
153
|
+
if (!request) return { ok: false, failure: "INVALID_DATA" };
|
|
154
|
+
if (!(await ensurePagBankSdk())) return { ok: false, failure: "SDK_UNAVAILABLE" };
|
|
155
|
+
const sdk = typeof window === "undefined" ? undefined : window.PagSeguro;
|
|
156
|
+
if (!sdk?.setUp || !sdk.authenticate3DS) return { ok: false, failure: "SDK_UNAVAILABLE" };
|
|
157
|
+
try {
|
|
158
|
+
sdk.setUp({ session: input.session, env: input.environment === "PRODUCTION" ? "PROD" : "SANDBOX" });
|
|
159
|
+
return threeDSOutcomeOf(await withTimeout(sdk.authenticate3DS(request), input.timeoutMs));
|
|
160
|
+
} catch {
|
|
161
|
+
// The SDK rejects with `{ detail: { httpStatus, traceId, errorMessages } }`
|
|
162
|
+
// — a bad field or an expired session. Neither can be fixed by the buyer
|
|
163
|
+
// retrying the same card in the same second.
|
|
164
|
+
return { ok: false, failure: "ERROR" };
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/card/tokenize.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { detectBrand, onlyDigits } from "./format";
|
|
|
27
27
|
import { tokenizeWithStripe } from "./stripe-token";
|
|
28
28
|
import type { CardDetails, CardToken } from "./types";
|
|
29
29
|
import type { CardCopy } from "./copy";
|
|
30
|
+
import type { PagBankThreeDSRequest, PagBankThreeDSResult } from "./three-ds";
|
|
30
31
|
|
|
31
32
|
/** Test PAN that always declines (mirrors common sandbox decline cards). */
|
|
32
33
|
const DECLINE_PAN = "4000000000000002";
|
|
@@ -56,6 +57,10 @@ declare global {
|
|
|
56
57
|
expYear: string;
|
|
57
58
|
securityCode: string;
|
|
58
59
|
}): PagBankEncryptResult;
|
|
60
|
+
/** 3-D Secure (FUT-3754): point the SDK at a server-minted session. */
|
|
61
|
+
setUp?(params: { session: string; env: "SANDBOX" | "PROD" }): void;
|
|
62
|
+
/** 3-D Secure (FUT-3754): see `three-ds.ts` for the request and the answer. */
|
|
63
|
+
authenticate3DS?(request: PagBankThreeDSRequest): Promise<PagBankThreeDSResult>;
|
|
59
64
|
};
|
|
60
65
|
}
|
|
61
66
|
}
|
|
@@ -66,7 +71,7 @@ declare global {
|
|
|
66
71
|
* surfaces a friendly retry message.
|
|
67
72
|
*/
|
|
68
73
|
let sdkLoad: Promise<boolean> | null = null;
|
|
69
|
-
function ensurePagBankSdk(): Promise<boolean> {
|
|
74
|
+
export function ensurePagBankSdk(): Promise<boolean> {
|
|
70
75
|
if (typeof window === "undefined") return Promise.resolve(false);
|
|
71
76
|
if (window.PagSeguro?.encryptCard) return Promise.resolve(true);
|
|
72
77
|
sdkLoad ??= new Promise<boolean>((resolve) => {
|
|
@@ -53,6 +53,7 @@ function unboundClient(copy: CheckoutTransportCopy): CheckoutClient {
|
|
|
53
53
|
beginVault: () => createCheckoutClient({ copy }).beginVault(),
|
|
54
54
|
completeVault: (input) => createCheckoutClient({ copy }).completeVault(input),
|
|
55
55
|
refreshBrowserKey: (input) => refreshCardPublicKey(input, copy),
|
|
56
|
+
threeDSecureSession: (input) => createCheckoutClient({ copy }).threeDSecureSession!(input),
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { authenticatePagBankDebit, type ThreeDSFailure } from "../../card/three-ds";
|
|
2
|
+
import { err, ok, type Result } from "../../result";
|
|
3
|
+
|
|
4
|
+
import type { CheckoutBillingAddress, PaymentListDebitCopy } from "./list/types";
|
|
5
|
+
import type { CheckoutClient } from "./transport";
|
|
6
|
+
import type { BuyerInfo, CheckoutOrder } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A DEBIT card's 3-D Secure, between the card's encryption and its charge
|
|
10
|
+
* (FUT-3754).
|
|
11
|
+
*
|
|
12
|
+
* The session is minted by the server for the order's own store (`/3ds-session`
|
|
13
|
+
* is payable-scoped), the provider's SDK authenticates the encrypted card in
|
|
14
|
+
* this browser — drawing the bank's challenge itself when the bank asks for
|
|
15
|
+
* one — and the id it answers is what the debit charge carries. Nothing is
|
|
16
|
+
* charged when the authentication fails: the buyer reads why and stays on the
|
|
17
|
+
* list with the card they typed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** What the debit row hands the card charge: the address the bank checks, and the row's words. */
|
|
21
|
+
export interface DebitAuthentication {
|
|
22
|
+
billingAddress: CheckoutBillingAddress;
|
|
23
|
+
copy: PaymentListDebitCopy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The session a stub connection answers — authenticating it needs no SDK and no bank. */
|
|
27
|
+
const STUB_SESSION = "stub-3ds-session";
|
|
28
|
+
|
|
29
|
+
function failureWords(copy: PaymentListDebitCopy, failure: ThreeDSFailure): string {
|
|
30
|
+
if (failure === "NOT_SUPPORTED") return copy.notSupported;
|
|
31
|
+
if (failure === "REFUSED") return copy.refused;
|
|
32
|
+
return copy.failed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The authentication id to charge the debit card with, or the buyer's reason why not. */
|
|
36
|
+
export async function authenticateDebit(input: {
|
|
37
|
+
client: Pick<CheckoutClient, "threeDSecureSession">;
|
|
38
|
+
order: CheckoutOrder;
|
|
39
|
+
buyer: BuyerInfo;
|
|
40
|
+
encryptedCard: string;
|
|
41
|
+
debit: DebitAuthentication;
|
|
42
|
+
}): Promise<Result<string>> {
|
|
43
|
+
const { copy } = input.debit;
|
|
44
|
+
const minted = await input.client.threeDSecureSession?.({ orderId: input.order.orderId });
|
|
45
|
+
if (!minted?.ok) return err(copy.failed);
|
|
46
|
+
if (minted.data.session === STUB_SESSION) return ok("stub-3ds");
|
|
47
|
+
const outcome = await authenticatePagBankDebit({
|
|
48
|
+
session: minted.data.session,
|
|
49
|
+
environment: minted.data.environment,
|
|
50
|
+
encryptedCard: input.encryptedCard,
|
|
51
|
+
amountCents: minted.data.amountCents,
|
|
52
|
+
customer: {
|
|
53
|
+
name: input.buyer.name ?? "",
|
|
54
|
+
email: input.buyer.email ?? "",
|
|
55
|
+
phone: input.buyer.phone ?? "",
|
|
56
|
+
},
|
|
57
|
+
billingAddress: input.debit.billingAddress,
|
|
58
|
+
});
|
|
59
|
+
return outcome.ok ? ok(outcome.authenticationId) : err(failureWords(copy, outcome.failure));
|
|
60
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The debit card's wire and config shapes (FUT-3754) — their own module for
|
|
3
|
+
* the size gate on `types.ts`, which extends them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A chain entry's debit claim: absent (an older host) means no debit, as `wallets`. */
|
|
7
|
+
export interface DebitDeclaration {
|
|
8
|
+
/** THIS entry charges a card as DEBIT, 3-D Secure authenticated. */
|
|
9
|
+
debit?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** What makes a card charge a debit one. Absent means credit, as every charge before it. */
|
|
13
|
+
export interface DebitChargeFields {
|
|
14
|
+
funding?: "DEBIT";
|
|
15
|
+
/** The 3-D Secure authentication the buyer completed for this charge. */
|
|
16
|
+
authenticationId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `POST /3ds-session`'s answer: what the provider's 3-D Secure SDK runs on. */
|
|
20
|
+
export interface ThreeDSecureSessionView {
|
|
21
|
+
provider: string;
|
|
22
|
+
session: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
environment: "SANDBOX" | "PRODUCTION";
|
|
25
|
+
/**
|
|
26
|
+
* What the charge will take, in cents — the figure the bank authenticates.
|
|
27
|
+
* The server's, never the browser's: an authentication for any other amount
|
|
28
|
+
* is refused or, worse, approves a debit the buyer never confirmed.
|
|
29
|
+
*/
|
|
30
|
+
amountCents: number;
|
|
31
|
+
}
|
|
@@ -19,6 +19,7 @@ import type { ChallengeScope } from "../card-outcome";
|
|
|
19
19
|
import { useCheckoutCopy } from "../copy-context";
|
|
20
20
|
import type { BuyerInfo, CheckoutOrder, CheckoutWalletType, OnCheckoutResolved } from "../types";
|
|
21
21
|
import { useCardSubmit } from "../use-card-checkout";
|
|
22
|
+
import type { DebitAuthentication } from "../debit-charge";
|
|
22
23
|
import { useWalletCharge } from "../use-wallet-charge";
|
|
23
24
|
|
|
24
25
|
import type { ListCard } from "./use-list-state";
|
|
@@ -60,10 +61,13 @@ export function CardRun({
|
|
|
60
61
|
pollIntervalMs,
|
|
61
62
|
onAccepted,
|
|
62
63
|
onFailed,
|
|
64
|
+
debit,
|
|
63
65
|
}: RunnerCallbacks & {
|
|
64
66
|
order: CheckoutOrder;
|
|
65
67
|
buyer: BuyerInfo;
|
|
66
68
|
card: ListCard;
|
|
69
|
+
/** The debit row is the choice: authenticate, then charge as debit (FUT-3754). */
|
|
70
|
+
debit?: DebitAuthentication;
|
|
67
71
|
config: CardTokenizationConfig;
|
|
68
72
|
chain: readonly CardChainLink[];
|
|
69
73
|
scope: ChallengeScope;
|
|
@@ -89,6 +93,7 @@ export function CardRun({
|
|
|
89
93
|
number: card.complete ? undefined : fieldCopy.numberInvalid,
|
|
90
94
|
}),
|
|
91
95
|
setFieldErrors: card.setFieldErrors,
|
|
96
|
+
debit,
|
|
92
97
|
},
|
|
93
98
|
chain,
|
|
94
99
|
scope,
|
|
@@ -8,16 +8,19 @@ import { cardName, type OnlineRow } from "./choice";
|
|
|
8
8
|
import type { ListChargeFailure } from "./charge-runners";
|
|
9
9
|
import { OnlineContent, opensContent } from "./online-content";
|
|
10
10
|
import { GroupHeading, OptionRow } from "./option-row";
|
|
11
|
-
import type { CheckoutDoorOption,
|
|
11
|
+
import type { CheckoutDoorOption, PaymentListCopy } from "./types";
|
|
12
12
|
import type { ListState } from "./use-list-state";
|
|
13
13
|
|
|
14
14
|
/** An online row's label and hint, worded for what the store and the buyer hold. */
|
|
15
15
|
function onlineWords(
|
|
16
|
-
|
|
16
|
+
row: OnlineRow,
|
|
17
17
|
copy: PaymentListCopy,
|
|
18
18
|
state: ListState,
|
|
19
19
|
provider: string | null,
|
|
20
20
|
): { label: string; hint: string } {
|
|
21
|
+
const { method } = row;
|
|
22
|
+
// A debit row exists only with these words (`useDebitReady`).
|
|
23
|
+
if (row.funding === "DEBIT" && copy.debit) return { label: copy.debit.label, hint: copy.debit.hint };
|
|
21
24
|
if (method === "PIX") return copy.pix;
|
|
22
25
|
if (method === "GOOGLE_PAY") return copy.googlePay;
|
|
23
26
|
if (method === "HAND_OFF") return { label: copy.handOff.label, hint: copy.handOff.hint(provider) };
|
|
@@ -28,6 +31,11 @@ function onlineWords(
|
|
|
28
31
|
};
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
/** A row's identity — its method, and `-DEBIT` for the debit card (FUT-3754). */
|
|
35
|
+
function rowKey(row: OnlineRow): string {
|
|
36
|
+
return row.funding ? `${row.method}-${row.funding}` : row.method;
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
/** The hint of a card the store declares and this browser cannot take — today's words (N9). */
|
|
32
40
|
function useUnavailableHint(): string {
|
|
33
41
|
return useCheckoutCopy().screens.method.unavailableHere;
|
|
@@ -50,23 +58,24 @@ interface RowProps {
|
|
|
50
58
|
function OnlineOptionRow({ row, props }: { row: OnlineRow; props: RowProps }): JSX.Element {
|
|
51
59
|
const { copy, state, provider } = props;
|
|
52
60
|
const unavailable = useUnavailableHint();
|
|
53
|
-
const words = onlineWords(row
|
|
54
|
-
const chosen = state.choice?.kind === "online" && state.choice.method === row.method;
|
|
61
|
+
const words = onlineWords(row, copy, state, provider);
|
|
62
|
+
const chosen = state.choice?.kind === "online" && state.choice.method === row.method && state.choice.funding === row.funding;
|
|
55
63
|
return (
|
|
56
64
|
<OptionRow
|
|
57
65
|
name={props.name}
|
|
58
|
-
value={row
|
|
66
|
+
value={rowKey(row)}
|
|
59
67
|
chosen={chosen}
|
|
60
68
|
disabled={row.disabled}
|
|
61
69
|
label={words.label}
|
|
62
70
|
hint={row.disabled ? unavailable : words.hint}
|
|
63
|
-
onChoose={() => state.choose({ kind: "online", method: row.method })}
|
|
64
|
-
testId={`list-option-${row
|
|
71
|
+
onChoose={() => state.choose({ kind: "online", method: row.method, ...(row.funding ? { funding: row.funding } : {}) })}
|
|
72
|
+
testId={`list-option-${rowKey(row)}`}
|
|
65
73
|
>
|
|
66
74
|
{opensContent(row.method, state.asked, props.failure, props.covered) ? (
|
|
67
75
|
<OnlineContent
|
|
68
76
|
withConsent={!state.top}
|
|
69
77
|
method={row.method}
|
|
78
|
+
debit={row.funding === "DEBIT"}
|
|
70
79
|
asked={state.asked}
|
|
71
80
|
buyer={props.buyer}
|
|
72
81
|
onBuyerChange={props.onBuyerChange}
|
|
@@ -164,7 +173,7 @@ export function ChoiceList({
|
|
|
164
173
|
{/* Each heading only over a group that has rows (a door-only store shows the door one alone). */}
|
|
165
174
|
{state.rows.length > 0 ? (
|
|
166
175
|
<Group id={`${base}-online`} heading={copy.onlineHeading}>
|
|
167
|
-
{state.rows.map((row) => <OnlineOptionRow key={row
|
|
176
|
+
{state.rows.map((row) => <OnlineOptionRow key={rowKey(row)} row={row} props={props} />)}
|
|
168
177
|
</Group>
|
|
169
178
|
) : null}
|
|
170
179
|
{door.length > 0 ? (
|
|
@@ -28,6 +28,8 @@ export interface OnlineRow {
|
|
|
28
28
|
method: CheckoutOnlineChoice;
|
|
29
29
|
/** A card the store declares but this browser cannot mint for — drawn, not choosable. */
|
|
30
30
|
disabled: boolean;
|
|
31
|
+
/** The DEBIT card row (FUT-3754): the card again, authenticated by 3-D Secure. */
|
|
32
|
+
funding?: "DEBIT";
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** What the chain lets this browser offer, as the list needs it. */
|
|
@@ -37,6 +39,8 @@ export interface OnlineCapability {
|
|
|
37
39
|
/** The methods the chain declares, or `null` while unknown (fails OPEN). */
|
|
38
40
|
offered: PaymentMethod[] | null;
|
|
39
41
|
cardUnavailable: boolean;
|
|
42
|
+
/** The debit row is offered: the head declares debit and the host gave an address (FUT-3754). */
|
|
43
|
+
debit?: boolean;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/**
|
|
@@ -51,6 +55,8 @@ export function onlineRows(cap: OnlineCapability): OnlineRow[] {
|
|
|
51
55
|
const rows: OnlineRow[] = [];
|
|
52
56
|
if (offers("PIX")) rows.push({ method: "PIX", disabled: false });
|
|
53
57
|
if (offers("CARD")) rows.push({ method: "CARD", disabled: cap.cardUnavailable });
|
|
58
|
+
// Right under the credit card, and only where the store has debit on.
|
|
59
|
+
if (offers("CARD") && cap.debit) rows.push({ method: "CARD", funding: "DEBIT", disabled: cap.cardUnavailable });
|
|
54
60
|
return rows;
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -197,9 +203,12 @@ export function actionLabel(
|
|
|
197
203
|
choice: CheckoutListChoice | null,
|
|
198
204
|
door: CheckoutDoorOption | undefined,
|
|
199
205
|
provider: string | null,
|
|
206
|
+
covered = false,
|
|
200
207
|
): string {
|
|
201
208
|
if (!choice) return copy.actions.choose;
|
|
202
209
|
if (choice.kind === "door") return door?.action ?? copy.actions.choose;
|
|
210
|
+
// A handoff leaves the store for a provider even at zero: it keeps its own verb.
|
|
211
|
+
if (covered && choice.method !== "HAND_OFF") return copy.actions.covered;
|
|
203
212
|
const verbs: Record<CheckoutOnlineChoice, string> = {
|
|
204
213
|
PIX: copy.actions.pix,
|
|
205
214
|
CARD: copy.actions.card,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The debit card row's host inputs (FUT-3754) — their own module for the size
|
|
3
|
+
* gate on `types.ts`, which re-exports them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** `PaymentListCopy.debit` — the debit card row and its 3-D Secure refusals. */
|
|
7
|
+
export interface PaymentListDebitCopy {
|
|
8
|
+
/** "Cartão de débito". */
|
|
9
|
+
label: string;
|
|
10
|
+
/** "Digite os dados do cartão; o banco pode pedir uma confirmação". */
|
|
11
|
+
hint: string;
|
|
12
|
+
/** The card cannot be authenticated as debit here — try credit or Pix. */
|
|
13
|
+
notSupported: string;
|
|
14
|
+
/** The bank refused the authentication. */
|
|
15
|
+
refused: string;
|
|
16
|
+
/** The authentication could not run (a session, the SDK, the buyer's data). */
|
|
17
|
+
failed: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the buyer's card bill goes, as a debit's 3-D Secure needs it
|
|
22
|
+
* (FUT-3754). The host's delivery address is the one it has; Brazil only.
|
|
23
|
+
*/
|
|
24
|
+
export interface CheckoutBillingAddress {
|
|
25
|
+
street: string;
|
|
26
|
+
number: string;
|
|
27
|
+
complement?: string;
|
|
28
|
+
city: string;
|
|
29
|
+
/** The state's two letters — "SP". */
|
|
30
|
+
regionCode: string;
|
|
31
|
+
postalCode: string;
|
|
32
|
+
}
|
|
@@ -11,6 +11,13 @@ export const EN_US_PAYMENT_LIST_COPY: PaymentListCopy = {
|
|
|
11
11
|
hintSaved: (card) => `${card} or another card`,
|
|
12
12
|
hintNew: "Enter your card details",
|
|
13
13
|
},
|
|
14
|
+
debit: {
|
|
15
|
+
label: "Debit card",
|
|
16
|
+
hint: "Your bank may ask you to confirm in its app",
|
|
17
|
+
notSupported: "This card can't be used as debit here. Try credit or Pix.",
|
|
18
|
+
refused: "Your bank didn't confirm the debit payment. Try another card or Pix.",
|
|
19
|
+
failed: "We couldn't confirm the debit with your bank. Try again.",
|
|
20
|
+
},
|
|
14
21
|
googlePay: { label: "Google Pay", hint: "With the card saved in your Google account" },
|
|
15
22
|
wallets: {
|
|
16
23
|
heading: "Quick payment",
|
|
@@ -50,6 +57,7 @@ export const EN_US_PAYMENT_LIST_COPY: PaymentListCopy = {
|
|
|
50
57
|
card: "Pay",
|
|
51
58
|
pix: "Create Pix",
|
|
52
59
|
googlePay: "Pay with Google Pay",
|
|
60
|
+
covered: "Pay with balance",
|
|
53
61
|
handOff: (provider) => (provider ? `Go to ${provider}` : "Go to payment"),
|
|
54
62
|
},
|
|
55
63
|
payer: (name) => `Paying as ${name} · CPF ✓`,
|
|
@@ -5,6 +5,7 @@ import { cardChain, cardTokenization, handOffMethod } from "../method-capability
|
|
|
5
5
|
import { PaymentErrorPanel } from "../payment-error-panel";
|
|
6
6
|
import { UNRESOLVED_CODE } from "../failure-codes";
|
|
7
7
|
import type { CheckoutBasketIdentity } from "../basket";
|
|
8
|
+
import type { DebitAuthentication } from "../debit-charge";
|
|
8
9
|
import type { BuyerContact, CheckoutProviderConfig, PaymentMethod } from "../types";
|
|
9
10
|
import type { useCheckoutController } from "../use-checkout-controller";
|
|
10
11
|
|
|
@@ -173,7 +174,7 @@ function buttonFacts(props: ListFaceProps): { label: string; total: string; busy
|
|
|
173
174
|
const door = chosenDoor(props);
|
|
174
175
|
const online = state.choice?.kind === "online";
|
|
175
176
|
return {
|
|
176
|
-
label: list.hold ? list.hold.action : pixBankLabel(list, state) ?? actionLabel(list.copy, state.choice, door, props.provider),
|
|
177
|
+
label: list.hold ? list.hold.action : pixBankLabel(list, state) ?? actionLabel(list.copy, state.choice, door, props.provider, coveredByHost(list)),
|
|
177
178
|
total: door ? (door.totalLabel ?? props.total) : online ? onlineTotal(props) : props.total,
|
|
178
179
|
busy: inFlight(props),
|
|
179
180
|
};
|
|
@@ -226,6 +227,14 @@ function BarAction({ props, failure, act, onCancel, reason, returnFocus }: {
|
|
|
226
227
|
return <PressButton props={props} act={act} reason={reason} returnFocus={returnFocus} />;
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/** The debit card's 3-D Secure inputs when the debit row is the choice (FUT-3754), else none. */
|
|
231
|
+
function debitOf(props: ListFaceProps): DebitAuthentication | undefined {
|
|
232
|
+
const { choice } = props.state;
|
|
233
|
+
const { billingAddress, copy } = props.list;
|
|
234
|
+
if (choice?.kind !== "online" || choice.funding !== "DEBIT" || !billingAddress || !copy.debit) return undefined;
|
|
235
|
+
return { billingAddress, copy: copy.debit };
|
|
236
|
+
}
|
|
237
|
+
|
|
229
238
|
/** The charge under way, once there is an order to run it against. */
|
|
230
239
|
function Runners({ props, sheet, onFailed, onWalletFailed }: {
|
|
231
240
|
props: ListFaceProps;
|
|
@@ -242,7 +251,7 @@ function Runners({ props, sheet, onFailed, onWalletFailed }: {
|
|
|
242
251
|
onResolved: c.handleResolved, pollIntervalMs: props.pollIntervalMs, onAccepted: charge.accepted, onFailed,
|
|
243
252
|
};
|
|
244
253
|
if (charge.kind === "CARD") {
|
|
245
|
-
return <CardRun {...common} card={props.state.card} config={cardTokenization(config)} chain={cardChain(config)} />;
|
|
254
|
+
return <CardRun {...common} card={props.state.card} config={cardTokenization(config)} chain={cardChain(config)} debit={debitOf(props)} />;
|
|
246
255
|
}
|
|
247
256
|
if (charge.kind !== null && charge.kind === sheet?.type) return <WalletRun {...common} onFailed={onWalletFailed} sheet={sheet} />;
|
|
248
257
|
return null;
|
|
@@ -86,6 +86,30 @@ function focusCardNumber(scope: HTMLElement | null): void {
|
|
|
86
86
|
scope?.querySelector<HTMLInputElement>('input[data-testid="card-number"]')?.focus();
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The card's own fields: the saved cards, then the form for a new one. A debit
|
|
91
|
+
* card (FUT-3754) is always typed — a saved card is charged as credit — and is
|
|
92
|
+
* never offered for saving.
|
|
93
|
+
*/
|
|
94
|
+
function CardFields({ card, copy, debit }: { card: ListCard; copy: PaymentListCopy; debit: boolean }): JSX.Element {
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
{debit ? null : <SavedChips card={card} copy={copy} />}
|
|
98
|
+
{card.selection === NEW_CARD ? (
|
|
99
|
+
<NewCardForm
|
|
100
|
+
card={card.card}
|
|
101
|
+
fieldErrors={card.fieldErrors}
|
|
102
|
+
brand={detectBrand(onlyDigits(card.card.number))}
|
|
103
|
+
saveCard={card.saveCard}
|
|
104
|
+
setCard={card.setCard}
|
|
105
|
+
setFieldErrors={card.setFieldErrors}
|
|
106
|
+
onSaveCardChange={debit ? undefined : card.setSaveCard}
|
|
107
|
+
/>
|
|
108
|
+
) : null}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
/**
|
|
90
114
|
* What opens inside a chosen ONLINE option, top to bottom (M11, FN:180-202):
|
|
91
115
|
* the buyer's missing details, then — for the card — the saved-card chips and
|
|
@@ -103,8 +127,11 @@ export function OnlineContent({
|
|
|
103
127
|
failure,
|
|
104
128
|
covered = false,
|
|
105
129
|
withConsent = true,
|
|
130
|
+
debit = false,
|
|
106
131
|
}: {
|
|
107
132
|
method: "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
|
|
133
|
+
/** The debit card row (FUT-3754): no saved cards, and nothing to save. */
|
|
134
|
+
debit?: boolean;
|
|
108
135
|
asked: readonly CheckoutCustomerField[];
|
|
109
136
|
buyer: BuyerInfo;
|
|
110
137
|
onBuyerChange: (buyer: BuyerInfo) => void;
|
|
@@ -130,18 +157,7 @@ export function OnlineContent({
|
|
|
130
157
|
onComplete={isCard ? () => focusCardNumber(scope.current) : undefined}
|
|
131
158
|
withConsent={withConsent}
|
|
132
159
|
/>
|
|
133
|
-
{isCard ? <
|
|
134
|
-
{isCard && card.selection === NEW_CARD ? (
|
|
135
|
-
<NewCardForm
|
|
136
|
-
card={card.card}
|
|
137
|
-
fieldErrors={card.fieldErrors}
|
|
138
|
-
brand={detectBrand(onlyDigits(card.card.number))}
|
|
139
|
-
saveCard={card.saveCard}
|
|
140
|
-
setCard={card.setCard}
|
|
141
|
-
setFieldErrors={card.setFieldErrors}
|
|
142
|
-
onSaveCardChange={card.setSaveCard}
|
|
143
|
-
/>
|
|
144
|
-
) : null}
|
|
160
|
+
{isCard ? <CardFields card={card} copy={copy} debit={debit} /> : null}
|
|
145
161
|
{failure ? <ChargeFailure message={failure.message} unresolved={failure.code === UNRESOLVED_CODE} /> : null}
|
|
146
162
|
</Box>
|
|
147
163
|
);
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type JSX, type ReactNode } f
|
|
|
2
2
|
|
|
3
3
|
import type { CheckoutBasketIdentity } from "../basket";
|
|
4
4
|
import { CartChangedBoundary } from "../cart-changed";
|
|
5
|
+
import { useCheckoutClientApi } from "../client-context";
|
|
5
6
|
import { displayTotals } from "../checkout-totals";
|
|
6
7
|
import type { CheckoutSummaryLine } from "../payment-summary";
|
|
7
8
|
import { usePaymentPixStage } from "../payment-step-layout";
|
|
@@ -144,6 +145,16 @@ function ListFaceFor({ props, state, charge, cap, onHandOff, reveal }: {
|
|
|
144
145
|
);
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Everything a debit row needs besides the provider's claim (FUT-3754): the
|
|
150
|
+
* host's billing address, its words for the row, and a client that can ask for
|
|
151
|
+
* the 3-D Secure session. Missing any one, there is no row.
|
|
152
|
+
*/
|
|
153
|
+
function useDebitReady(list: CheckoutPaymentList): boolean {
|
|
154
|
+
const client = useCheckoutClientApi();
|
|
155
|
+
return Boolean(list.billingAddress) && list.copy.debit !== undefined && client.threeDSecureSession !== undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
147
158
|
/**
|
|
148
159
|
* The Pagamento step as ONE list (FUT-3591, FUT-3592) — see `./types.ts`.
|
|
149
160
|
*
|
|
@@ -156,7 +167,8 @@ function ListFaceFor({ props, state, charge, cap, onHandOff, reveal }: {
|
|
|
156
167
|
export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
|
|
157
168
|
const { c, list } = props;
|
|
158
169
|
const config = props.providerConfig ?? null;
|
|
159
|
-
const
|
|
170
|
+
const debitReady = useDebitReady(list);
|
|
171
|
+
const cap = useListCapability(config, list.totalCents, props.validateApplePayMerchant, debitReady);
|
|
160
172
|
const reveal = useReveal(c, props.defaultBuyer);
|
|
161
173
|
const { revealed } = reveal;
|
|
162
174
|
const state = useListState({
|
|
@@ -27,6 +27,13 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
|
|
|
27
27
|
hintSaved: (card) => `${card} ou outro cartão`,
|
|
28
28
|
hintNew: "Digite os dados do cartão",
|
|
29
29
|
},
|
|
30
|
+
debit: {
|
|
31
|
+
label: "Cartão de débito",
|
|
32
|
+
hint: "O banco pode pedir confirmação no app",
|
|
33
|
+
notSupported: "Esse cartão não pode ser usado no débito aqui. Tente no crédito ou no Pix.",
|
|
34
|
+
refused: "O banco não confirmou o pagamento no débito. Tente outro cartão ou o Pix.",
|
|
35
|
+
failed: "O banco não confirmou o débito agora. Tente de novo.",
|
|
36
|
+
},
|
|
30
37
|
googlePay: { label: "Google Pay", hint: "Com o cartão salvo no seu Google" },
|
|
31
38
|
wallets: {
|
|
32
39
|
heading: "Pagamento rápido",
|
|
@@ -66,6 +73,7 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
|
|
|
66
73
|
card: "Pagar",
|
|
67
74
|
pix: "Gerar Pix",
|
|
68
75
|
googlePay: "Pagar com Google Pay",
|
|
76
|
+
covered: "Pagar com saldo",
|
|
69
77
|
handOff: (provider) => (provider ? `Ir para ${the(provider)}` : "Ir para o pagamento"),
|
|
70
78
|
},
|
|
71
79
|
payer: (name) => `Pagando como ${name} · CPF ✓`,
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import type { ReactNode } from "react";
|
|
22
22
|
|
|
23
|
+
import type { CheckoutBillingAddress, PaymentListDebitCopy } from "./debit-copy";
|
|
24
|
+
|
|
25
|
+
export type { CheckoutBillingAddress, PaymentListDebitCopy };
|
|
26
|
+
|
|
23
27
|
/**
|
|
24
28
|
* What a door option's `content` function is handed (FUT-3715).
|
|
25
29
|
*
|
|
@@ -103,7 +107,7 @@ export type CheckoutOnlineChoice = "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
|
|
|
103
107
|
|
|
104
108
|
/** What the buyer has chosen in the list. */
|
|
105
109
|
export type CheckoutListChoice =
|
|
106
|
-
| { kind: "online"; method: CheckoutOnlineChoice }
|
|
110
|
+
| { kind: "online"; method: CheckoutOnlineChoice; funding?: "DEBIT" }
|
|
107
111
|
| { kind: "door"; id: string };
|
|
108
112
|
|
|
109
113
|
/**
|
|
@@ -214,6 +218,8 @@ export interface CheckoutPaymentList {
|
|
|
214
218
|
* wide, the bar otherwise. The Pix screen and the hand-off keep their bar.
|
|
215
219
|
*/
|
|
216
220
|
summaryTarget?: HTMLElement | null;
|
|
221
|
+
/** The billing address a DEBIT card's 3-D Secure needs (FUT-3754); none ⇒ no debit row. */
|
|
222
|
+
billingAddress?: CheckoutBillingAddress | null;
|
|
217
223
|
}
|
|
218
224
|
|
|
219
225
|
/** The six banks the Pix face offers to open, in the prototype's order. */
|
|
@@ -286,6 +292,8 @@ export interface PaymentListCopy {
|
|
|
286
292
|
/** "Digite os dados do cartão". */
|
|
287
293
|
hintNew: string;
|
|
288
294
|
};
|
|
295
|
+
/** The DEBIT card row's words (FUT-3754). A pack without them offers no debit row. */
|
|
296
|
+
debit?: PaymentListDebitCopy;
|
|
289
297
|
/**
|
|
290
298
|
* @deprecated Google Pay is no longer a row (FUT-3685): it is a button in
|
|
291
299
|
* the "PAGAMENTO RÁPIDO" block — see {@link PaymentListCopy.wallets}. Kept so
|
|
@@ -354,6 +362,8 @@ export interface PaymentListCopy {
|
|
|
354
362
|
card: string;
|
|
355
363
|
pix: string;
|
|
356
364
|
googlePay: string;
|
|
365
|
+
/** The bar's verb when the host's balance pays the whole amount. */
|
|
366
|
+
covered: string;
|
|
357
367
|
handOff: (provider: string | null) => string;
|
|
358
368
|
};
|
|
359
369
|
/** "Pagando como Ana Teste · CPF ✓". */
|
|
@@ -71,12 +71,21 @@ function googleSheet(config: CheckoutProviderConfig | null, client: GooglePaymen
|
|
|
71
71
|
return params && client ? { client, params } : null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The debit row (FUT-3754): the card is here, the head declares debit, and the
|
|
76
|
+
* host gave the address PagBank's 3-D Secure refuses to run without.
|
|
77
|
+
*/
|
|
78
|
+
function debitOffered(config: CheckoutProviderConfig | null, card: boolean, hasBillingAddress: boolean): boolean {
|
|
79
|
+
return card && hasBillingAddress && config?.chain?.[0]?.debit === true;
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
/** {@link useListCapability}'s answer, from what it read. */
|
|
75
83
|
function capabilityOf(
|
|
76
84
|
config: CheckoutProviderConfig | null,
|
|
77
85
|
client: GooglePaymentsClient | null,
|
|
78
86
|
price: { priced: boolean; repricing: boolean },
|
|
79
87
|
port: ListApplePay["validateMerchant"] | undefined,
|
|
88
|
+
hasBillingAddress: boolean,
|
|
80
89
|
): ListCapability {
|
|
81
90
|
const offered = offeredMethods(config);
|
|
82
91
|
const cardUnavailable = !cardPathAvailable(config);
|
|
@@ -89,6 +98,7 @@ function capabilityOf(
|
|
|
89
98
|
handOff,
|
|
90
99
|
offered,
|
|
91
100
|
cardUnavailable,
|
|
101
|
+
debit: debitOffered(config, card, hasBillingAddress),
|
|
92
102
|
wallets: walletsShown(card && (price.priced || price.repricing), google, apple),
|
|
93
103
|
googlePay: live ? google : null,
|
|
94
104
|
applePay: live ? apple : null,
|
|
@@ -114,10 +124,12 @@ export function useListCapability(
|
|
|
114
124
|
/** The host's price for the sheets — no price (or zero), no wallet. */
|
|
115
125
|
totalCents: number | undefined,
|
|
116
126
|
validateApplePayMerchant?: ListApplePay["validateMerchant"],
|
|
127
|
+
/** The host gave the billing address a debit card's 3-D Secure needs. */
|
|
128
|
+
hasBillingAddress = false,
|
|
117
129
|
): ListCapability {
|
|
118
|
-
const declared = googlePayConfig(config)
|
|
130
|
+
const declared = googlePayConfig(config);
|
|
119
131
|
// `null` tells the client hook not to load pay.js at all.
|
|
120
|
-
const client = useGooglePayClient(declared ? undefined : null, "TEST");
|
|
132
|
+
const client = useGooglePayClient(declared ? undefined : null, declared?.environment ?? "TEST");
|
|
121
133
|
const { priced, repricing } = useRepricing(totalCents);
|
|
122
134
|
// The port is read at the press, through a ref: a host passing a new
|
|
123
135
|
// function each render must not rebuild the capability (and the wallets'
|
|
@@ -130,7 +142,7 @@ export function useListCapability(
|
|
|
130
142
|
return current ? current(validationURL) : Promise.reject(new Error("no merchant validation port"));
|
|
131
143
|
}, []);
|
|
132
144
|
return useMemo(
|
|
133
|
-
() => capabilityOf(config, client, { priced, repricing }, hasPort ? stablePort : undefined),
|
|
134
|
-
[config, client, priced, repricing, hasPort, stablePort],
|
|
145
|
+
() => capabilityOf(config, client, { priced, repricing }, hasPort ? stablePort : undefined, hasBillingAddress),
|
|
146
|
+
[config, client, priced, repricing, hasPort, stablePort, hasBillingAddress],
|
|
135
147
|
);
|
|
136
148
|
}
|
|
@@ -92,6 +92,25 @@ function reopenedSelection(cards: readonly SavedCard[], fresh: boolean): string
|
|
|
92
92
|
return fresh ? null : head.id;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The card selection a choice opens on, or `undefined` when it opens no card.
|
|
97
|
+
* Coming back to the card from another option starts on the head of the saved
|
|
98
|
+
* list again (FN:420), or on the fields when there is none; what was typed in
|
|
99
|
+
* them is kept. The debit row always types one: a saved card is charged as
|
|
100
|
+
* credit (FUT-3754).
|
|
101
|
+
*/
|
|
102
|
+
function cardOpenedBy(
|
|
103
|
+
previous: CheckoutListChoice | null,
|
|
104
|
+
next: CheckoutListChoice,
|
|
105
|
+
cards: readonly SavedCard[],
|
|
106
|
+
fresh: boolean,
|
|
107
|
+
): string | null | undefined {
|
|
108
|
+
if (next.kind !== "online" || next.method !== "CARD") return undefined;
|
|
109
|
+
const sameRow = previous?.kind === "online" && previous.method === "CARD" && previous.funding === next.funding;
|
|
110
|
+
if (sameRow) return undefined;
|
|
111
|
+
return next.funding === "DEBIT" ? NEW_CARD : reopenedSelection(cards, fresh);
|
|
112
|
+
}
|
|
113
|
+
|
|
95
114
|
/**
|
|
96
115
|
* The one list's state (FUT-3591). Picking an option changes THIS and nothing
|
|
97
116
|
* else: no order, no charge, no request — the bar's button is the only thing
|
|
@@ -133,20 +152,20 @@ export function useListState(input: {
|
|
|
133
152
|
(next: CheckoutListChoice) => {
|
|
134
153
|
touched.current = true;
|
|
135
154
|
setChoice((previous) => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// what was typed in them is kept.
|
|
139
|
-
const reopensCard = next.kind === "online" && next.method === "CARD" && !(previous?.kind === "online" && previous.method === "CARD");
|
|
140
|
-
if (reopensCard) setSelection(reopenedSelection(cards, input.fresh));
|
|
155
|
+
const selection = cardOpenedBy(previous, next, cards, input.fresh);
|
|
156
|
+
if (selection !== undefined) setSelection(selection);
|
|
141
157
|
return next;
|
|
142
158
|
});
|
|
143
159
|
},
|
|
144
160
|
[cards, setSelection, input.fresh],
|
|
145
161
|
);
|
|
146
162
|
// A choice the chain no longer offers (a config that resolved to a hand-off
|
|
147
|
-
// store after the card was preselected) is no choice at all.
|
|
163
|
+
// store after the card was preselected) is no choice at all. Matched on the
|
|
164
|
+
// funding too: a debit row that went away (its billing address with it) must
|
|
165
|
+
// not leave a choice the card charge would run as CREDIT (FUT-3754).
|
|
148
166
|
useEffect(() => {
|
|
149
|
-
if (choice?.kind
|
|
167
|
+
if (choice?.kind !== "online") return;
|
|
168
|
+
if (!rows.some((row) => row.method === choice.method && row.funding === choice.funding && !row.disabled)) setChoice(null);
|
|
150
169
|
}, [choice, rows]);
|
|
151
170
|
const { onChoiceChange } = input;
|
|
152
171
|
useEffect(() => {
|
|
@@ -158,13 +158,15 @@ function toCardLink(link: CheckoutChainLink): CardChainLink {
|
|
|
158
158
|
*/
|
|
159
159
|
export function googlePayConfig(
|
|
160
160
|
config: CheckoutProviderConfig | null,
|
|
161
|
-
): { gateway: string; gatewayMerchantId: string } | null {
|
|
161
|
+
): { gateway: string; gatewayMerchantId: string; environment: "TEST" | "PRODUCTION" } | null {
|
|
162
162
|
const head = config?.chain?.[0];
|
|
163
163
|
if (!head?.methods.includes("CARD")) return null;
|
|
164
164
|
if (!head.wallets?.includes("GOOGLE_PAY")) return null;
|
|
165
165
|
const params = head.googlePay;
|
|
166
166
|
if (!params?.gatewayMerchantId) return null;
|
|
167
|
-
|
|
167
|
+
// The connection's own environment (FUT-3756): a production store must never
|
|
168
|
+
// mint Google's test tokens. Absent ⇒ TEST, as before it was published.
|
|
169
|
+
return { gateway: params.gateway, gatewayMerchantId: params.gatewayMerchantId, environment: params.environment ?? "TEST" };
|
|
168
170
|
}
|
|
169
171
|
|
|
170
172
|
/**
|
|
@@ -20,6 +20,7 @@ import type { BuyerVaultSession, VaultedCardDisplay } from "@12-apps/payments-ba
|
|
|
20
20
|
import type { SavedCard } from "../../card";
|
|
21
21
|
import { err, ok, type Result } from "../../result";
|
|
22
22
|
|
|
23
|
+
import type { ThreeDSecureSessionView } from "./debit-types";
|
|
23
24
|
import type { CheckoutTransportCopy } from "./screens-copy";
|
|
24
25
|
|
|
25
26
|
import type {
|
|
@@ -127,6 +128,11 @@ export interface CheckoutClient {
|
|
|
127
128
|
*/
|
|
128
129
|
completeVault(input: CompleteVaultInput): Promise<Result<VaultedCardDisplay>>;
|
|
129
130
|
refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
|
|
131
|
+
/**
|
|
132
|
+
* `POST /3ds-session` (FUT-3754): the 3-D Secure session a debit card is
|
|
133
|
+
* authenticated on. Optional — a host without the row offers no debit.
|
|
134
|
+
*/
|
|
135
|
+
threeDSecureSession?(input: { orderId: string }): Promise<Result<ThreeDSecureSessionView>>;
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
/** Envelope the API routes return: `{ data }` on success, `{ error }` on failure. */
|
|
@@ -192,6 +198,11 @@ function returnedSettlement(): Record<string, string> {
|
|
|
192
198
|
};
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
/** A POST naming the payable — the body every payable-scoped row takes. */
|
|
202
|
+
function orderBody(input: { orderId: string }): RequestInit {
|
|
203
|
+
return { method: "POST", body: JSON.stringify({ orderId: input.orderId }) };
|
|
204
|
+
}
|
|
205
|
+
|
|
195
206
|
/** The ambient `fetch`, wrapped so it is never invoked detached from its global. */
|
|
196
207
|
function ambientFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
197
208
|
return globalThis.fetch(input, init);
|
|
@@ -211,6 +222,8 @@ function flatChargeBody(input: ChargeCardInput): string {
|
|
|
211
222
|
saveCard: input.saveCard,
|
|
212
223
|
cardMeta: input.cardMeta,
|
|
213
224
|
taxId: input.taxId,
|
|
225
|
+
...(input.funding ? { funding: input.funding } : {}),
|
|
226
|
+
...(input.authenticationId ? { authenticationId: input.authenticationId } : {}),
|
|
214
227
|
});
|
|
215
228
|
}
|
|
216
229
|
|
|
@@ -321,10 +334,8 @@ export function createCheckoutClient(transport: CheckoutTransport): CheckoutClie
|
|
|
321
334
|
body: completeVaultBody(input),
|
|
322
335
|
}),
|
|
323
336
|
|
|
324
|
-
refreshBrowserKey: (input) =>
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
body: JSON.stringify({ orderId: input.orderId }),
|
|
328
|
-
}),
|
|
337
|
+
refreshBrowserKey: (input) => call<{ publicKey: string | null }>("/refresh-key", orderBody(input)),
|
|
338
|
+
|
|
339
|
+
threeDSecureSession: (input) => call<ThreeDSecureSessionView>("/3ds-session", orderBody(input)),
|
|
329
340
|
};
|
|
330
341
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* this folder. The host-side halves — the cart, the catalog, order CREATION — stay in the
|
|
8
8
|
* host app and reach the flow through the ports declared on `CheckoutFlowProps`.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
import type { DebitChargeFields, DebitDeclaration } from "./debit-types";
|
|
11
11
|
import type { CheckoutDecline, CheckoutDeclineReason } from "./decline";
|
|
12
12
|
|
|
13
13
|
/** Payment methods offered at checkout. Mirrors `Payment.method` (FUT-42). */
|
|
@@ -215,7 +215,7 @@ export interface SavedCardMeta {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
/** Input to charge a tokenized card against an order. */
|
|
218
|
-
export interface ChargeCardInput {
|
|
218
|
+
export interface ChargeCardInput extends DebitChargeFields {
|
|
219
219
|
orderId: string;
|
|
220
220
|
/** A fresh token, or a saved card's id for reuse. */
|
|
221
221
|
token: string;
|
|
@@ -271,7 +271,7 @@ export interface CheckoutCustomerField {
|
|
|
271
271
|
* answers it. The same protocol facts as the head, stated per provider —
|
|
272
272
|
* which is what lets the card path mint an instrument for each of them.
|
|
273
273
|
*/
|
|
274
|
-
export interface CheckoutChainLink {
|
|
274
|
+
export interface CheckoutChainLink extends DebitDeclaration {
|
|
275
275
|
provider: string;
|
|
276
276
|
/**
|
|
277
277
|
* The provider's own name as a BUYER should read it ("InfinitePay"), which
|
|
@@ -322,7 +322,7 @@ export interface CheckoutChainLink {
|
|
|
322
322
|
* — the button must not render, because a token minted against a missing id
|
|
323
323
|
* charges nobody.
|
|
324
324
|
*/
|
|
325
|
-
googlePay?: { gateway: string; gatewayMerchantId: string | null } | null;
|
|
325
|
+
googlePay?: { gateway: string; gatewayMerchantId: string | null; environment?: "TEST" | "PRODUCTION" } | null;
|
|
326
326
|
/**
|
|
327
327
|
* The buyer screen THIS provider's flow needs (FUT-596) — an opaque id the
|
|
328
328
|
* adapter declares and `providers/registry.ts` resolves to a component.
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type CardTokenizationConfig,
|
|
15
15
|
type SavedCard,
|
|
16
16
|
} from "../../card";
|
|
17
|
-
import { ok, type Result } from "../../result";
|
|
17
|
+
import { err, ok, type Result } from "../../result";
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
resolveNewCardToken,
|
|
@@ -27,7 +27,9 @@ import { useCheckoutClientApi } from "./client-context";
|
|
|
27
27
|
import { useCheckoutNavigate } from "./navigate-context";
|
|
28
28
|
import type { CardChainLink } from "./method-capability";
|
|
29
29
|
import { useOneClickArmed, useOneClickPay } from "./one-click";
|
|
30
|
-
import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved, OrderStatus } from "./types";
|
|
30
|
+
import type { BuyerInfo, ChargeCardInput, CheckoutOrder, OnCheckoutResolved, OrderStatus } from "./types";
|
|
31
|
+
import { authenticateDebit, type DebitAuthentication } from "./debit-charge";
|
|
32
|
+
import type { CheckoutClient } from "./transport";
|
|
31
33
|
import { usePaymentPolling } from "./use-payment-polling";
|
|
32
34
|
import type { CardCopy } from "../../card/copy";
|
|
33
35
|
import { useCheckoutCopy } from "./copy-context";
|
|
@@ -80,6 +82,32 @@ interface CardFormState {
|
|
|
80
82
|
saveCard: boolean;
|
|
81
83
|
validate: () => CardFieldErrors;
|
|
82
84
|
setFieldErrors: React.Dispatch<React.SetStateAction<CardFieldErrors>>;
|
|
85
|
+
/** Charge the card as DEBIT, after its 3-D Secure (FUT-3754). */
|
|
86
|
+
debit?: DebitAuthentication;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The charge's body. A DEBIT card (FUT-3754) is 3-D Secure authenticated first
|
|
91
|
+
* and charged with the head's own instrument only — the authentication is that
|
|
92
|
+
* provider's — so no per-provider map, and nothing saved.
|
|
93
|
+
*/
|
|
94
|
+
async function chargeBodyOf(
|
|
95
|
+
form: CardFormState,
|
|
96
|
+
instruments: CardInstruments,
|
|
97
|
+
ctx: { client: CheckoutClient; order: CheckoutOrder; buyer: BuyerInfo },
|
|
98
|
+
): Promise<Result<ChargeCardInput>> {
|
|
99
|
+
const body: ChargeCardInput = {
|
|
100
|
+
orderId: ctx.order.orderId,
|
|
101
|
+
token: instruments.token,
|
|
102
|
+
tokensByProvider: instruments.tokensByProvider,
|
|
103
|
+
saveCard: form.usingNewCard && form.saveCard,
|
|
104
|
+
cardMeta: instruments.cardMeta,
|
|
105
|
+
taxId: ctx.buyer.taxId,
|
|
106
|
+
};
|
|
107
|
+
if (!form.debit) return ok(body);
|
|
108
|
+
const authenticated = await authenticateDebit({ ...ctx, encryptedCard: instruments.token, debit: form.debit });
|
|
109
|
+
if (!authenticated.ok) return authenticated;
|
|
110
|
+
return ok({ ...body, tokensByProvider: undefined, saveCard: false, funding: "DEBIT", authenticationId: authenticated.data });
|
|
83
111
|
}
|
|
84
112
|
|
|
85
113
|
/** The submit slice of {@link useCardCheckout}. */
|
|
@@ -223,20 +251,13 @@ export function useCardSubmit(
|
|
|
223
251
|
refreshKey: client.refreshBrowserKey,
|
|
224
252
|
copy: cardCopy,
|
|
225
253
|
});
|
|
226
|
-
|
|
227
|
-
|
|
254
|
+
const body = resolved.ok ? await chargeBodyOf(form, resolved.data, { client, order, buyer }) : err<ChargeCardInput>(resolved.error);
|
|
255
|
+
if (!body.ok) {
|
|
256
|
+
setError(body.error);
|
|
228
257
|
setSubmitting(false);
|
|
229
258
|
return;
|
|
230
259
|
}
|
|
231
|
-
|
|
232
|
-
const charged = await client.charge({
|
|
233
|
-
orderId: order.orderId,
|
|
234
|
-
token: resolved.data.token,
|
|
235
|
-
tokensByProvider: resolved.data.tokensByProvider,
|
|
236
|
-
saveCard: form.usingNewCard && form.saveCard,
|
|
237
|
-
cardMeta: resolved.data.cardMeta,
|
|
238
|
-
taxId: buyer.taxId,
|
|
239
|
-
});
|
|
260
|
+
const charged = await client.charge(body.data);
|
|
240
261
|
if (!charged.ok) {
|
|
241
262
|
setError(charged.error); // transport/validation problem — stay on the form
|
|
242
263
|
// An UNRESOLVED charge is not a decline: some provider may be holding the
|
|
@@ -128,6 +128,7 @@ function WalletButtons({
|
|
|
128
128
|
<GooglePayButton
|
|
129
129
|
order={props.order}
|
|
130
130
|
params={googlePay}
|
|
131
|
+
environment={googlePay.environment}
|
|
131
132
|
onKey={(key) => void wallet.payWithKey("GOOGLE_PAY", key)}
|
|
132
133
|
onError={onSheetError}
|
|
133
134
|
onReady={() => setGoogleReady(true)}
|
|
@@ -4,12 +4,16 @@ import { Alert, Box, Button, LinearProgress, Stack, Typography } from '@mui/mate
|
|
|
4
4
|
import { useState, type ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
import type {
|
|
7
|
+
BrowserCardFailure,
|
|
7
8
|
BrowserCardResult,
|
|
9
|
+
DebitTestData,
|
|
8
10
|
EvidenceSummary,
|
|
9
11
|
PaymentEnvironment,
|
|
12
|
+
ThreeDSecureSession,
|
|
10
13
|
} from '@12-apps/payments-backend';
|
|
11
14
|
|
|
12
|
-
import { maskTestCard, PAGBANK_TEST_CARDS } from '../../card/pagbank-test-cards';
|
|
15
|
+
import { maskTestCard, PAGBANK_3DS_TEST_CARD, PAGBANK_TEST_CARDS } from '../../card/pagbank-test-cards';
|
|
16
|
+
import { authenticatePagBankDebit, type ThreeDSFailure } from '../../card/three-ds';
|
|
13
17
|
import { encryptPagBankCard } from '../../card/tokenize';
|
|
14
18
|
import { CARD_SX, TAP_SX } from './ConnectEnvironmentCard';
|
|
15
19
|
import type { PlatformHomologacaoCopy } from './copy';
|
|
@@ -21,7 +25,8 @@ import { PlatformCopyProvider, usePlatformCopy } from './copy-context';
|
|
|
21
25
|
*
|
|
22
26
|
* The one thing this card does itself is the part that has to happen in a
|
|
23
27
|
* browser: encrypting PagBank's official TEST cards with the seller's public
|
|
24
|
-
* key, through the same SDK call the checkout makes
|
|
28
|
+
* key, through the same SDK call the checkout makes, and authenticating the
|
|
29
|
+
* 3DS test card with PagBank's SDK for the debit scenario (FUT-3755). Everything else is the
|
|
25
30
|
* host's — each callback is a call to its own mounted routes — and what the
|
|
26
31
|
* file says is decided by the server, from what was recorded. This card shows
|
|
27
32
|
* the counts it answered and never infers a result of its own.
|
|
@@ -47,8 +52,18 @@ export interface HomologacaoEvidenceCardProps {
|
|
|
47
52
|
connectOutcome?: 'connected' | 'failed' | null;
|
|
48
53
|
/** Start the authorization: the host mints the state and navigates away. */
|
|
49
54
|
onConnect: () => Promise<void>;
|
|
50
|
-
/**
|
|
51
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Phase 1 — the seller's public key and the debit card's 3DS session, each
|
|
57
|
+
* null when it could not be minted (the session absent on a host that does
|
|
58
|
+
* not mint one yet: the debit scenarios then stay pending, saying why).
|
|
59
|
+
*/
|
|
60
|
+
onPrepare: () => Promise<{
|
|
61
|
+
runId: string;
|
|
62
|
+
publicKey: string | null;
|
|
63
|
+
threeDSSession?: ThreeDSecureSession | null;
|
|
64
|
+
/** What the debit card is authenticated with — the server's, so the amount matches what it charges. */
|
|
65
|
+
debitTest?: DebitTestData;
|
|
66
|
+
}>;
|
|
52
67
|
/** Phase 2 — raise the orders with the encrypted cards. */
|
|
53
68
|
onRun: (runId: string, cards: BrowserCardResult[]) => Promise<void>;
|
|
54
69
|
/** Build and deliver the file; resolves to the counts it holds. */
|
|
@@ -58,29 +73,69 @@ export interface HomologacaoEvidenceCardProps {
|
|
|
58
73
|
type Phase = 'idle' | 'preparing' | 'encrypting' | 'running' | 'downloading';
|
|
59
74
|
type ActivePhase = Exclude<Phase, 'idle'>;
|
|
60
75
|
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
type TestCard = typeof PAGBANK_3DS_TEST_CARD;
|
|
77
|
+
|
|
78
|
+
/** One official test card, encrypted — or the code saying why it is not. */
|
|
79
|
+
async function encryptTestCard(card: TestCard, publicKey: string | null): Promise<BrowserCardResult> {
|
|
80
|
+
const base = { scenario: card.scenario, brand: card.brand, masked: maskTestCard(card.number) };
|
|
81
|
+
if (!publicKey) return { ...base, encrypted: null, failure: 'NO_PUBLIC_KEY' };
|
|
82
|
+
const encrypted = await encryptPagBankCard({
|
|
83
|
+
publicKey,
|
|
84
|
+
holder: card.holder,
|
|
85
|
+
number: card.number,
|
|
86
|
+
expMonth: card.expMonth,
|
|
87
|
+
expYear: card.expYear,
|
|
88
|
+
securityCode: card.securityCode,
|
|
89
|
+
});
|
|
90
|
+
return 'encrypted' in encrypted
|
|
91
|
+
? { ...base, encrypted: encrypted.encrypted }
|
|
92
|
+
: { ...base, encrypted: null, failure: encrypted.failure };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The SDK's 3DS failure, as the code the evidence copy words. */
|
|
96
|
+
const THREEDS_FAILURES: Readonly<Record<ThreeDSFailure, BrowserCardFailure>> = {
|
|
97
|
+
SDK_UNAVAILABLE: 'SDK_UNAVAILABLE',
|
|
98
|
+
NOT_SUPPORTED: 'THREEDS_NOT_SUPPORTED',
|
|
99
|
+
REFUSED: 'THREEDS_REFUSED',
|
|
100
|
+
INVALID_DATA: 'THREEDS_FAILED',
|
|
101
|
+
ERROR: 'THREEDS_FAILED',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The 3DS test card, encrypted and authenticated with PagBank's SDK for the
|
|
106
|
+
* amount the server will charge — or the code saying where it stopped. The
|
|
107
|
+
* server charges it as DEBIT only with the authentication id.
|
|
108
|
+
*/
|
|
109
|
+
async function authenticateDebitTestCard(
|
|
110
|
+
publicKey: string | null,
|
|
111
|
+
session: ThreeDSecureSession | null,
|
|
112
|
+
test: DebitTestData | undefined,
|
|
113
|
+
): Promise<BrowserCardResult> {
|
|
114
|
+
const encrypted = await encryptTestCard(PAGBANK_3DS_TEST_CARD, publicKey);
|
|
115
|
+
if (!encrypted.encrypted) return encrypted;
|
|
116
|
+
if (!session || !test) return { ...encrypted, failure: 'NO_THREEDS_SESSION' };
|
|
117
|
+
const outcome = await authenticatePagBankDebit({
|
|
118
|
+
session: session.session,
|
|
119
|
+
environment: session.environment,
|
|
120
|
+
encryptedCard: encrypted.encrypted,
|
|
121
|
+
amountCents: test.amountCents,
|
|
122
|
+
customer: test.customer,
|
|
123
|
+
billingAddress: test.billingAddress,
|
|
124
|
+
// The test card never challenges; a hung SDK must not hold the Pix and
|
|
125
|
+
// credit scenarios back with it.
|
|
126
|
+
timeoutMs: 60_000,
|
|
127
|
+
});
|
|
128
|
+
return outcome.ok
|
|
129
|
+
? { ...encrypted, authenticationId: outcome.authenticationId, authenticated: outcome.authenticated }
|
|
130
|
+
: { ...encrypted, failure: THREEDS_FAILURES[outcome.failure] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Each official test card, encrypted, then the debit one authenticated (FUT-3755). */
|
|
134
|
+
async function prepareTestCards(prepared: Awaited<ReturnType<HomologacaoEvidenceCardProps['onPrepare']>>): Promise<BrowserCardResult[]> {
|
|
135
|
+
const { publicKey } = prepared;
|
|
63
136
|
const results: BrowserCardResult[] = [];
|
|
64
|
-
for (const card of PAGBANK_TEST_CARDS)
|
|
65
|
-
|
|
66
|
-
if (!publicKey) {
|
|
67
|
-
results.push({ ...base, encrypted: null, failure: 'NO_PUBLIC_KEY' });
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
const encrypted = await encryptPagBankCard({
|
|
71
|
-
publicKey,
|
|
72
|
-
holder: card.holder,
|
|
73
|
-
number: card.number,
|
|
74
|
-
expMonth: card.expMonth,
|
|
75
|
-
expYear: card.expYear,
|
|
76
|
-
securityCode: card.securityCode,
|
|
77
|
-
});
|
|
78
|
-
results.push(
|
|
79
|
-
'encrypted' in encrypted
|
|
80
|
-
? { ...base, encrypted: encrypted.encrypted }
|
|
81
|
-
: { ...base, encrypted: null, failure: encrypted.failure },
|
|
82
|
-
);
|
|
83
|
-
}
|
|
137
|
+
for (const card of PAGBANK_TEST_CARDS) results.push(await encryptTestCard(card, publicKey));
|
|
138
|
+
results.push(await authenticateDebitTestCard(publicKey, prepared.threeDSSession ?? null, prepared.debitTest));
|
|
84
139
|
return results;
|
|
85
140
|
}
|
|
86
141
|
|
|
@@ -169,7 +224,7 @@ function useEvidenceRun({ onPrepare, onRun, onDownload }: Pick<HomologacaoEviden
|
|
|
169
224
|
enter('preparing');
|
|
170
225
|
const prepared = await onPrepare();
|
|
171
226
|
enter('encrypting');
|
|
172
|
-
const cards = await
|
|
227
|
+
const cards = await prepareTestCards(prepared);
|
|
173
228
|
enter('running');
|
|
174
229
|
await onRun(prepared.runId, cards);
|
|
175
230
|
}
|
|
@@ -123,12 +123,12 @@ export const EN_US_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
123
123
|
connectFailed: 'Could not start the test seller connection.',
|
|
124
124
|
returnedConnected: 'Test seller connected. The callback and the code exchange were recorded in the evidence.',
|
|
125
125
|
returnedFailed: 'The test seller connection did not complete. Try connecting again.',
|
|
126
|
-
generate: 'Generate evidence file\u00a0(TXT)',
|
|
126
|
+
generate: 'Generate evidence file\u00a0(TXT and JSON)',
|
|
127
127
|
downloadLatest: 'Download the latest recorded',
|
|
128
128
|
phases: {
|
|
129
|
-
preparing: 'Consulting the application, renewing the token and minting the public key…',
|
|
130
|
-
encrypting: 'Encrypting the test cards in the browser…',
|
|
131
|
-
running: 'Creating the Pix and
|
|
129
|
+
preparing: 'Consulting the application, renewing the token and minting the public key and the 3DS session…',
|
|
130
|
+
encrypting: 'Encrypting the test cards and authenticating the debit card (3DS) in the browser…',
|
|
131
|
+
running: 'Creating the Pix, credit and debit orders in the sandbox — this can take a few minutes…',
|
|
132
132
|
downloading: 'Building the file…',
|
|
133
133
|
},
|
|
134
134
|
summary: ({ executed, failed, pending }) =>
|
|
@@ -139,7 +139,7 @@ export const EN_US_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
139
139
|
`${
|
|
140
140
|
{
|
|
141
141
|
preparing: 'Consulting the application and preparing the test seller failed.',
|
|
142
|
-
encrypting: 'Encrypting the test cards in the browser failed.',
|
|
142
|
+
encrypting: 'Encrypting the test cards or authenticating the debit card in the browser failed.',
|
|
143
143
|
running: 'Creating the orders in the sandbox failed.',
|
|
144
144
|
downloading: 'Building the file failed.',
|
|
145
145
|
}[phase]
|
|
@@ -104,12 +104,12 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
104
104
|
'Lojista de teste conectado. O callback e a troca do código foram registrados nas evidências.',
|
|
105
105
|
returnedFailed:
|
|
106
106
|
'A conexão do lojista de teste não foi concluída. Tente conectar de novo.',
|
|
107
|
-
generate: 'Gerar arquivo de evidências\u00a0(TXT)',
|
|
107
|
+
generate: 'Gerar arquivo de evidências\u00a0(TXT e JSON)',
|
|
108
108
|
downloadLatest: 'Baixar o último registrado',
|
|
109
109
|
phases: {
|
|
110
|
-
preparing: 'Consultando a aplicação, renovando o token e gerando a chave pública…',
|
|
111
|
-
encrypting: 'Criptografando os cartões de teste no navegador…',
|
|
112
|
-
running: 'Criando os pedidos Pix e
|
|
110
|
+
preparing: 'Consultando a aplicação, renovando o token e gerando a chave pública e a sessão 3DS…',
|
|
111
|
+
encrypting: 'Criptografando os cartões de teste e autenticando o cartão de débito (3DS) no navegador…',
|
|
112
|
+
running: 'Criando os pedidos Pix, crédito e débito no sandbox — pode levar alguns minutos…',
|
|
113
113
|
downloading: 'Montando o arquivo…',
|
|
114
114
|
},
|
|
115
115
|
summary: ({ executed, failed, pending }) =>
|
|
@@ -123,7 +123,7 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
|
|
|
123
123
|
`${
|
|
124
124
|
{
|
|
125
125
|
preparing: 'Falhou ao consultar a aplicação e preparar o lojista de teste.',
|
|
126
|
-
encrypting: 'Falhou ao criptografar os cartões de teste no navegador.',
|
|
126
|
+
encrypting: 'Falhou ao criptografar os cartões de teste ou autenticar o cartão de débito no navegador.',
|
|
127
127
|
running: 'Falhou ao criar os pedidos no sandbox.',
|
|
128
128
|
downloading: 'Falhou ao montar o arquivo.',
|
|
129
129
|
}[phase]
|
package/src/index.ts
CHANGED
|
@@ -56,7 +56,7 @@ export { PT_BR_CHECKOUT_PAYMENT_COPY } from './components/checkout-payment-pt-BR
|
|
|
56
56
|
// ---------------------------------------------------------------------------
|
|
57
57
|
export { CheckoutFlow, type CheckoutCartView, type CheckoutFlowProps } from './components/checkout/checkout-flow';
|
|
58
58
|
// The Pagamento step as ONE list (FUT-3591/3592) — opt-in via `CheckoutFlowProps.paymentList`.
|
|
59
|
-
export type { CheckoutDoorOption, CheckoutDoorRowControls, CheckoutListChoice, CheckoutListHold, CheckoutOnlineChoice, CheckoutPaymentList, CheckoutPixBank, PaymentListCopy, PixBankCopy, PixBankId, PixBankName } from './components/checkout/list/types';
|
|
59
|
+
export type { CheckoutBillingAddress, CheckoutDoorOption, CheckoutDoorRowControls, CheckoutListChoice, CheckoutListHold, CheckoutOnlineChoice, CheckoutPaymentList, CheckoutPixBank, PaymentListCopy, PaymentListDebitCopy, PixBankCopy, PixBankId, PixBankName } from './components/checkout/list/types';
|
|
60
60
|
export { PT_BR_PAYMENT_LIST_COPY } from './components/checkout/list/pt-BR';
|
|
61
61
|
export type { BackActionEmphasis } from './components/checkout/payment-status-parts';
|
|
62
62
|
export type { CheckoutDeliverySummary, CheckoutSummaryLine } from './components/checkout/payment-summary';
|