@12-apps/payments-frontend 1.0.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +7 -4
  2. package/src/__tests__/provider-priority-list.test.tsx +2 -2
  3. package/src/__tests__/slugged-provider.test.tsx +108 -0
  4. package/src/card/cpf.ts +42 -0
  5. package/src/card/fields.tsx +254 -0
  6. package/src/card/format.ts +103 -0
  7. package/src/card/index.ts +42 -0
  8. package/src/card/stripe-token.ts +81 -0
  9. package/src/card/tokenize.test.ts +194 -0
  10. package/src/card/tokenize.ts +327 -0
  11. package/src/card/types.ts +54 -0
  12. package/src/components/PaymentProviderSettings.tsx +30 -4
  13. package/src/components/checkout/__tests__/card-3ds-handover.test.tsx +147 -0
  14. package/src/components/checkout/__tests__/clear-cart-on-paid.test.tsx +64 -0
  15. package/src/components/checkout/__tests__/hosted-return.test.ts +109 -0
  16. package/src/components/checkout/__tests__/method-capability.test.tsx +120 -0
  17. package/src/components/checkout/__tests__/payments-unavailable.test.tsx +53 -0
  18. package/src/components/checkout/__tests__/save-on-continue.test.tsx +165 -0
  19. package/src/components/checkout/__tests__/second-host.test.tsx +86 -0
  20. package/src/components/checkout/buyer-info-form.tsx +138 -0
  21. package/src/components/checkout/card-view.tsx +128 -0
  22. package/src/components/checkout/checkout-flow.tsx +201 -0
  23. package/src/components/checkout/checkout-steps.tsx +366 -0
  24. package/src/components/checkout/client.ts +157 -0
  25. package/src/components/checkout/hosted-return.ts +92 -0
  26. package/src/components/checkout/icons.tsx +61 -0
  27. package/src/components/checkout/method-capability.ts +69 -0
  28. package/src/components/checkout/method-picker.tsx +153 -0
  29. package/src/components/checkout/mui-defaults.tsx +218 -0
  30. package/src/components/checkout/payer-summary.tsx +81 -0
  31. package/src/components/checkout/payment-status.tsx +256 -0
  32. package/src/components/checkout/payments-unavailable.tsx +79 -0
  33. package/src/components/checkout/pix-view.tsx +179 -0
  34. package/src/components/checkout/types.ts +223 -0
  35. package/src/components/checkout/ui.tsx +171 -0
  36. package/src/components/checkout/use-card-checkout.ts +346 -0
  37. package/src/components/checkout/use-checkout-controller.ts +252 -0
  38. package/src/components/checkout/use-payment-polling.ts +93 -0
  39. package/src/components/settings-state.ts +45 -2
  40. package/src/index.ts +74 -1
  41. package/src/result.ts +11 -0
  42. package/src/components/CheckoutFlow.tsx +0 -169
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "1.0.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
6
6
  "exports": {
@@ -14,6 +14,9 @@
14
14
  "check-types": "tsc --noEmit",
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
+ "dependencies": {
18
+ "react-qr-code": "^2.2.0"
19
+ },
17
20
  "peerDependencies": {
18
21
  "@emotion/react": ">=11.0.0",
19
22
  "@emotion/styled": ">=11.0.0",
@@ -25,9 +28,9 @@
25
28
  "@emotion/react": "^11.14.0",
26
29
  "@emotion/styled": "^11.14.0",
27
30
  "@mui/material": "^6.5.0",
28
- "@12-apps/eslint-config": "^1.1.0",
29
- "@12-apps/payments-backend": "^1.0.0",
30
- "@12-apps/typescript-config": "^1.1.0",
31
+ "@12-apps/eslint-config": "^1.3.1",
32
+ "@12-apps/payments-backend": "^1.2.1",
33
+ "@12-apps/typescript-config": "^1.3.1",
31
34
  "@testing-library/react": "^16.1.0",
32
35
  "@types/react": "19.2.2",
33
36
  "@types/react-dom": "19.2.2",
@@ -36,8 +36,8 @@ function viewOf(configs: MaskedProviderConfig[]): MerchantSettingsView {
36
36
  const chain = configs.filter((c) => c.enabled).map((c) => c.provider);
37
37
  return {
38
38
  providers: [
39
- { name: 'stone', displayName: 'Stone', authMode: 'credentials', capabilities: CAPS, credentialSchema: [] },
40
- { name: 'stripe', displayName: 'Stripe', authMode: 'oauth', capabilities: CAPS, credentialSchema: [] },
39
+ { name: 'stone', displayName: 'Stone', urlSlug: 'stone', authMode: 'credentials', capabilities: CAPS, credentialSchema: [] },
40
+ { name: 'stripe', displayName: 'Stripe', urlSlug: 'stripe', authMode: 'oauth', capabilities: CAPS, credentialSchema: [] },
41
41
  ],
42
42
  configs,
43
43
  providerChain: chain,
@@ -0,0 +1,108 @@
1
+ // @vitest-environment jsdom
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import type { MerchantSettingsView } from '@12-apps/payments-backend';
6
+
7
+ import type { PaymentsSettingsClient } from '../client';
8
+ import { PaymentProviderSettings } from '../components/PaymentProviderSettings';
9
+
10
+ /**
11
+ * How a provider is spelled in a URL is the ADAPTER's declaration (FUT-557),
12
+ * carried in the catalog as `urlSlug`. A controlled host passes its path
13
+ * segment verbatim and writes back whatever `onProviderChange` reports, so
14
+ * it holds no map of providers: resolution here must accept the slug AND the
15
+ * raw name (old links must not 404), and report the slug on every change.
16
+ */
17
+
18
+ const VIEW: MerchantSettingsView = {
19
+ providers: [
20
+ {
21
+ name: 'infinitepay',
22
+ displayName: 'InfinitePay',
23
+ urlSlug: 'infinite-pay',
24
+ authMode: 'credentials',
25
+ credentialSchema: [],
26
+ },
27
+ { name: 'pagbank', displayName: 'PagBank', urlSlug: 'pagbank', authMode: 'oauth', credentialSchema: [] },
28
+ ],
29
+ configs: [],
30
+ activeProvider: null,
31
+ } as unknown as MerchantSettingsView;
32
+
33
+ function fakeClient(): PaymentsSettingsClient {
34
+ return {
35
+ getSettings: vi.fn().mockResolvedValue(VIEW),
36
+ getSetupGuide: vi.fn().mockResolvedValue(null),
37
+ setEnabled: vi.fn(),
38
+ saveCredentials: vi.fn(),
39
+ } as unknown as PaymentsSettingsClient;
40
+ }
41
+
42
+ describe('PaymentProviderSettings — adapter-declared URL slugs', () => {
43
+ it('opens the provider a slug segment names', async () => {
44
+ render(<PaymentProviderSettings client={fakeClient()} selectedProvider="infinite-pay" />);
45
+
46
+ expect(await screen.findByTestId('payments-provider-back')).toBeDefined();
47
+ await waitFor(() => expect(screen.queryByTestId('payments-provider-picker')).toBeNull());
48
+ });
49
+
50
+ it('still resolves the raw name, so a link minted before the slug existed lands', async () => {
51
+ render(<PaymentProviderSettings client={fakeClient()} selectedProvider="infinitepay" />);
52
+
53
+ expect(await screen.findByTestId('payments-provider-back')).toBeDefined();
54
+ });
55
+
56
+ it('reports the slug on selection, for the host to write into its URL verbatim', async () => {
57
+ const onProviderChange = vi.fn();
58
+ render(
59
+ <PaymentProviderSettings
60
+ client={fakeClient()}
61
+ selectedProvider={null}
62
+ onProviderChange={onProviderChange}
63
+ />,
64
+ );
65
+ const card = await screen.findByTestId('payments-provider-card-infinitepay');
66
+
67
+ card.click();
68
+
69
+ await waitFor(() => expect(onProviderChange).toHaveBeenCalledWith('infinite-pay'));
70
+ });
71
+
72
+ /**
73
+ * The OAuth callback's `?connected=` carries the raw NAME, and the host
74
+ * writes it into the URL before the catalog exists — it holds no map. The
75
+ * screen is right either way (the alias resolves); the ADDRESS BAR is what
76
+ * would stay wrong, and it is what a reload or a shared link uses. So once
77
+ * the catalog can spell the provider, the component asks the host to respell
78
+ * the segment — as a `replace`, so Voltar never revisits the alias.
79
+ */
80
+ it('asks the host to respell an alias segment to the canonical slug, as a replace', async () => {
81
+ const onProviderChange = vi.fn();
82
+ render(
83
+ <PaymentProviderSettings
84
+ client={fakeClient()}
85
+ selectedProvider="infinitepay"
86
+ onProviderChange={onProviderChange}
87
+ />,
88
+ );
89
+
90
+ await waitFor(() =>
91
+ expect(onProviderChange).toHaveBeenCalledWith('infinite-pay', { replace: true }),
92
+ );
93
+ });
94
+
95
+ it('leaves a segment already spelled canonically alone', async () => {
96
+ const onProviderChange = vi.fn();
97
+ render(
98
+ <PaymentProviderSettings
99
+ client={fakeClient()}
100
+ selectedProvider="infinite-pay"
101
+ onProviderChange={onProviderChange}
102
+ />,
103
+ );
104
+
105
+ await screen.findByTestId('payments-provider-back');
106
+ expect(onProviderChange).not.toHaveBeenCalled();
107
+ });
108
+ });
@@ -0,0 +1,42 @@
1
+ import { onlyDigits } from "./format";
2
+
3
+ /**
4
+ * CPF (Brazilian taxpayer id) formatting + validation.
5
+ *
6
+ * PagBank requires the payer's CPF (`customer.tax_id`) on every PIX/card charge
7
+ * — the shopper's at checkout, and the owner's on the R$0,01 charge that proves
8
+ * their provider works (FUT-463). It is sent to PagBank as digits and — by
9
+ * design — never persisted in our database (data minimization).
10
+ */
11
+
12
+ /** Progressive `000.000.000-00` mask for a partially-typed CPF. */
13
+ export function formatCpf(value: string): string {
14
+ const d = onlyDigits(value).slice(0, 11);
15
+ let out = d.slice(0, 3);
16
+ if (d.length >= 4) out += `.${d.slice(3, 6)}`;
17
+ if (d.length >= 7) out += `.${d.slice(6, 9)}`;
18
+ if (d.length >= 10) out += `-${d.slice(9, 11)}`;
19
+ return out;
20
+ }
21
+
22
+ /** Verify the two CPF check digits (rejects all-same-digit sequences too). */
23
+ function isValidCpf(value: string): boolean {
24
+ const d = onlyDigits(value);
25
+ if (d.length !== 11) return false;
26
+ if (/^(\d)\1{10}$/.test(d)) return false;
27
+
28
+ const checkDigit = (len: number): number => {
29
+ let sum = 0;
30
+ for (let i = 0; i < len; i += 1) sum += Number(d[i]) * (len + 1 - i);
31
+ const mod = (sum * 10) % 11;
32
+ return mod === 10 ? 0 : mod;
33
+ };
34
+
35
+ return checkDigit(9) === Number(d[9]) && checkDigit(10) === Number(d[10]);
36
+ }
37
+
38
+ /** Field validator: returns an error message, or `undefined` when valid. */
39
+ export function validateCpf(value: string): string | undefined {
40
+ if (!onlyDigits(value)) return "CPF obrigatório.";
41
+ return isValidCpf(value) ? undefined : "CPF inválido.";
42
+ }
@@ -0,0 +1,254 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX } from "react";
3
+
4
+ import { useCheckoutComponents } from "../components/checkout/ui";
5
+
6
+ import {
7
+ cvvLength,
8
+ formatCardNumber,
9
+ formatCvv,
10
+ formatExpiry,
11
+ validateCardNumber,
12
+ validateCvv,
13
+ validateExpiry,
14
+ validateHolder,
15
+ type CardBrand,
16
+ } from "./format";
17
+ import { NEW_CARD, type CardDetails, type CardFieldErrors, type SavedCard } from "./types";
18
+
19
+ type SetCard = React.Dispatch<React.SetStateAction<CardDetails>>;
20
+ type SetErrors = React.Dispatch<React.SetStateAction<CardFieldErrors>>;
21
+
22
+ /** Saved-cards radio list, with a trailing "Novo cartão" option. */
23
+ export function SavedCardsPicker({
24
+ savedCards,
25
+ selection,
26
+ onSelect,
27
+ }: {
28
+ savedCards: SavedCard[];
29
+ selection: string;
30
+ onSelect: (id: string) => void;
31
+ }): JSX.Element {
32
+ const { RadioGroup } = useCheckoutComponents();
33
+ return (
34
+ <RadioGroup
35
+ label="Cartão"
36
+ value={selection}
37
+ onChange={(_event, next) => onSelect(next)}
38
+ options={[
39
+ ...savedCards.map((saved) => ({
40
+ value: saved.id,
41
+ label: `${saved.brand} •••• ${saved.last4}`,
42
+ description: `Validade ${String(saved.expMonth).padStart(2, "0")}/${saved.expYear}`,
43
+ })),
44
+ { value: NEW_CARD, label: "Novo cartão", description: "Inserir outro cartão" },
45
+ ]}
46
+ dataTestId="saved-cards"
47
+ />
48
+ );
49
+ }
50
+
51
+ /** The card-number field — carries the detected-brand adornment. */
52
+ function CardNumberInput({
53
+ card,
54
+ fieldErrors,
55
+ brand,
56
+ setCard,
57
+ setFieldErrors,
58
+ }: {
59
+ card: CardDetails;
60
+ fieldErrors: CardFieldErrors;
61
+ brand: CardBrand;
62
+ setCard: SetCard;
63
+ setFieldErrors: SetErrors;
64
+ }): JSX.Element {
65
+ const { Input, Text } = useCheckoutComponents();
66
+ return (
67
+ <Input
68
+ label="Número do cartão"
69
+ type="text"
70
+ inputMode="numeric"
71
+ variant="outlined"
72
+ size="md"
73
+ fullWidth
74
+ autoComplete="cc-number"
75
+ placeholder="0000 0000 0000 0000"
76
+ value={card.number}
77
+ error={Boolean(fieldErrors.number)}
78
+ helperText={fieldErrors.number}
79
+ endAdornment={
80
+ brand !== "Cartão" ? (
81
+ <Text variant="caption" size="xs" color="secondary" as="span">
82
+ {brand}
83
+ </Text>
84
+ ) : undefined
85
+ }
86
+ onChange={(event) => setCard((prev) => ({ ...prev, number: formatCardNumber(event.target.value) }))}
87
+ onBlur={() => setFieldErrors((prev) => ({ ...prev, number: validateCardNumber(card.number) }))}
88
+ data-testid="card-number"
89
+ />
90
+ );
91
+ }
92
+
93
+ /** The side-by-side expiry + CVV fields. */
94
+ function ExpiryCvvFields({
95
+ card,
96
+ fieldErrors,
97
+ brand,
98
+ setCard,
99
+ setFieldErrors,
100
+ }: {
101
+ card: CardDetails;
102
+ fieldErrors: CardFieldErrors;
103
+ brand: CardBrand;
104
+ setCard: SetCard;
105
+ setFieldErrors: SetErrors;
106
+ }): JSX.Element {
107
+ const { Input } = useCheckoutComponents();
108
+ return (
109
+ <Box sx={{ display: "flex", gap: 2 }}>
110
+ <Input
111
+ label="Validade (MM/AA)"
112
+ type="text"
113
+ inputMode="numeric"
114
+ placeholder="MM/AA"
115
+ variant="outlined"
116
+ size="md"
117
+ fullWidth
118
+ autoComplete="cc-exp"
119
+ value={card.expiry}
120
+ error={Boolean(fieldErrors.expiry)}
121
+ helperText={fieldErrors.expiry}
122
+ onChange={(event) => setCard((prev) => ({ ...prev, expiry: formatExpiry(event.target.value) }))}
123
+ onBlur={() => setFieldErrors((prev) => ({ ...prev, expiry: validateExpiry(card.expiry) }))}
124
+ data-testid="card-expiry"
125
+ />
126
+ <Input
127
+ label="CVV"
128
+ type="text"
129
+ inputMode="numeric"
130
+ variant="outlined"
131
+ size="md"
132
+ fullWidth
133
+ autoComplete="cc-csc"
134
+ maxLength={cvvLength(brand)}
135
+ value={card.cvv}
136
+ error={Boolean(fieldErrors.cvv)}
137
+ helperText={fieldErrors.cvv}
138
+ onChange={(event) => setCard((prev) => ({ ...prev, cvv: formatCvv(event.target.value) }))}
139
+ onBlur={() => setFieldErrors((prev) => ({ ...prev, cvv: validateCvv(card.cvv, brand) }))}
140
+ data-testid="card-cvv"
141
+ />
142
+ </Box>
143
+ );
144
+ }
145
+
146
+ /**
147
+ * New-card entry form: number, holder, expiry+CVV, and the save-card opt-in.
148
+ *
149
+ * The opt-in appears only when the caller can act on it. A one-off card — the
150
+ * owner's, on the R$0,01 charge that proves their provider works — has nothing
151
+ * to save it for, and offering a dead checkbox there would be a promise the
152
+ * screen does not keep.
153
+ */
154
+ export function NewCardForm({
155
+ card,
156
+ fieldErrors,
157
+ brand,
158
+ saveCard = false,
159
+ setCard,
160
+ setFieldErrors,
161
+ onSaveCardChange,
162
+ }: {
163
+ card: CardDetails;
164
+ fieldErrors: CardFieldErrors;
165
+ brand: CardBrand;
166
+ saveCard?: boolean;
167
+ setCard: SetCard;
168
+ setFieldErrors: SetErrors;
169
+ onSaveCardChange?: (checked: boolean) => void;
170
+ }): JSX.Element {
171
+ const { Input, Checkbox } = useCheckoutComponents();
172
+ return (
173
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
174
+ <CardNumberInput
175
+ card={card}
176
+ fieldErrors={fieldErrors}
177
+ brand={brand}
178
+ setCard={setCard}
179
+ setFieldErrors={setFieldErrors}
180
+ />
181
+ <Input
182
+ label="Nome impresso no cartão"
183
+ type="text"
184
+ variant="outlined"
185
+ size="md"
186
+ fullWidth
187
+ autoComplete="cc-name"
188
+ value={card.holder}
189
+ error={Boolean(fieldErrors.holder)}
190
+ helperText={fieldErrors.holder}
191
+ onChange={(event) => setCard((prev) => ({ ...prev, holder: event.target.value }))}
192
+ onBlur={() => setFieldErrors((prev) => ({ ...prev, holder: validateHolder(card.holder) }))}
193
+ data-testid="card-holder"
194
+ />
195
+ <ExpiryCvvFields
196
+ card={card}
197
+ fieldErrors={fieldErrors}
198
+ brand={brand}
199
+ setCard={setCard}
200
+ setFieldErrors={setFieldErrors}
201
+ />
202
+ {onSaveCardChange ? (
203
+ <Checkbox
204
+ label="Salvar cartão para próximas compras"
205
+ checked={saveCard}
206
+ onChange={(_event, checked) => onSaveCardChange(checked)}
207
+ data-testid="save-card"
208
+ />
209
+ ) : null}
210
+ </Box>
211
+ );
212
+ }
213
+
214
+ /** Sticky primary "Pagar" action — pinned to the bottom of the viewport on mobile. */
215
+ export function CardPayBar({
216
+ totalLabel,
217
+ submitting,
218
+ onPay,
219
+ }: {
220
+ totalLabel: string;
221
+ submitting: boolean;
222
+ onPay: () => void;
223
+ }): JSX.Element {
224
+ const { Button } = useCheckoutComponents();
225
+ return (
226
+ <Box
227
+ data-testid="card-pay-bar"
228
+ sx={{
229
+ position: { xs: "sticky", sm: "static" },
230
+ bottom: 0,
231
+ zIndex: 2,
232
+ mx: { xs: -2, sm: 0 },
233
+ px: { xs: 2, sm: 0 },
234
+ py: { xs: 1.5, sm: 0 },
235
+ bgcolor: "background.paper",
236
+ borderTop: { xs: "1px solid", sm: "none" },
237
+ borderColor: "divider",
238
+ }}
239
+ >
240
+ <Button
241
+ variant="solid"
242
+ color="primary"
243
+ size="lg"
244
+ fullWidth
245
+ loading={submitting}
246
+ disabled={submitting}
247
+ onClick={onPay}
248
+ dataTestId="card-pay"
249
+ >
250
+ Pagar {totalLabel}
251
+ </Button>
252
+ </Box>
253
+ );
254
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Client-side card formatting + validation (FUT-58).
3
+ *
4
+ * Pure helpers with no imports — the card form uses them to format input live and
5
+ * to validate before tokenizing. They never transmit anything; the PAN is only
6
+ * checked locally (Luhn + brand + length) then handed to the tokenizer.
7
+ */
8
+
9
+ export type CardBrand = "Visa" | "Mastercard" | "Amex" | "Elo" | "Cartão";
10
+
11
+ /** Strip everything but digits. */
12
+ export function onlyDigits(value: string): string {
13
+ return value.replace(/\D/g, "");
14
+ }
15
+
16
+ /** Best-effort brand detection from the leading digits. */
17
+ export function detectBrand(digits: string): CardBrand {
18
+ if (/^4/.test(digits)) return "Visa";
19
+ if (/^(5[1-5]|2[2-7])/.test(digits)) return "Mastercard";
20
+ if (/^3[47]/.test(digits)) return "Amex";
21
+ if (/^6/.test(digits)) return "Elo";
22
+ return "Cartão";
23
+ }
24
+
25
+ /** Expected CVV length for a brand (Amex uses 4, everyone else 3). */
26
+ export function cvvLength(brand: CardBrand): number {
27
+ return brand === "Amex" ? 4 : 3;
28
+ }
29
+
30
+ /** Format a PAN into brand-appropriate groups, e.g. `4242 4242 4242 4242`. */
31
+ export function formatCardNumber(value: string): string {
32
+ const digits = onlyDigits(value).slice(0, 19);
33
+ const groups = detectBrand(digits) === "Amex" ? [4, 6, 5] : [4, 4, 4, 4, 3];
34
+ const parts: string[] = [];
35
+ let cursor = 0;
36
+ for (const size of groups) {
37
+ if (cursor >= digits.length) break;
38
+ parts.push(digits.slice(cursor, cursor + size));
39
+ cursor += size;
40
+ }
41
+ return parts.join(" ");
42
+ }
43
+
44
+ /** Format an expiry as `MM/YY`, inserting the slash after the month. */
45
+ export function formatExpiry(value: string): string {
46
+ const digits = onlyDigits(value).slice(0, 4);
47
+ return digits.length <= 2 ? digits : `${digits.slice(0, 2)}/${digits.slice(2)}`;
48
+ }
49
+
50
+ /** Keep CVV digits only (max 4). */
51
+ export function formatCvv(value: string): string {
52
+ return onlyDigits(value).slice(0, 4);
53
+ }
54
+
55
+ /** Luhn checksum — rejects mistyped/invalid card numbers. */
56
+ function luhnValid(digits: string): boolean {
57
+ if (digits.length < 12) return false;
58
+ let sum = 0;
59
+ let double = false;
60
+ for (let i = digits.length - 1; i >= 0; i -= 1) {
61
+ let d = digits.charCodeAt(i) - 48; // '0' → 0
62
+ if (d < 0 || d > 9) return false;
63
+ if (double) {
64
+ d *= 2;
65
+ if (d > 9) d -= 9;
66
+ }
67
+ sum += d;
68
+ double = !double;
69
+ }
70
+ return sum % 10 === 0;
71
+ }
72
+
73
+ /** @returns an error message, or `undefined` when valid. */
74
+ export function validateCardNumber(value: string): string | undefined {
75
+ const digits = onlyDigits(value);
76
+ if (!digits) return "Informe o número do cartão.";
77
+ if (digits.length < 13) return "Número de cartão incompleto.";
78
+ if (!luhnValid(digits)) return "Número de cartão inválido.";
79
+ return undefined;
80
+ }
81
+
82
+ export function validateHolder(value: string): string | undefined {
83
+ return value.trim().length < 2 ? "Informe o nome impresso no cartão." : undefined;
84
+ }
85
+
86
+ export function validateExpiry(value: string, now: Date = new Date()): string | undefined {
87
+ const match = /^(\d{2})\/(\d{2})$/.exec(value.trim());
88
+ if (!match) return "Validade incompleta (MM/AA).";
89
+ const mm = match[1];
90
+ const yy = match[2];
91
+ if (mm === undefined || yy === undefined) return "Validade incompleta (MM/AA).";
92
+ const month = Number(mm);
93
+ if (month < 1 || month > 12) return "Mês inválido.";
94
+ const endOfMonth = new Date(2000 + Number(yy), month, 0, 23, 59, 59, 999);
95
+ return endOfMonth.getTime() < now.getTime() ? "Cartão expirado." : undefined;
96
+ }
97
+
98
+ export function validateCvv(value: string, brand: CardBrand = "Cartão"): string | undefined {
99
+ const digits = onlyDigits(value);
100
+ if (!digits) return "Informe o CVV.";
101
+ const len = cvvLength(brand);
102
+ return digits.length === len ? undefined : `CVV deve ter ${len} dígitos.`;
103
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The shared card-entry surface: format/validate helpers, the form fields, and
3
+ * the browser tokenizer. Moved here from `@12-apps/spa-shared` with the buyer
4
+ * checkout (FUT-564) — the tokenization helpers follow the screens.
5
+ *
6
+ * Two consumers, on purpose. The storefront checkout takes a shopper's card;
7
+ * the admin's provider activation takes the OWNER's card for a R$0,01 proof
8
+ * charge (FUT-463). They must be the same form — a verification that exercised
9
+ * a different path would prove nothing about the path a shopper takes.
10
+ *
11
+ * The form components render through the checkout's slot contract (`ui.tsx`),
12
+ * so they carry no design system of their own: raw MUI by default, the host's
13
+ * primitives when a `CheckoutComponentsProvider` sits above them.
14
+ */
15
+
16
+ export {
17
+ cvvLength,
18
+ detectBrand,
19
+ formatCardNumber,
20
+ formatCvv,
21
+ formatExpiry,
22
+ onlyDigits,
23
+ validateCardNumber,
24
+ validateCvv,
25
+ validateExpiry,
26
+ validateHolder,
27
+ type CardBrand,
28
+ } from "./format";
29
+
30
+ export { formatCpf, validateCpf } from "./cpf";
31
+
32
+ export { CardPayBar, NewCardForm, SavedCardsPicker } from "./fields";
33
+
34
+ export {
35
+ tokenizeCard,
36
+ tokenizeForCheckout,
37
+ tokenizerFor,
38
+ type CardTokenizationConfig,
39
+ type CardTokenizer,
40
+ } from "./tokenize";
41
+
42
+ export { NEW_CARD, type CardDetails, type CardFieldErrors, type CardToken, type SavedCard } from "./types";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Stripe browser-side card tokenization (FUT-698) — the `stripe-pm` scheme.
3
+ *
4
+ * The card is POSTed straight from the browser to Stripe's payment-methods
5
+ * endpoint, authenticated with the store's PUBLISHABLE key, and only the
6
+ * minted `pm_…` id comes back to us — the same PCI boundary the Stone path
7
+ * reaches through Pagar.me's tokens endpoint, and the same wire call
8
+ * Stripe.js itself makes under its iframe. Stripe.js is NOT loaded here on
9
+ * purpose: its public API only tokenizes cards typed into its own Elements
10
+ * iframe, and this checkout's card form is the shared {@link CardDetails}
11
+ * form every provider uses (`fields.tsx`) — the admin's vault flow
12
+ * (`apps/admin/.../stripe-sdk.ts`) keeps Elements where the UI was built
13
+ * around it.
14
+ *
15
+ * The `pm_…` id is what `stripe-charges.ts` sends as `payment_method` when it
16
+ * confirms the PaymentIntent server-side.
17
+ */
18
+
19
+ import { err, ok, type Result } from "../result";
20
+
21
+ import type { CardDetails, CardToken } from "./types";
22
+
23
+ /** Stripe payment-methods endpoint — the publishable key authenticates. */
24
+ const STRIPE_PAYMENT_METHODS_URL = "https://api.stripe.com/v1/payment_methods";
25
+
26
+ /**
27
+ * Mint a Stripe PaymentMethod for a checkout card.
28
+ *
29
+ * `card` has already passed local validation; `pan`, `brand` and `last4` are
30
+ * derived by the caller so every scheme reports the same display metadata.
31
+ * A refusal carries Stripe's own message verbatim — a rejected tokenization
32
+ * is the provider's answer, and it must reach a human.
33
+ */
34
+ export async function tokenizeWithStripe(
35
+ card: CardDetails,
36
+ pan: string,
37
+ publicKey: string,
38
+ brand: string,
39
+ last4: string,
40
+ ): Promise<Result<CardToken>> {
41
+ const match = /^(\d{2})\/(\d{2})$/.exec(card.expiry.trim());
42
+ if (!match) return err("Validade inválida ou expirada.");
43
+
44
+ // Stripe's API speaks form-encoding, not JSON.
45
+ const body = new URLSearchParams({
46
+ type: "card",
47
+ "card[number]": pan,
48
+ "card[exp_month]": String(Number(match[1])),
49
+ "card[exp_year]": `20${match[2]}`,
50
+ "card[cvc]": card.cvv.trim(),
51
+ "billing_details[name]": card.holder.trim(),
52
+ });
53
+
54
+ let response: Response;
55
+ try {
56
+ response = await fetch(STRIPE_PAYMENT_METHODS_URL, {
57
+ method: "POST",
58
+ headers: {
59
+ "Content-Type": "application/x-www-form-urlencoded",
60
+ Authorization: `Bearer ${publicKey}`,
61
+ },
62
+ body: body.toString(),
63
+ });
64
+ } catch {
65
+ return err("Não foi possível contatar o provedor do cartão. Verifique sua conexão.");
66
+ }
67
+
68
+ const parsed = (await response.json().catch(() => null)) as {
69
+ id?: unknown;
70
+ error?: { message?: unknown };
71
+ } | null;
72
+ if (!response.ok || typeof parsed?.id !== "string") {
73
+ const reason =
74
+ typeof parsed?.error?.message === "string" ? parsed.error.message : JSON.stringify(parsed);
75
+ return err(
76
+ `O provedor recusou os dados do cartão (HTTP ${response.status}). ` +
77
+ `Resposta: ${String(reason).slice(0, 300)}`,
78
+ );
79
+ }
80
+ return ok({ token: parsed.id, brand, last4 });
81
+ }