@12-apps/payments-frontend 3.45.1 → 3.46.1
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/bank-en-US.ts +1 -0
- package/src/components/checkout/list/bank-pt-BR.ts +1 -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 +6 -0
- package/src/components/checkout/list/debit-copy.ts +32 -0
- package/src/components/checkout/list/en-US.ts +7 -0
- package/src/components/checkout/list/list-face.tsx +10 -1
- package/src/components/checkout/list/list-pix.tsx +1 -1
- 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/pix-banks.tsx +18 -2
- package/src/components/checkout/list/pt-BR.ts +7 -0
- package/src/components/checkout/list/types.ts +14 -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.1",
|
|
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
|
+
}
|
|
@@ -20,6 +20,7 @@ export const EN_US_PIX_BANK_COPY: PixBankCopy = {
|
|
|
20
20
|
other: "Another bank",
|
|
21
21
|
notOpened: (bank) => `We couldn't open ${bank.name}. Is the app installed on this phone?`,
|
|
22
22
|
retry: "Try again",
|
|
23
|
+
appHint: "Open your bank's app and paste the code in Pix copy and paste.",
|
|
23
24
|
copied: "Pix code copied",
|
|
24
25
|
copiedPick: "Pix code copied. Choose your bank.",
|
|
25
26
|
copiedOpening: (bank) => `Pix code copied. Opening ${bank.name}…`,
|
|
@@ -20,6 +20,7 @@ export const PT_BR_PIX_BANK_COPY: PixBankCopy = {
|
|
|
20
20
|
other: "Outro banco",
|
|
21
21
|
notOpened: (bank) => `Não conseguimos abrir ${bank.the}${bank.name}. O app está instalado neste celular?`,
|
|
22
22
|
retry: "Tentar de novo",
|
|
23
|
+
appHint: "Abra o app do seu banco e cole em Pix copia e cola.",
|
|
23
24
|
copied: "Código Pix copiado",
|
|
24
25
|
copiedPick: "Código Pix copiado. Escolha o banco.",
|
|
25
26
|
copiedOpening: (bank) => `Código Pix copiado. Abrindo ${bank.the}${bank.name}…`,
|
|
@@ -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
|
|
|
@@ -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",
|
|
@@ -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
|
|
|
@@ -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;
|
|
@@ -311,7 +311,7 @@ export function ListPixFace(props: ListPixFaceProps & { mode: PaymentListMode })
|
|
|
311
311
|
// On a phone the bank flow takes the copy: "Copiar e abrir o Nubank", the
|
|
312
312
|
// toast, the app. Without it — or on a computer — the copy is today's.
|
|
313
313
|
const onCopy = flow ? copies.copy : props.onCopy;
|
|
314
|
-
const banks = flow?.os ? { node: <PixBanks bank={flow.bank} state={flow.state} stale={stale} />, replacesChecklist:
|
|
314
|
+
const banks = flow?.os ? { node: <PixBanks bank={flow.bank} state={flow.state} stale={stale} />, replacesChecklist: true } : undefined;
|
|
315
315
|
return (
|
|
316
316
|
<Box data-testid="list-pix" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
317
317
|
<Text variant="body" size="md" weight="bold" as="p" style={{ margin: 0 }} data-testid="list-pix-title">
|
|
@@ -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
|
);
|