@12-apps/payments-frontend 1.0.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 (34) hide show
  1. package/eslint.config.js +34 -0
  2. package/package.json +63 -0
  3. package/src/__tests__/checkout-confirmation.test.tsx +177 -0
  4. package/src/__tests__/connection-state.test.tsx +84 -0
  5. package/src/__tests__/context.test.tsx +88 -0
  6. package/src/__tests__/controlled-provider.test.tsx +116 -0
  7. package/src/__tests__/credential-confirm.test.tsx +193 -0
  8. package/src/__tests__/initial-provider.test.tsx +81 -0
  9. package/src/__tests__/provider-priority-list.test.tsx +159 -0
  10. package/src/__tests__/provider-status-bar.test.tsx +152 -0
  11. package/src/__tests__/verification-slot.test.tsx +125 -0
  12. package/src/client.ts +200 -0
  13. package/src/components/CheckoutFlow.tsx +169 -0
  14. package/src/components/CheckoutPayment.tsx +379 -0
  15. package/src/components/ConfirmCredentialSave.tsx +106 -0
  16. package/src/components/CredentialFields.tsx +158 -0
  17. package/src/components/CredentialFormAlerts.tsx +144 -0
  18. package/src/components/EnvironmentTabs.tsx +109 -0
  19. package/src/components/PaymentProviderSettings.tsx +267 -0
  20. package/src/components/ProviderConnection.tsx +251 -0
  21. package/src/components/ProviderCredentialForm.tsx +387 -0
  22. package/src/components/ProviderList.tsx +126 -0
  23. package/src/components/ProviderPanel.tsx +300 -0
  24. package/src/components/ProviderPriorityList.tsx +293 -0
  25. package/src/components/ProviderSetupGuide.tsx +263 -0
  26. package/src/components/ProviderStatusBar.tsx +192 -0
  27. package/src/components/SetupGuideSection.tsx +190 -0
  28. package/src/components/checkout-ack.ts +106 -0
  29. package/src/components/connection-state.ts +66 -0
  30. package/src/components/credential-rules.ts +120 -0
  31. package/src/components/rich-text.tsx +27 -0
  32. package/src/components/settings-state.ts +211 -0
  33. package/src/context.tsx +144 -0
  34. package/src/index.ts +69 -0
@@ -0,0 +1,125 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
3
+ import { afterEach, 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
+ * The activation step's slot.
12
+ *
13
+ * "Connected" and "can charge" are different facts, and only the HOST can prove
14
+ * the second — it owns the endpoint that puts a real cent through the store's
15
+ * account. This package's job is narrower and worth pinning: place that step
16
+ * where an owner will actually find it, and tell the host which provider is
17
+ * open and whether it is already live.
18
+ */
19
+
20
+ const PROVIDERS = [
21
+ { name: 'pagbank', displayName: 'PagBank', authMode: 'oauth', credentialSchema: [] },
22
+ ];
23
+
24
+ function viewWith(config: Record<string, unknown> | null): MerchantSettingsView {
25
+ return {
26
+ providers: PROVIDERS,
27
+ configs: config ? [config] : [],
28
+ activeProvider: null,
29
+ } as unknown as MerchantSettingsView;
30
+ }
31
+
32
+ function fakeClient(view: MerchantSettingsView): PaymentsSettingsClient {
33
+ return {
34
+ getSettings: vi.fn().mockResolvedValue(view),
35
+ getSetupGuide: vi.fn().mockResolvedValue(null),
36
+ setEnabled: vi.fn(),
37
+ saveCredentials: vi.fn(),
38
+ } as unknown as PaymentsSettingsClient;
39
+ }
40
+
41
+ afterEach(cleanup);
42
+
43
+ describe('PaymentProviderSettings — the verification slot', () => {
44
+ it('is not rendered while the owner is still on the provider list', async () => {
45
+ const renderVerification = vi.fn(() => <div data-testid="slot" />);
46
+ render(
47
+ <PaymentProviderSettings
48
+ client={fakeClient(viewWith(null))}
49
+ renderVerification={renderVerification}
50
+ />,
51
+ );
52
+
53
+ await screen.findByTestId('payments-provider-picker');
54
+ expect(renderVerification).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it('hands the host the open provider and its connection state', async () => {
58
+ const renderVerification = vi.fn(() => <div data-testid="slot" />);
59
+ render(
60
+ <PaymentProviderSettings
61
+ client={fakeClient(
62
+ viewWith({ provider: 'pagbank', status: 'VERIFIED', enabled: true, environments: {} }),
63
+ )}
64
+ initialProvider="pagbank"
65
+ renderVerification={renderVerification}
66
+ />,
67
+ );
68
+
69
+ await screen.findByTestId('slot');
70
+ expect(renderVerification).toHaveBeenCalledWith(
71
+ expect.objectContaining({ provider: 'pagbank', connected: true }),
72
+ );
73
+ /**
74
+ * `enabled` is deliberately withheld. The step used it to render a
75
+ * standing "Provedor ativo" banner, which went on reassuring a store whose
76
+ * every charge was refused — the banner and the refusal were on screen
77
+ * together. The step reports what it observes, nothing else.
78
+ */
79
+ expect(renderVerification).not.toHaveBeenCalledWith(
80
+ expect.objectContaining({ enabled: expect.anything() }),
81
+ );
82
+ });
83
+
84
+ /**
85
+ * A provider with no stored row yet still renders the slot — the host decides
86
+ * to show nothing. Reporting `connected: false` is what lets it.
87
+ */
88
+ it('reports a provider with no stored connection as not connected', async () => {
89
+ const renderVerification = vi.fn(() => <div data-testid="slot" />);
90
+ render(
91
+ <PaymentProviderSettings
92
+ client={fakeClient(viewWith(null))}
93
+ initialProvider="pagbank"
94
+ renderVerification={renderVerification}
95
+ />,
96
+ );
97
+
98
+ await screen.findByTestId('slot');
99
+ expect(renderVerification).toHaveBeenCalledWith(
100
+ expect.objectContaining({ connected: false }),
101
+ );
102
+ });
103
+
104
+ /**
105
+ * Placement, not just presence: the step must sit OUTSIDE the manual
106
+ * credentials disclosure. An owner who connected by OAuth is told no key
107
+ * needs copying, so they have no reason to open it — and the step that
108
+ * actually turns the store on would be hidden in there.
109
+ */
110
+ it('places the step outside the manual-credentials disclosure', async () => {
111
+ render(
112
+ <PaymentProviderSettings
113
+ client={fakeClient(
114
+ viewWith({ provider: 'pagbank', status: 'VERIFIED', enabled: false, environments: {} }),
115
+ )}
116
+ initialProvider="pagbank"
117
+ renderVerification={() => <div data-testid="slot" />}
118
+ />,
119
+ );
120
+
121
+ const slot = await screen.findByTestId('slot');
122
+ await waitFor(() => expect(screen.queryByTestId('payments-manual-fallback')).toBeNull());
123
+ expect(slot.closest('[data-testid="payments-manual-fallback"]')).toBeNull();
124
+ });
125
+ });
package/src/client.ts ADDED
@@ -0,0 +1,200 @@
1
+ import type {
2
+ ChargeRequestDraft,
3
+ ChargeStatus,
4
+ ClientChargeView,
5
+ ClientTokenization,
6
+ MaskedProviderConfig,
7
+ MerchantFailoverPolicy,
8
+ MerchantSettingsView,
9
+ PaymentEnvironment,
10
+ ProviderName,
11
+ ProviderSetupGuide,
12
+ SaveCredentialsInput,
13
+ VerifiedProviderConfig,
14
+ } from '@12-apps/payments-backend';
15
+
16
+ /**
17
+ * Browser-side API clients. They talk to the HOST's endpoints (which mount
18
+ * `@12-apps/payments-backend`'s HTTP handlers) — never to a provider directly.
19
+ * Endpoint paths are injected so every app can mount its routes wherever it
20
+ * likes; the shapes are fixed so the components work identically everywhere.
21
+ *
22
+ * Only TYPES are imported from the backend package — no server code ever
23
+ * enters a browser bundle.
24
+ */
25
+
26
+ /** Charge states the frontend should stop polling on. */
27
+ export function isSettled(status: ChargeStatus): boolean {
28
+ return status !== 'PENDING' && status !== 'AUTHORIZED';
29
+ }
30
+
31
+ /**
32
+ * What the browser sends to create a charge. Card data is TOKENS ONLY, and
33
+ * there is deliberately no `amount`/`reference`: the SERVER resolves what is
34
+ * owed and for which order (see the backend's `resolveChargeRequest`), so a
35
+ * tampered client cannot underpay or point a payment at someone else's order.
36
+ */
37
+ export type ClientChargeRequest = ChargeRequestDraft;
38
+
39
+ /** Client-safe tokenization config (see the backend's `clientConfig`). */
40
+ export interface ClientPaymentsConfig {
41
+ provider: ProviderName;
42
+ tokenization: ClientTokenization;
43
+ publicKey?: string;
44
+ }
45
+
46
+ export class PaymentsClientError extends Error {
47
+ constructor(
48
+ readonly status: number,
49
+ message: string,
50
+ ) {
51
+ super(message);
52
+ this.name = 'PaymentsClientError';
53
+ }
54
+ }
55
+
56
+ export interface PaymentsClientOptions {
57
+ /** e.g. `/api/payments` — host route prefix wrapping the gateway. */
58
+ baseUrl: string;
59
+ /** Injectable for tests; defaults to the global `fetch`. */
60
+ fetchImpl?: typeof fetch;
61
+ }
62
+
63
+ function makeRequest(options: PaymentsClientOptions) {
64
+ const fetchImpl = options.fetchImpl ?? fetch;
65
+ return async function request<T>(path: string, init?: RequestInit): Promise<T> {
66
+ const res = await fetchImpl(`${options.baseUrl}${path}`, {
67
+ headers: { 'content-type': 'application/json' },
68
+ ...init,
69
+ });
70
+ if (!res.ok) {
71
+ const body = await res.text().catch(() => '');
72
+ throw new PaymentsClientError(res.status, body || `Payments request failed (${res.status})`);
73
+ }
74
+ return (await res.json()) as T;
75
+ };
76
+ }
77
+
78
+ /** Checkout-surface client (buyer-facing). */
79
+ export interface PaymentsClient {
80
+ getConfig(): Promise<ClientPaymentsConfig | null>;
81
+ createCharge(request: ClientChargeRequest): Promise<ClientChargeView>;
82
+ getCharge(provider: ProviderName, providerChargeId: string): Promise<ClientChargeView>;
83
+ }
84
+
85
+ export function createPaymentsClient(options: PaymentsClientOptions): PaymentsClient {
86
+ const request = makeRequest(options);
87
+ return {
88
+ getConfig: () => request<ClientPaymentsConfig | null>('/config'),
89
+ createCharge: (body) =>
90
+ request<ClientChargeView>('/charges', { method: 'POST', body: JSON.stringify(body) }),
91
+ getCharge: (provider, providerChargeId) =>
92
+ request<ClientChargeView>(
93
+ `/charges/${encodeURIComponent(provider)}/${encodeURIComponent(providerChargeId)}`,
94
+ ),
95
+ };
96
+ }
97
+
98
+ /** Settings-surface client (merchant-admin-facing). */
99
+ export interface PaymentsSettingsClient {
100
+ /**
101
+ * The host prefix this client was built on, echoed back.
102
+ *
103
+ * Exposed because it is the only MERCHANT-SCOPED identifier the components
104
+ * have: the package is deliberately ignorant of tenants, and a host mounts
105
+ * one client per store (`/api/admin/<slug>/payments`). Anything the browser
106
+ * remembers per store — a setup step the owner has ticked off — has to be
107
+ * keyed on something, and this is it. Read-only; nothing derives a request
108
+ * path from it.
109
+ */
110
+ readonly baseUrl: string;
111
+ getSettings(): Promise<MerchantSettingsView>;
112
+ saveCredentials(provider: ProviderName, input: SaveCredentialsInput): Promise<MaskedProviderConfig>;
113
+ setEnabled(provider: ProviderName, enabled: boolean): Promise<MaskedProviderConfig>;
114
+ /**
115
+ * Replace the whole failover chain in priority order. Sends the COMPLETE
116
+ * enabled set, not a delta — a reorder is one intent, and splitting it into
117
+ * per-provider writes is what allows a half-applied order.
118
+ */
119
+ setPriorities(ordered: readonly ProviderName[]): Promise<MerchantSettingsView>;
120
+ /** Whether a declined card may be retried on the next acquirer. */
121
+ setFailoverPolicy(policy: MerchantFailoverPolicy): Promise<MerchantSettingsView>;
122
+ /**
123
+ * Probe the stored credentials for `environment` (default: the active one).
124
+ *
125
+ * The reply carries `probe` because the stored `status` answers only for the
126
+ * ACTIVE environment — testing the other tab reports its result without
127
+ * overwriting a verdict that describes different credentials.
128
+ */
129
+ verify(
130
+ provider: ProviderName,
131
+ environment?: PaymentEnvironment,
132
+ ): Promise<VerifiedProviderConfig>;
133
+ /** Null when the provider ships no walkthrough (endpoint 404s). */
134
+ getSetupGuide(provider: ProviderName): Promise<ProviderSetupGuide | null>;
135
+ /**
136
+ * Start an OAuth connect. `state` is minted and stored SERVER-side by the
137
+ * host against the admin session; the browser only carries it onward.
138
+ */
139
+ beginOAuth(
140
+ provider: ProviderName,
141
+ body: { state: string; redirectUri: string; environment?: 'SANDBOX' | 'PRODUCTION' },
142
+ ): Promise<{ url: string; state: string }>;
143
+ disconnectOAuth(provider: ProviderName): Promise<{ disconnected: boolean }>;
144
+ }
145
+
146
+ export function createPaymentsSettingsClient(
147
+ options: PaymentsClientOptions,
148
+ ): PaymentsSettingsClient {
149
+ const request = makeRequest(options);
150
+ const providerPath = (provider: ProviderName, suffix = ''): string =>
151
+ `/settings/providers/${encodeURIComponent(provider)}${suffix}`;
152
+ return {
153
+ baseUrl: options.baseUrl,
154
+ getSettings: () => request<MerchantSettingsView>('/settings'),
155
+ saveCredentials: (provider, input) =>
156
+ request<MaskedProviderConfig>(providerPath(provider), {
157
+ method: 'PUT',
158
+ body: JSON.stringify(input),
159
+ }),
160
+ setEnabled: (provider, enabled) =>
161
+ request<MaskedProviderConfig>(providerPath(provider, '/enabled'), {
162
+ method: 'PUT',
163
+ body: JSON.stringify({ enabled }),
164
+ }),
165
+ setFailoverPolicy: (policy) =>
166
+ request<MerchantSettingsView>('/settings/failover-policy', {
167
+ method: 'PUT',
168
+ body: JSON.stringify({ policy }),
169
+ }),
170
+ setPriorities: (ordered) =>
171
+ request<MerchantSettingsView>('/settings/priorities', {
172
+ method: 'PUT',
173
+ body: JSON.stringify({ providers: ordered }),
174
+ }),
175
+ verify: (provider, environment) =>
176
+ request<VerifiedProviderConfig>(
177
+ providerPath(provider, environment ? `/verify?environment=${environment}` : '/verify'),
178
+ { method: 'POST' },
179
+ ),
180
+ // Deliberately NOT under `/providers/...`: the guide is READ-ONLY public
181
+ // content, while everything under `/providers` writes or tests a
182
+ // credential. Hosts gate the two differently (this repo's agent surface
183
+ // exposes the guide and forbids the writes), which a shared prefix would
184
+ // make impossible to express.
185
+ getSetupGuide: (provider) =>
186
+ request<ProviderSetupGuide>(`/settings/guides/${encodeURIComponent(provider)}`).catch((err: unknown) => {
187
+ if (err instanceof PaymentsClientError && err.status === 404) return null;
188
+ throw err;
189
+ }),
190
+ beginOAuth: (provider, body) =>
191
+ request<{ url: string; state: string }>(providerPath(provider, '/oauth/begin'), {
192
+ method: 'POST',
193
+ body: JSON.stringify(body),
194
+ }),
195
+ disconnectOAuth: (provider) =>
196
+ request<{ disconnected: boolean }>(providerPath(provider, '/oauth/disconnect'), {
197
+ method: 'POST',
198
+ }),
199
+ };
200
+ }
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Alert,
5
+ Button,
6
+ Checkbox,
7
+ FormControlLabel,
8
+ Stack,
9
+ Step,
10
+ StepLabel,
11
+ Stepper,
12
+ TextField,
13
+ Typography,
14
+ } from '@mui/material';
15
+ import { useState } from 'react';
16
+
17
+ import type { ClientChargeView, CustomerInfo, Money } from '@12-apps/payments-backend';
18
+
19
+ import { CheckoutPayment, type CheckoutPaymentProps } from './CheckoutPayment';
20
+
21
+ /**
22
+ * The full plug-and-play checkout: the three-step flow of the buyer-facing
23
+ * screens — Dados (CPF required; name/email/phone optional, "save my data")
24
+ * → Pagamento (method cards, saved cards, pay button — `CheckoutPayment`)
25
+ * → Confirmação. The host supplies cart totals, prefilled customer data,
26
+ * saved cards, and reacts to `onCustomerConfirmed` / `onPaid`.
27
+ */
28
+ export interface CheckoutFlowProps
29
+ extends Omit<CheckoutPaymentProps, 'customer' | 'onPaid'> {
30
+ initialCustomer?: Partial<CustomerInfo>;
31
+ /** Default state of the "save my data for next time" checkbox. */
32
+ saveDataDefault?: boolean;
33
+ /** Fired when the buyer completes step 1 (persist profile if saveData). */
34
+ onCustomerConfirmed?: (customer: CustomerInfo, saveData: boolean) => void;
35
+ onPaid?: (charge: ClientChargeView) => void;
36
+ /** e.g. "1 item" — shown next to the total in the footer. */
37
+ itemsLabel?: string;
38
+ }
39
+
40
+ const STEPS = ['Dados', 'Pagamento', 'Confirmação'];
41
+
42
+ /** CPF: 11 digits (checksum stays server/provider-side; this is UX-level). */
43
+ function cpfDigits(value: string): string {
44
+ return value.replace(/\D/g, '');
45
+ }
46
+
47
+ interface CustomerStepProps {
48
+ amount: Money;
49
+ itemsLabel?: string;
50
+ initial: Partial<CustomerInfo>;
51
+ saveDataDefault: boolean;
52
+ onContinue: (customer: CustomerInfo, saveData: boolean) => void;
53
+ }
54
+
55
+ function CustomerStep({ amount, itemsLabel, initial, saveDataDefault, onContinue }: CustomerStepProps) {
56
+ const [taxId, setTaxId] = useState(initial.taxId ?? '');
57
+ const [name, setName] = useState(initial.name ?? '');
58
+ const [email, setEmail] = useState(initial.email ?? '');
59
+ const [phone, setPhone] = useState(initial.phone ?? '');
60
+ const [saveData, setSaveData] = useState(saveDataDefault);
61
+ const [touched, setTouched] = useState(false);
62
+ const cpfInvalid = cpfDigits(taxId).length !== 11;
63
+
64
+ return (
65
+ <Stack spacing={2}>
66
+ <Typography variant="body2" color="text.secondary">
67
+ Informe seu CPF (obrigatório para o pagamento). Nome, e-mail e telefone são opcionais —
68
+ usados apenas para o comprovante.
69
+ </Typography>
70
+ <TextField
71
+ size="small"
72
+ label="CPF"
73
+ required
74
+ value={taxId}
75
+ error={touched && cpfInvalid}
76
+ helperText={touched && cpfInvalid ? 'Informe um CPF com 11 dígitos.' : undefined}
77
+ onBlur={() => setTouched(true)}
78
+ onChange={(e) => setTaxId(e.target.value)}
79
+ />
80
+ <TextField size="small" label="Nome" value={name} onChange={(e) => setName(e.target.value)} />
81
+ <TextField size="small" label="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} />
82
+ <TextField size="small" label="Telefone" value={phone} onChange={(e) => setPhone(e.target.value)} />
83
+ <FormControlLabel
84
+ control={<Checkbox checked={saveData} onChange={(_, v) => setSaveData(v)} />}
85
+ label="Salvar meus dados para a próxima compra"
86
+ />
87
+ <Stack direction="row" spacing={2} alignItems="center">
88
+ <Typography variant="body2" color="text.secondary">
89
+ {itemsLabel ? `Total · ${itemsLabel} — ` : 'Total: '}
90
+ {(amount.amountCents / 100).toLocaleString('pt-BR', { style: 'currency', currency: amount.currency })}
91
+ </Typography>
92
+ <Button
93
+ variant="contained"
94
+ fullWidth
95
+ disabled={cpfInvalid}
96
+ onClick={() => onContinue({ name, email, taxId: cpfDigits(taxId), phone }, saveData)}
97
+ >
98
+ Continuar
99
+ </Button>
100
+ </Stack>
101
+ <Typography variant="caption" color="text.secondary" textAlign="right">
102
+ 🔒 Pagamento seguro
103
+ </Typography>
104
+ </Stack>
105
+ );
106
+ }
107
+
108
+ export function CheckoutFlow(props: CheckoutFlowProps) {
109
+ const {
110
+ initialCustomer = {},
111
+ saveDataDefault = true,
112
+ onCustomerConfirmed,
113
+ onPaid,
114
+ itemsLabel,
115
+ ...payment
116
+ } = props;
117
+ const [step, setStep] = useState(0);
118
+ const [customer, setCustomer] = useState<CustomerInfo | null>(null);
119
+ const [paid, setPaid] = useState<ClientChargeView | null>(null);
120
+
121
+ return (
122
+ <Stack spacing={3}>
123
+ <Stepper activeStep={step} alternativeLabel>
124
+ {STEPS.map((label) => (
125
+ <Step key={label}>
126
+ <StepLabel>{label}</StepLabel>
127
+ </Step>
128
+ ))}
129
+ </Stepper>
130
+
131
+ {step === 0 ? (
132
+ <CustomerStep
133
+ amount={payment.amount}
134
+ itemsLabel={itemsLabel}
135
+ initial={initialCustomer}
136
+ saveDataDefault={saveDataDefault}
137
+ onContinue={(confirmed, saveData) => {
138
+ setCustomer(confirmed);
139
+ onCustomerConfirmed?.(confirmed, saveData);
140
+ setStep(1);
141
+ }}
142
+ />
143
+ ) : null}
144
+
145
+ {step === 1 && customer ? (
146
+ <Stack spacing={1}>
147
+ <Button size="small" sx={{ alignSelf: 'flex-start' }} onClick={() => setStep(0)}>
148
+ ← Voltar
149
+ </Button>
150
+ <CheckoutPayment
151
+ {...payment}
152
+ customer={customer}
153
+ onPaid={(charge) => {
154
+ setPaid(charge);
155
+ setStep(2);
156
+ onPaid?.(charge);
157
+ }}
158
+ />
159
+ </Stack>
160
+ ) : null}
161
+
162
+ {step === 2 && paid ? (
163
+ <Alert severity="success">
164
+ Pagamento confirmado — {paid.provider} · {paid.providerChargeId}
165
+ </Alert>
166
+ ) : null}
167
+ </Stack>
168
+ );
169
+ }