@12-apps/payments-frontend 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/src/__tests__/provider-priority-list.test.tsx +2 -2
- package/src/__tests__/slugged-provider.test.tsx +108 -0
- package/src/card/cpf.ts +42 -0
- package/src/card/fields.tsx +254 -0
- package/src/card/format.ts +103 -0
- package/src/card/index.ts +42 -0
- package/src/card/stripe-token.ts +81 -0
- package/src/card/tokenize.test.ts +194 -0
- package/src/card/tokenize.ts +327 -0
- package/src/card/types.ts +54 -0
- package/src/components/PaymentProviderSettings.tsx +30 -4
- package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
- package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
- package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
- package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
- package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
- package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
- package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
- package/src/components/checkout/buyer-info-form.tsx +138 -0
- package/src/components/checkout/card-view.tsx +128 -0
- package/src/components/checkout/checkout-flow.tsx +201 -0
- package/src/components/checkout/checkout-steps.tsx +366 -0
- package/src/components/checkout/client.ts +157 -0
- package/src/components/checkout/hosted-return.ts +92 -0
- package/src/components/checkout/icons.tsx +61 -0
- package/src/components/checkout/method-capability.ts +69 -0
- package/src/components/checkout/method-picker.tsx +153 -0
- package/src/components/checkout/mui-defaults.tsx +218 -0
- package/src/components/checkout/payer-summary.tsx +81 -0
- package/src/components/checkout/payment-status.tsx +256 -0
- package/src/components/checkout/payments-unavailable.tsx +79 -0
- package/src/components/checkout/pix-view.tsx +179 -0
- package/src/components/checkout/types.ts +223 -0
- package/src/components/checkout/ui.tsx +171 -0
- package/src/components/checkout/use-card-checkout.ts +346 -0
- package/src/components/checkout/use-checkout-controller.ts +252 -0
- package/src/components/checkout/use-payment-polling.ts +93 -0
- package/src/components/settings-state.ts +45 -2
- package/src/index.ts +74 -1
- package/src/result.ts +11 -0
- package/src/components/CheckoutFlow.tsx +0 -169
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { tokenizeForCheckout, tokenizerFor } from "./tokenize";
|
|
5
|
+
import type { CardDetails } from "./types";
|
|
6
|
+
|
|
7
|
+
/** A Luhn-valid Visa test PAN (the mock path validates before minting). */
|
|
8
|
+
const VALID_CARD: CardDetails = {
|
|
9
|
+
number: "4111 1111 1111 1111",
|
|
10
|
+
holder: "Ana Compradora",
|
|
11
|
+
expiry: "12/39",
|
|
12
|
+
cvv: "123",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** The always-declines test PAN the mock encodes as `tok_declined…`. */
|
|
16
|
+
const DECLINE_CARD: CardDetails = { ...VALID_CARD, number: "4000 0000 0000 0002" };
|
|
17
|
+
|
|
18
|
+
describe("tokenizeForCheckout — the mock gate (FUT-697)", () => {
|
|
19
|
+
it("NEVER mints a mock token without server-granted stub permission", async () => {
|
|
20
|
+
// The exact production shape of the bug: a Stone/Stripe store resolved to
|
|
21
|
+
// a null PagBank key, and the old fallback minted a fake `tok_…` that
|
|
22
|
+
// entered a real charge. With no `mockTokenization` grant, the answer is
|
|
23
|
+
// a clear bail BEFORE any money moves.
|
|
24
|
+
const result = await tokenizeForCheckout(VALID_CARD, {
|
|
25
|
+
provider: "stripe",
|
|
26
|
+
publicKey: null,
|
|
27
|
+
mockTokenization: false,
|
|
28
|
+
});
|
|
29
|
+
expect(result.ok).toBe(false);
|
|
30
|
+
if (!result.ok) expect(result.error).toContain("indisponível");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("bails clearly for a provider with no browser scheme, even WITH a key", async () => {
|
|
34
|
+
const result = await tokenizeForCheckout(VALID_CARD, {
|
|
35
|
+
provider: "infinitepay",
|
|
36
|
+
publicKey: "pk_live_123",
|
|
37
|
+
mockTokenization: false,
|
|
38
|
+
});
|
|
39
|
+
expect(result.ok).toBe(false);
|
|
40
|
+
if (!result.ok) expect(result.error).toContain("indisponível");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("bails clearly when the store has no provider at all", async () => {
|
|
44
|
+
const result = await tokenizeForCheckout(VALID_CARD, {
|
|
45
|
+
provider: null,
|
|
46
|
+
publicKey: null,
|
|
47
|
+
mockTokenization: false,
|
|
48
|
+
});
|
|
49
|
+
expect(result.ok).toBe(false);
|
|
50
|
+
if (!result.ok) expect(result.error).toContain("indisponível");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("mints the mock token under stub permission (dev / e2e path)", async () => {
|
|
54
|
+
const result = await tokenizeForCheckout(VALID_CARD, {
|
|
55
|
+
provider: "pagbank",
|
|
56
|
+
publicKey: null,
|
|
57
|
+
mockTokenization: true,
|
|
58
|
+
});
|
|
59
|
+
expect(result.ok).toBe(true);
|
|
60
|
+
if (result.ok) {
|
|
61
|
+
expect(result.data.token).toMatch(/^tok/);
|
|
62
|
+
expect(result.data.token).not.toMatch(/^tok_declined/);
|
|
63
|
+
expect(result.data.last4).toBe("1111");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("encodes the decline scenario in the mock token", async () => {
|
|
68
|
+
const result = await tokenizeForCheckout(DECLINE_CARD, {
|
|
69
|
+
provider: "pagbank",
|
|
70
|
+
publicKey: null,
|
|
71
|
+
mockTokenization: true,
|
|
72
|
+
});
|
|
73
|
+
expect(result.ok).toBe(true);
|
|
74
|
+
if (result.ok) expect(result.data.token).toMatch(/^tok_declined/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("still validates the card before minting a mock", async () => {
|
|
78
|
+
const result = await tokenizeForCheckout(
|
|
79
|
+
{ ...VALID_CARD, number: "1234" },
|
|
80
|
+
{ provider: "pagbank", publicKey: null, mockTokenization: true },
|
|
81
|
+
);
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("routes a keyed PagBank store to the REAL tokenizer — a key beats stub permission", async () => {
|
|
86
|
+
// Stub the SDK so `ensurePagBankSdk` short-circuits (no script injection in
|
|
87
|
+
// the test env). The encrypted blob coming back — never a `tok_…` mock —
|
|
88
|
+
// is the proof that a present key routes to REAL tokenization even when
|
|
89
|
+
// stub permission was granted.
|
|
90
|
+
const pagSeguro = {
|
|
91
|
+
encryptCard: () => ({ hasErrors: false, encryptedCard: "enc-blob-123" }),
|
|
92
|
+
};
|
|
93
|
+
(window as typeof window & { PagSeguro?: typeof pagSeguro }).PagSeguro = pagSeguro;
|
|
94
|
+
try {
|
|
95
|
+
const result = await tokenizeForCheckout(VALID_CARD, {
|
|
96
|
+
provider: "pagbank",
|
|
97
|
+
publicKey: "PUB-KEY",
|
|
98
|
+
mockTokenization: true, // even with stub permission, a key means REAL tokenization
|
|
99
|
+
});
|
|
100
|
+
expect(result.ok).toBe(true);
|
|
101
|
+
if (result.ok) {
|
|
102
|
+
expect(result.data.token).toBe("enc-blob-123");
|
|
103
|
+
expect(result.data.token).not.toMatch(/^tok/);
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
delete (window as typeof window & { PagSeguro?: typeof pagSeguro }).PagSeguro;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("tokenizerFor — scheme is chosen by provider, never by capability", () => {
|
|
112
|
+
it("names each implemented provider's own scheme", () => {
|
|
113
|
+
expect(tokenizerFor("pagbank")).toBe("pagbank-sdk");
|
|
114
|
+
expect(tokenizerFor("stone")).toBe("pagarme-token");
|
|
115
|
+
expect(tokenizerFor("stripe")).toBe("stripe-pm");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("answers null for providers with no browser card path", () => {
|
|
119
|
+
expect(tokenizerFor("infinitepay")).toBeNull();
|
|
120
|
+
expect(tokenizerFor("unknown-vendor")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("tokenizeForCheckout — the stripe-pm scheme (FUT-698)", () => {
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
vi.unstubAllGlobals();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/** Stub fetch once and capture the single call the tokenizer makes. */
|
|
130
|
+
function stubStripe(status: number, body: unknown): { url?: string; init?: RequestInit }[] {
|
|
131
|
+
const calls: { url?: string; init?: RequestInit }[] = [];
|
|
132
|
+
vi.stubGlobal(
|
|
133
|
+
"fetch",
|
|
134
|
+
vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
135
|
+
calls.push({ url: String(url), init });
|
|
136
|
+
return new Response(JSON.stringify(body), { status });
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
return calls;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
it("mints a PaymentMethod at Stripe with the publishable key — the PAN never reaches us", async () => {
|
|
143
|
+
const calls = stubStripe(200, { id: "pm_123", card: { brand: "visa", last4: "1111" } });
|
|
144
|
+
|
|
145
|
+
const result = await tokenizeForCheckout(VALID_CARD, {
|
|
146
|
+
provider: "stripe",
|
|
147
|
+
publicKey: "pk_test_1",
|
|
148
|
+
mockTokenization: false,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.ok).toBe(true);
|
|
152
|
+
if (result.ok) {
|
|
153
|
+
expect(result.data.token).toBe("pm_123");
|
|
154
|
+
expect(result.data.last4).toBe("1111");
|
|
155
|
+
}
|
|
156
|
+
expect(calls[0]?.url).toBe("https://api.stripe.com/v1/payment_methods");
|
|
157
|
+
const headers = calls[0]?.init?.headers as Record<string, string>;
|
|
158
|
+
expect(headers["Authorization"]).toBe("Bearer pk_test_1");
|
|
159
|
+
const form = new URLSearchParams(String(calls[0]?.init?.body));
|
|
160
|
+
expect(form.get("type")).toBe("card");
|
|
161
|
+
expect(form.get("card[number]")).toBe("4111111111111111");
|
|
162
|
+
expect(form.get("card[exp_month]")).toBe("12");
|
|
163
|
+
expect(form.get("card[exp_year]")).toBe("2039");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("carries Stripe's refusal verbatim — the provider's answer must reach a human", async () => {
|
|
167
|
+
stubStripe(402, { error: { message: "Your card number is incorrect." } });
|
|
168
|
+
|
|
169
|
+
const result = await tokenizeForCheckout(VALID_CARD, {
|
|
170
|
+
provider: "stripe",
|
|
171
|
+
publicKey: "pk_test_1",
|
|
172
|
+
mockTokenization: false,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(result.ok).toBe(false);
|
|
176
|
+
if (!result.ok) {
|
|
177
|
+
expect(result.error).toContain("HTTP 402");
|
|
178
|
+
expect(result.error).toContain("Your card number is incorrect.");
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("a keyed Stripe store routes to the REAL tokenizer — a key beats stub permission", async () => {
|
|
183
|
+
stubStripe(200, { id: "pm_real" });
|
|
184
|
+
|
|
185
|
+
const result = await tokenizeForCheckout(VALID_CARD, {
|
|
186
|
+
provider: "stripe",
|
|
187
|
+
publicKey: "pk_test_1",
|
|
188
|
+
mockTokenization: true, // even with stub permission, a key means REAL tokenization
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(result.ok).toBe(true);
|
|
192
|
+
if (result.ok) expect(result.data.token).toBe("pm_real");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side card tokenization — the PCI boundary.
|
|
3
|
+
*
|
|
4
|
+
* The PAN is validated locally, then encrypted **in the browser** by the
|
|
5
|
+
* PagBank SDK with the store's public key, so the raw {@link CardDetails} never
|
|
6
|
+
* leaves the page; only the encrypted blob is sent to the server. The SDK
|
|
7
|
+
* script is injected lazily the first time a real key is used.
|
|
8
|
+
*
|
|
9
|
+
* Two doors, and the naming is the safety mechanism:
|
|
10
|
+
*
|
|
11
|
+
* - {@link tokenizeCard} REQUIRES a real public key and fails without one.
|
|
12
|
+
* - {@link tokenizeForCheckout} may fall back to a fake token, but ONLY when
|
|
13
|
+
* the server granted stub mode (`mockTokenization`, FUT-697) — a dev/e2e
|
|
14
|
+
* affordance, and one that must never be reached by anything deciding
|
|
15
|
+
* whether a store is allowed to take money.
|
|
16
|
+
*
|
|
17
|
+
* That distinction is the whole reason this moved out of the storefront: the
|
|
18
|
+
* provider-activation check (FUT-463) proves a store can charge by charging it.
|
|
19
|
+
* Handed the mock, a store with no public key would "pass" without a single
|
|
20
|
+
* request reaching PagBank and be switched live — the exact false positive the
|
|
21
|
+
* check exists to catch.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { err, ok, type Result } from "../result";
|
|
25
|
+
|
|
26
|
+
import { detectBrand, onlyDigits } from "./format";
|
|
27
|
+
import { tokenizeWithStripe } from "./stripe-token";
|
|
28
|
+
import type { CardDetails, CardToken } from "./types";
|
|
29
|
+
|
|
30
|
+
/** Test PAN that always declines (mirrors common sandbox decline cards). */
|
|
31
|
+
const DECLINE_PAN = "4000000000000002";
|
|
32
|
+
|
|
33
|
+
/** PagBank browser SDK — encrypts the card with the public key, client-side. */
|
|
34
|
+
const PAGBANK_SDK_URL =
|
|
35
|
+
"https://assets.pagseguro.com.br/checkout-sdk-js/rc/dist/browser/pagseguro.min.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* PagBank browser SDK surface. It encrypts the card with the RSA public key
|
|
39
|
+
* entirely client-side, so the PAN never leaves the browser — only the
|
|
40
|
+
* resulting blob is sent to our server.
|
|
41
|
+
*/
|
|
42
|
+
interface PagBankEncryptResult {
|
|
43
|
+
encryptedCard?: string;
|
|
44
|
+
hasErrors: boolean;
|
|
45
|
+
errors?: unknown[];
|
|
46
|
+
}
|
|
47
|
+
declare global {
|
|
48
|
+
interface Window {
|
|
49
|
+
PagSeguro?: {
|
|
50
|
+
encryptCard(params: {
|
|
51
|
+
publicKey: string;
|
|
52
|
+
holder: string;
|
|
53
|
+
number: string;
|
|
54
|
+
expMonth: string;
|
|
55
|
+
expYear: string;
|
|
56
|
+
securityCode: string;
|
|
57
|
+
}): PagBankEncryptResult;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Inject the PagBank SDK script once and resolve when it is usable. Resolves
|
|
64
|
+
* `false` (never rejects) when the script fails or times out — the caller
|
|
65
|
+
* surfaces a friendly retry message.
|
|
66
|
+
*/
|
|
67
|
+
let sdkLoad: Promise<boolean> | null = null;
|
|
68
|
+
function ensurePagBankSdk(): Promise<boolean> {
|
|
69
|
+
if (typeof window === "undefined") return Promise.resolve(false);
|
|
70
|
+
if (window.PagSeguro?.encryptCard) return Promise.resolve(true);
|
|
71
|
+
sdkLoad ??= new Promise<boolean>((resolve) => {
|
|
72
|
+
const script = document.createElement("script");
|
|
73
|
+
script.src = PAGBANK_SDK_URL;
|
|
74
|
+
script.async = true;
|
|
75
|
+
script.onload = () => resolve(Boolean(window.PagSeguro?.encryptCard));
|
|
76
|
+
script.onerror = () => resolve(false);
|
|
77
|
+
document.head.appendChild(script);
|
|
78
|
+
});
|
|
79
|
+
return sdkLoad;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Local card-field validation. Returns an error message, or `null` when valid. */
|
|
83
|
+
function validateCardInput(card: CardDetails): string | null {
|
|
84
|
+
if (!passesLuhn(onlyDigits(card.number))) return "Número de cartão inválido.";
|
|
85
|
+
if (card.holder.trim().length < 2) return "Informe o nome impresso no cartão.";
|
|
86
|
+
if (!expiryIsFuture(card.expiry)) return "Validade inválida ou expirada.";
|
|
87
|
+
if (!/^\d{3,4}$/.test(card.cvv.trim())) return "CVV inválido.";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Encrypt the (already-validated) card with the PagBank browser SDK, so the raw
|
|
93
|
+
* PAN never leaves the page — only the resulting token is returned.
|
|
94
|
+
*/
|
|
95
|
+
function encryptWithSdk(
|
|
96
|
+
card: CardDetails,
|
|
97
|
+
pan: string,
|
|
98
|
+
publicKey: string,
|
|
99
|
+
brand: string,
|
|
100
|
+
last4: string,
|
|
101
|
+
): Result<CardToken> {
|
|
102
|
+
if (typeof window === "undefined" || !window.PagSeguro?.encryptCard) {
|
|
103
|
+
return err("Não foi possível carregar o meio de pagamento. Recarregue a página.");
|
|
104
|
+
}
|
|
105
|
+
const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
|
|
106
|
+
if (!match) return err("Validade inválida ou expirada.");
|
|
107
|
+
const encrypted = window.PagSeguro.encryptCard({
|
|
108
|
+
publicKey,
|
|
109
|
+
holder: card.holder.trim(),
|
|
110
|
+
number: pan,
|
|
111
|
+
expMonth: match[1]!,
|
|
112
|
+
expYear: `20${match[2]!}`,
|
|
113
|
+
securityCode: card.cvv.trim(),
|
|
114
|
+
});
|
|
115
|
+
if (encrypted.hasErrors || !encrypted.encryptedCard) {
|
|
116
|
+
return err("Não foi possível processar o cartão. Verifique os dados e tente novamente.");
|
|
117
|
+
}
|
|
118
|
+
return ok({ token: encrypted.encryptedCard, brand, last4 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which browser-side scheme mints the token.
|
|
123
|
+
*
|
|
124
|
+
* `tokenization: 'PUBLIC_KEY'` does NOT disambiguate this — PagBank and Stone
|
|
125
|
+
* both report it while speaking entirely different protocols: PagBank encrypts
|
|
126
|
+
* locally with an injected SDK, Pagar.me (Stone's payments technology) posts
|
|
127
|
+
* the card to its own tokens endpoint and hands back an id, and Stripe mints a
|
|
128
|
+
* PaymentMethod at its own endpoint with the publishable key (FUT-698). Keyed
|
|
129
|
+
* by provider because the protocol is the provider's, not the capability's.
|
|
130
|
+
*/
|
|
131
|
+
export type CardTokenizer = "pagbank-sdk" | "pagarme-token" | "stripe-pm";
|
|
132
|
+
|
|
133
|
+
export function tokenizerFor(provider: string): CardTokenizer | null {
|
|
134
|
+
if (provider === "pagbank") return "pagbank-sdk";
|
|
135
|
+
if (provider === "stone") return "pagarme-token";
|
|
136
|
+
if (provider === "stripe") return "stripe-pm";
|
|
137
|
+
// InfinitePay (REDIRECT) has no in-browser card path — its own page takes
|
|
138
|
+
// the card. Null so the caller can SAY so, rather than mint a token with the
|
|
139
|
+
// wrong scheme and report the provider's rejection as if the card were bad.
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Pagar.me v5 tokens endpoint — the public key rides as `appId`. */
|
|
144
|
+
const PAGARME_TOKENS_URL = "https://api.pagar.me/core/v5/tokens";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mint a Pagar.me card token, for Stone.
|
|
148
|
+
*
|
|
149
|
+
* The PAN is posted straight to Pagar.me from the browser and never touches our
|
|
150
|
+
* server — the same PCI boundary the PagBank SDK gives us by encrypting
|
|
151
|
+
* locally, reached a different way. Only the returned id comes back to us, and
|
|
152
|
+
* that id is what `stone-orders.ts` sends as `card_token`.
|
|
153
|
+
*/
|
|
154
|
+
async function tokenizeWithPagarme(
|
|
155
|
+
card: CardDetails,
|
|
156
|
+
pan: string,
|
|
157
|
+
publicKey: string,
|
|
158
|
+
brand: string,
|
|
159
|
+
last4: string,
|
|
160
|
+
): Promise<Result<CardToken>> {
|
|
161
|
+
const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
|
|
162
|
+
if (!match) return err("Validade inválida ou expirada.");
|
|
163
|
+
|
|
164
|
+
let response: Response;
|
|
165
|
+
try {
|
|
166
|
+
response = await fetch(`${PAGARME_TOKENS_URL}?appId=${encodeURIComponent(publicKey)}`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
type: "card",
|
|
171
|
+
card: {
|
|
172
|
+
number: pan,
|
|
173
|
+
holder_name: card.holder.trim(),
|
|
174
|
+
exp_month: Number(match[1]),
|
|
175
|
+
exp_year: Number(`20${match[2]}`),
|
|
176
|
+
cvv: card.cvv.trim(),
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
} catch {
|
|
181
|
+
return err("Não foi possível contatar o provedor do cartão. Verifique sua conexão.");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const body = (await response.json().catch(() => null)) as { id?: unknown } | null;
|
|
185
|
+
if (!response.ok || typeof body?.id !== "string") {
|
|
186
|
+
// Carried verbatim: a rejected tokenization is the provider's answer, and
|
|
187
|
+
// the whole point of the activation screen is that it reaches a human.
|
|
188
|
+
return err(
|
|
189
|
+
`O provedor recusou os dados do cartão (HTTP ${response.status}). ` +
|
|
190
|
+
`Resposta: ${JSON.stringify(body).slice(0, 300)}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return ok({ token: body.id, brand, last4 });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Tokenize a card with the store's PagBank public key.
|
|
198
|
+
*
|
|
199
|
+
* A missing key is an ERROR here, never a fallback: without one there is no
|
|
200
|
+
* encryption, so there is nothing a provider could accept or refuse.
|
|
201
|
+
*/
|
|
202
|
+
export async function tokenizeCard(
|
|
203
|
+
card: CardDetails,
|
|
204
|
+
publicKey: string | null | undefined,
|
|
205
|
+
tokenizer: CardTokenizer = "pagbank-sdk",
|
|
206
|
+
): Promise<Result<CardToken>> {
|
|
207
|
+
const validationError = validateCardInput(card);
|
|
208
|
+
if (validationError) return err(validationError);
|
|
209
|
+
|
|
210
|
+
if (!publicKey) {
|
|
211
|
+
return err(
|
|
212
|
+
"A chave pública do cartão não está disponível para esta loja. " +
|
|
213
|
+
"Reconecte o provedor e tente novamente.",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const pan = onlyDigits(card.number);
|
|
218
|
+
const brand = detectBrand(pan);
|
|
219
|
+
const last4 = pan.slice(-4);
|
|
220
|
+
|
|
221
|
+
if (tokenizer === "pagarme-token") {
|
|
222
|
+
return tokenizeWithPagarme(card, pan, publicKey, brand, last4);
|
|
223
|
+
}
|
|
224
|
+
if (tokenizer === "stripe-pm") {
|
|
225
|
+
return tokenizeWithStripe(card, pan, publicKey, brand, last4);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!(await ensurePagBankSdk())) {
|
|
229
|
+
return err("Não foi possível carregar o meio de pagamento. Recarregue a página.");
|
|
230
|
+
}
|
|
231
|
+
return encryptWithSdk(card, pan, publicKey, brand, last4);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* What the buyer checkout knows about the store's active provider — the
|
|
236
|
+
* buyer-safe triple `GET /api/checkout/config` answers (FUT-697), which is the
|
|
237
|
+
* ONLY sanctioned source for `mockTokenization`.
|
|
238
|
+
*/
|
|
239
|
+
export interface CardTokenizationConfig {
|
|
240
|
+
/** Active provider's name, or `null` when the store has none connected. */
|
|
241
|
+
provider: string | null;
|
|
242
|
+
/** The provider's PUBLIC browser key, when it has one. */
|
|
243
|
+
publicKey: string | null;
|
|
244
|
+
/**
|
|
245
|
+
* Server-granted stub-mode permission. `true` only when the store's
|
|
246
|
+
* connection runs the deterministic stub (local dev / e2e / demo tenants).
|
|
247
|
+
* A mock token may be minted under NO other condition — minting one against
|
|
248
|
+
* a real provider was the FUT-697 bug: a fake `tok_…` entered a real charge
|
|
249
|
+
* and the provider's rejection read as though the buyer's card were bad.
|
|
250
|
+
*/
|
|
251
|
+
mockTokenization: boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The clear bail (FUT-697): said BEFORE any charge, naming the remedy. */
|
|
255
|
+
const CARD_PATH_UNAVAILABLE =
|
|
256
|
+
"O pagamento com cartão está indisponível nesta loja no momento. Recarregue a página e tente de novo, escolha outro método de pagamento ou combine diretamente com a loja.";
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Tokenize for the buyer checkout, speaking the ACTIVE provider's protocol.
|
|
260
|
+
*
|
|
261
|
+
* - Provider with a browser scheme and a key ⇒ real tokenization, with the
|
|
262
|
+
* scheme {@link tokenizerFor} names (PagBank encrypts via SDK, Stone posts
|
|
263
|
+
* to Pagar.me, Stripe mints a PaymentMethod with the publishable key —
|
|
264
|
+
* PagBank and Stone advertise the same `PUBLIC_KEY` while speaking different
|
|
265
|
+
* protocols, so the choice is by provider, never by capability).
|
|
266
|
+
* - Stub-mode store ⇒ a mock token that encodes the decline scenario, so the
|
|
267
|
+
* full order→charge→confirm path runs with no credentials (dev / e2e).
|
|
268
|
+
* - Anything else ⇒ a clear bail. Never a fake token into a real charge.
|
|
269
|
+
*
|
|
270
|
+
* Only the buyer checkout may use this. Anything that GRANTS something on a
|
|
271
|
+
* successful charge must call {@link tokenizeCard} instead — a mock token
|
|
272
|
+
* cannot prove a real account works.
|
|
273
|
+
*/
|
|
274
|
+
export async function tokenizeForCheckout(
|
|
275
|
+
card: CardDetails,
|
|
276
|
+
config: CardTokenizationConfig,
|
|
277
|
+
): Promise<Result<CardToken>> {
|
|
278
|
+
const scheme = config.provider ? tokenizerFor(config.provider) : null;
|
|
279
|
+
if (scheme && config.publicKey) return tokenizeCard(card, config.publicKey, scheme);
|
|
280
|
+
|
|
281
|
+
if (!config.mockTokenization) return err(CARD_PATH_UNAVAILABLE);
|
|
282
|
+
|
|
283
|
+
const validationError = validateCardInput(card);
|
|
284
|
+
if (validationError) return err(validationError);
|
|
285
|
+
|
|
286
|
+
const pan = onlyDigits(card.number);
|
|
287
|
+
const token = pan === DECLINE_PAN ? mintToken("tok_declined") : mintToken("tok");
|
|
288
|
+
return ok({ token, brand: detectBrand(pan), last4: pan.slice(-4) });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Local card helpers (used only by tokenization — the PAN stays in this file)
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/** Luhn checksum — rejects obviously invalid card numbers before tokenizing. */
|
|
296
|
+
function passesLuhn(pan: string): boolean {
|
|
297
|
+
let sum = 0;
|
|
298
|
+
let double = false;
|
|
299
|
+
for (let i = pan.length - 1; i >= 0; i -= 1) {
|
|
300
|
+
let digit = Number(pan[i]);
|
|
301
|
+
if (double) {
|
|
302
|
+
digit *= 2;
|
|
303
|
+
if (digit > 9) digit -= 9;
|
|
304
|
+
}
|
|
305
|
+
sum += digit;
|
|
306
|
+
double = !double;
|
|
307
|
+
}
|
|
308
|
+
return pan.length >= 13 && sum % 10 === 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function expiryIsFuture(expiry: string): boolean {
|
|
312
|
+
const match = /^(\d{2})\/(\d{2})$/.exec(expiry.trim());
|
|
313
|
+
if (!match) return false;
|
|
314
|
+
const month = Number(match[1]);
|
|
315
|
+
const year = 2000 + Number(match[2]);
|
|
316
|
+
if (month < 1 || month > 12) return false;
|
|
317
|
+
const now = new Date();
|
|
318
|
+
const endOfMonth = new Date(year, month, 0, 23, 59, 59);
|
|
319
|
+
return endOfMonth.getTime() >= now.getTime();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let tokenCounter = 0;
|
|
323
|
+
/** Opaque client-side token id (stands in for the SDK's encrypted card token). */
|
|
324
|
+
function mintToken(prefix: string): string {
|
|
325
|
+
tokenCounter += 1;
|
|
326
|
+
return `${prefix}_${Date.now().toString(36)}${tokenCounter.toString(36)}`;
|
|
327
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The card-entry vocabulary, shared by every surface that takes a card.
|
|
3
|
+
*
|
|
4
|
+
* These lived in the storefront checkout until the admin needed the SAME form:
|
|
5
|
+
* activating a payment provider is now earned by a real R$0,01 charge on the
|
|
6
|
+
* owner's own card (FUT-463), and that has to exercise the shopper's exact
|
|
7
|
+
* path — same fields, same validation, same tokenization — or it proves
|
|
8
|
+
* nothing about what a shopper will hit.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Raw card details entered in the form. These NEVER leave the browser — they
|
|
13
|
+
* are handed to `tokenizeCard` (the PagBank JS SDK in production) and only the
|
|
14
|
+
* resulting {@link CardToken} is sent to the server, so the PAN stays out of
|
|
15
|
+
* PCI scope.
|
|
16
|
+
*/
|
|
17
|
+
export interface CardDetails {
|
|
18
|
+
number: string;
|
|
19
|
+
holder: string;
|
|
20
|
+
/** `MM/YY`. */
|
|
21
|
+
expiry: string;
|
|
22
|
+
cvv: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A client-side card token — the only card data that reaches the server. */
|
|
26
|
+
export interface CardToken {
|
|
27
|
+
token: string;
|
|
28
|
+
brand: string;
|
|
29
|
+
last4: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A previously-saved card available for reuse. Only display metadata is kept
|
|
34
|
+
* client-side — the reusable token lives server-side keyed by {@link id}.
|
|
35
|
+
*/
|
|
36
|
+
export interface SavedCard {
|
|
37
|
+
id: string;
|
|
38
|
+
brand: string;
|
|
39
|
+
last4: string;
|
|
40
|
+
expMonth: number;
|
|
41
|
+
expYear: number;
|
|
42
|
+
holder: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Per-field validation messages; `undefined` means the field is fine. */
|
|
46
|
+
export interface CardFieldErrors {
|
|
47
|
+
number?: string;
|
|
48
|
+
holder?: string;
|
|
49
|
+
expiry?: string;
|
|
50
|
+
cvv?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Sentinel selection value meaning "enter a new card". */
|
|
54
|
+
export const NEW_CARD = "new";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Alert, Box, Button, CircularProgress } from '@mui/material';
|
|
4
|
-
import type
|
|
4
|
+
import { useCallback, type ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
import type { MaskedProviderConfig } from '@12-apps/payments-backend';
|
|
7
7
|
|
|
@@ -12,11 +12,13 @@ import { ActivePanel, type ActivePanelProps, type PrepareConnect } from './Provi
|
|
|
12
12
|
import {
|
|
13
13
|
guideAwaitsConfirmation,
|
|
14
14
|
progressKeyOf,
|
|
15
|
+
useCanonicalProviderSegment,
|
|
15
16
|
useOpenProvider,
|
|
16
17
|
useSelectedProvider,
|
|
17
18
|
useSettingsState,
|
|
18
19
|
useSetupConfirmation,
|
|
19
20
|
useSetupGuide,
|
|
21
|
+
type ProviderChangeHandler,
|
|
20
22
|
} from './settings-state';
|
|
21
23
|
import { openSection, SetupGuideSection } from './SetupGuideSection';
|
|
22
24
|
|
|
@@ -66,6 +68,12 @@ export interface PaymentProviderSettingsProps {
|
|
|
66
68
|
* because the host's value is now the single source of truth. Leave it
|
|
67
69
|
* `undefined` and nothing changes: selection stays internal, as every
|
|
68
70
|
* existing caller expects.
|
|
71
|
+
*
|
|
72
|
+
* The value may be the provider's NAME or its `urlSlug` — a host that keeps
|
|
73
|
+
* the selection in a path segment passes the segment verbatim, and the raw
|
|
74
|
+
* name stays a working alias so old links do not 404. Which spelling each
|
|
75
|
+
* provider uses is the adapter's own declaration, carried in the catalog;
|
|
76
|
+
* the host holds no map.
|
|
69
77
|
*/
|
|
70
78
|
selectedProvider?: string | null;
|
|
71
79
|
/**
|
|
@@ -74,8 +82,15 @@ export interface PaymentProviderSettingsProps {
|
|
|
74
82
|
* Independent of `selectedProvider`: an uncontrolled host can use this purely
|
|
75
83
|
* to observe, while a controlled host uses it to write the new selection
|
|
76
84
|
* wherever it keeps it.
|
|
85
|
+
*
|
|
86
|
+
* Reports the provider's `urlSlug` — its name, unless the adapter re-spells
|
|
87
|
+
* it — so a controlled host writes the value straight into its URL. It is
|
|
88
|
+
* also how a segment gets CORRECTED: handed an alias (the OAuth callback's
|
|
89
|
+
* `?connected=` carries the raw name), this fires once with the canonical
|
|
90
|
+
* slug and `{ replace: true }`, asking the host to rewrite — not extend —
|
|
91
|
+
* its history ({@link ProviderChangeHandler}).
|
|
77
92
|
*/
|
|
78
|
-
onProviderChange?:
|
|
93
|
+
onProviderChange?: ProviderChangeHandler;
|
|
79
94
|
/**
|
|
80
95
|
* The activation step, rendered under the connection card.
|
|
81
96
|
*
|
|
@@ -216,8 +231,19 @@ export function PaymentProviderSettings({
|
|
|
216
231
|
selectedProvider,
|
|
217
232
|
onProviderChange,
|
|
218
233
|
);
|
|
234
|
+
// What leaves this component (and what it stores) is the adapter's URL
|
|
235
|
+
// spelling, so a controlled host writes it into its path segment verbatim —
|
|
236
|
+
// `useOpenProvider` resolves names and slugs alike, so either survives.
|
|
237
|
+
const openProvider = useCallback(
|
|
238
|
+
(name: string | null) =>
|
|
239
|
+
setSelected(name ? (view?.providers.find((p) => p.name === name)?.urlSlug ?? name) : null),
|
|
240
|
+
[view, setSelected],
|
|
241
|
+
);
|
|
219
242
|
// Resolved above the early returns, because the guide hook depends on both.
|
|
220
243
|
const { active, activeConfig } = useOpenProvider(view, selected);
|
|
244
|
+
// A controlled segment that resolved through the alias gets respelled to the
|
|
245
|
+
// adapter's canonical slug — the address bar is part of the contract.
|
|
246
|
+
useCanonicalProviderSegment(selectedProvider, active, onProviderChange);
|
|
221
247
|
const { guide, loaded } = useSetupGuide(client, active?.name ?? null, progressKeyOf(activeConfig));
|
|
222
248
|
const ack = useSetupConfirmation(client, active, activeConfig);
|
|
223
249
|
|
|
@@ -225,7 +251,7 @@ export function PaymentProviderSettings({
|
|
|
225
251
|
if (!view) return <CircularProgress data-testid="payments-settings-loading" />;
|
|
226
252
|
|
|
227
253
|
if (!active) {
|
|
228
|
-
return <ProviderList view={view} client={client} reload={reload} onSelect={
|
|
254
|
+
return <ProviderList view={view} client={client} reload={reload} onSelect={openProvider} />;
|
|
229
255
|
}
|
|
230
256
|
|
|
231
257
|
return (
|
|
@@ -236,7 +262,7 @@ export function PaymentProviderSettings({
|
|
|
236
262
|
onChanged={onChanged}
|
|
237
263
|
reload={() => void reload()}
|
|
238
264
|
prepareConnect={prepareConnect}
|
|
239
|
-
onBack={() =>
|
|
265
|
+
onBack={() => openProvider(null)}
|
|
240
266
|
// A new credential may name a DIFFERENT InfinitePay account, and the
|
|
241
267
|
// owner's "Checkout Integrado is on" was said about the old one. The
|
|
242
268
|
// server already drops its own verdict and its proof on any credential
|