@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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.
|
|
29
|
-
"@12-apps/payments-backend": "^1.
|
|
30
|
-
"@12-apps/typescript-config": "^1.
|
|
31
|
+
"@12-apps/eslint-config": "^1.3.0",
|
|
32
|
+
"@12-apps/payments-backend": "^1.2.0",
|
|
33
|
+
"@12-apps/typescript-config": "^1.3.0",
|
|
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
|
+
});
|
package/src/card/cpf.ts
ADDED
|
@@ -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
|
+
}
|