@12-apps/payments-frontend 3.45.1 → 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.
Files changed (28) hide show
  1. package/package.json +2 -2
  2. package/src/card/pagbank-test-cards.ts +17 -1
  3. package/src/card/three-ds.ts +166 -0
  4. package/src/card/tokenize.ts +6 -1
  5. package/src/components/checkout/client-context.tsx +1 -0
  6. package/src/components/checkout/debit-charge.ts +60 -0
  7. package/src/components/checkout/debit-types.ts +31 -0
  8. package/src/components/checkout/list/charge-runners.tsx +5 -0
  9. package/src/components/checkout/list/choice-list.tsx +17 -8
  10. package/src/components/checkout/list/choice.ts +6 -0
  11. package/src/components/checkout/list/debit-copy.ts +32 -0
  12. package/src/components/checkout/list/en-US.ts +7 -0
  13. package/src/components/checkout/list/list-face.tsx +10 -1
  14. package/src/components/checkout/list/online-content.tsx +28 -12
  15. package/src/components/checkout/list/payment-list-step.tsx +13 -1
  16. package/src/components/checkout/list/pt-BR.ts +7 -0
  17. package/src/components/checkout/list/types.ts +9 -1
  18. package/src/components/checkout/list/use-capability.ts +16 -4
  19. package/src/components/checkout/list/use-list-state.ts +26 -7
  20. package/src/components/checkout/method-capability.ts +4 -2
  21. package/src/components/checkout/transport.ts +16 -5
  22. package/src/components/checkout/types.ts +4 -4
  23. package/src/components/checkout/use-card-checkout.ts +34 -13
  24. package/src/components/checkout/wallet-pane.tsx +1 -0
  25. package/src/components/platform/HomologacaoEvidenceCard.tsx +82 -27
  26. package/src/components/platform/en-US.ts +5 -5
  27. package/src/components/platform/pt-BR.ts +5 -5
  28. 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.45.1",
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.36.1",
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
+ }
@@ -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, CheckoutOnlineChoice, PaymentListCopy } from "./types";
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
- method: CheckoutOnlineChoice,
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.method, copy, state, provider);
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.method}
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.method}`}
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.method} row={row} props={props} />)}
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;
@@ -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 ? <SavedChips card={card} copy={copy} /> : null}
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 cap = useListCapability(config, list.totalCents, props.validateApplePayMerchant);
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",
@@ -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
@@ -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) !== null;
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
- // Coming back to the card from another option starts on the head of
137
- // the saved list again (FN:420), or on the fields when there is none;
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 === "online" && !rows.some((row) => row.method === choice.method && !row.disabled)) setChoice(null);
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
- return { gateway: params.gateway, gatewayMerchantId: params.gatewayMerchantId };
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
- call<{ publicKey: string | null }>("/refresh-key", {
326
- method: "POST",
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
- if (!resolved.ok) {
227
- setError(resolved.error);
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. Everything else is the
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
- /** Phase 1 — the seller's public key, null when it could not be minted. */
51
- onPrepare: () => Promise<{ runId: string; publicKey: string | null }>;
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
- /** Each official test card, encrypted — or the code saying why it is not. */
62
- async function encryptTestCards(publicKey: string | null): Promise<BrowserCardResult[]> {
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
- const base = { scenario: card.scenario, brand: card.brand, masked: maskTestCard(card.number) };
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 encryptTestCards(prepared.publicKey);
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 card orders in the sandbox — this can take up to a minute…',
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 cartão no sandbox — pode levar até um minuto…',
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';