@12-apps/payments-frontend 3.2.4 → 3.3.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 +2 -2
- package/src/activation/charge-copy.ts +23 -0
- package/src/activation/use-activation-charge.ts +267 -0
- package/src/index.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"storybook:build": "storybook build"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@12-apps/payments-backend": "^4.
|
|
20
|
+
"@12-apps/payments-backend": "^4.12.0",
|
|
21
21
|
"react-qr-code": "^2.2.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three sentences the card activation charge has to put on screen itself
|
|
3
|
+
* (FUT-763).
|
|
4
|
+
*
|
|
5
|
+
* Same rule as `RedirectActivationCopy`, for the same reason: these states are
|
|
6
|
+
* reached inside the flow and carry no message from the provider, so something
|
|
7
|
+
* has to be shown — and a fallback string compiled into the package is how one
|
|
8
|
+
* product's voice reaches every adopter. No defaults, and the field is required.
|
|
9
|
+
*/
|
|
10
|
+
export interface ActivationChargeCopy {
|
|
11
|
+
/**
|
|
12
|
+
* No tokenizer is registered for this provider, so nothing can be encrypted
|
|
13
|
+
* and there is no charge to make.
|
|
14
|
+
*
|
|
15
|
+
* `{provider}` is substituted with the provider's name — the one word that
|
|
16
|
+
* makes the sentence actionable on a screen listing several.
|
|
17
|
+
*/
|
|
18
|
+
noTokenizer: string;
|
|
19
|
+
/** The server refused the charge and sent no reason of its own. */
|
|
20
|
+
chargeFailed: string;
|
|
21
|
+
/** The request never got out — the browser's own fetch threw. */
|
|
22
|
+
unreachable: string;
|
|
23
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
detectBrand,
|
|
8
|
+
onlyDigits,
|
|
9
|
+
tokenizeCard,
|
|
10
|
+
tokenizerFor,
|
|
11
|
+
validateCardNumber,
|
|
12
|
+
validateCpf,
|
|
13
|
+
validateCvv,
|
|
14
|
+
validateExpiry,
|
|
15
|
+
validateHolder,
|
|
16
|
+
type CardDetails,
|
|
17
|
+
type CardFieldErrors,
|
|
18
|
+
} from '../card';
|
|
19
|
+
|
|
20
|
+
import type { ActivationChargeCopy } from './charge-copy';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The activation charge for a provider whose payer pays HERE (FUT-463, moved
|
|
24
|
+
* into the package by FUT-763).
|
|
25
|
+
*
|
|
26
|
+
* A connection is not a capability. An OAuth grant completing tells you the
|
|
27
|
+
* owner authorized us; it does not tell you the account can take money, and the
|
|
28
|
+
* gap between those two is where a store ships broken — connected, switched on,
|
|
29
|
+
* and every real shopper met an access error because the integration had never
|
|
30
|
+
* been homologated.
|
|
31
|
+
*
|
|
32
|
+
* So the owner puts their own card through the SAME path a shopper takes — same
|
|
33
|
+
* fields, same validation, same browser-side encryption — for one cent,
|
|
34
|
+
* refunded immediately. Whatever would break for a buyer breaks here, in front
|
|
35
|
+
* of the person who can fix it.
|
|
36
|
+
*
|
|
37
|
+
* The sibling of `useRedirectActivation`, for the other half of the same step:
|
|
38
|
+
* that one is for a provider whose payer leaves for its own page. Both prove
|
|
39
|
+
* the same fact and both leave the SCREEN to the host.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const EMPTY_CARD: CardDetails = { number: '', holder: '', expiry: '', cvv: '' };
|
|
43
|
+
|
|
44
|
+
export type ActivationChargeState =
|
|
45
|
+
| { kind: 'idle' }
|
|
46
|
+
| { kind: 'submitting' }
|
|
47
|
+
/**
|
|
48
|
+
* The charge did not go through — the provider's reason, verbatim enough to
|
|
49
|
+
* act on. `providerMessage` carries the provider's RAW refusal when `reason`
|
|
50
|
+
* is a rewording of it, so the screen can show both.
|
|
51
|
+
*/
|
|
52
|
+
| { kind: 'failed'; reason: string; providerMessage?: string }
|
|
53
|
+
/** Money moved. The server has already enabled the provider. */
|
|
54
|
+
| { kind: 'passed'; refunded: boolean };
|
|
55
|
+
|
|
56
|
+
export interface ActivationChargeOptions {
|
|
57
|
+
/**
|
|
58
|
+
* The host's verify-charge endpoint for this provider.
|
|
59
|
+
*
|
|
60
|
+
* `GET` answers the store's card public key; `POST` takes the tokenized card
|
|
61
|
+
* and makes the charge. A whole URL, not the parts of one — the route shape
|
|
62
|
+
* belongs to the host.
|
|
63
|
+
*/
|
|
64
|
+
verifyChargeUrl: string;
|
|
65
|
+
/**
|
|
66
|
+
* Which provider is being activated.
|
|
67
|
+
*
|
|
68
|
+
* Named rather than derived from a capability: two providers can both declare
|
|
69
|
+
* `tokenization: 'PUBLIC_KEY'` while speaking different protocols, so
|
|
70
|
+
* choosing by capability would silently mint one vendor's blob with another's
|
|
71
|
+
* key and report the second's rejection as though the card were bad.
|
|
72
|
+
*/
|
|
73
|
+
provider: string;
|
|
74
|
+
/** The signed-in owner's e-mail — the charge's customer record. */
|
|
75
|
+
email: string;
|
|
76
|
+
/** The charge landed; the caller refreshes so the provider shows as active. */
|
|
77
|
+
onVerified: () => void;
|
|
78
|
+
copy: ActivationChargeCopy;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ActivationCharge {
|
|
82
|
+
card: CardDetails;
|
|
83
|
+
setCard: React.Dispatch<React.SetStateAction<CardDetails>>;
|
|
84
|
+
fieldErrors: CardFieldErrors;
|
|
85
|
+
setFieldErrors: React.Dispatch<React.SetStateAction<CardFieldErrors>>;
|
|
86
|
+
cpf: string;
|
|
87
|
+
setCpf: (value: string) => void;
|
|
88
|
+
cpfError: string | undefined;
|
|
89
|
+
state: ActivationChargeState;
|
|
90
|
+
submit: () => Promise<void>;
|
|
91
|
+
/** Back to the form from a settled state, to try another card. */
|
|
92
|
+
reset: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The store's own card public key, fetched through the VERIFICATION endpoint.
|
|
97
|
+
*
|
|
98
|
+
* Not the checkout one: that reads credentials through the enabled gate, and a
|
|
99
|
+
* provider being verified is by definition still disabled.
|
|
100
|
+
*/
|
|
101
|
+
function usePublicKey(verifyChargeUrl: string): string | null {
|
|
102
|
+
const [publicKey, setPublicKey] = useState<string | null>(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const alive = { current: true };
|
|
106
|
+
void fetch(verifyChargeUrl)
|
|
107
|
+
.then((res) => (res.ok ? (res.json() as Promise<{ publicKey?: string | null }>) : null))
|
|
108
|
+
.then((body) => {
|
|
109
|
+
if (alive.current && body?.publicKey) setPublicKey(body.publicKey);
|
|
110
|
+
})
|
|
111
|
+
.catch(() => undefined);
|
|
112
|
+
return () => {
|
|
113
|
+
alive.current = false;
|
|
114
|
+
};
|
|
115
|
+
}, [verifyChargeUrl]);
|
|
116
|
+
|
|
117
|
+
return publicKey;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Local validation — nothing reaches the provider until the card is well-formed. */
|
|
121
|
+
function validateAll(card: CardDetails, cpf: string) {
|
|
122
|
+
const brand = detectBrand(onlyDigits(card.number));
|
|
123
|
+
return {
|
|
124
|
+
fieldErrors: {
|
|
125
|
+
number: validateCardNumber(card.number),
|
|
126
|
+
holder: validateHolder(card.holder),
|
|
127
|
+
expiry: validateExpiry(card.expiry),
|
|
128
|
+
cvv: validateCvv(card.cvv, brand),
|
|
129
|
+
} satisfies CardFieldErrors,
|
|
130
|
+
cpfError: validateCpf(cpf),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface ChargeRequest {
|
|
135
|
+
verifyChargeUrl: string;
|
|
136
|
+
provider: string;
|
|
137
|
+
card: CardDetails;
|
|
138
|
+
cpf: string;
|
|
139
|
+
publicKey: string | null;
|
|
140
|
+
email: string;
|
|
141
|
+
copy: ActivationChargeCopy;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Tokenize the owner's card and ask the server to charge the cent.
|
|
146
|
+
*
|
|
147
|
+
* Strict tokenization on purpose: with no public key there is no encryption, so
|
|
148
|
+
* there would be nothing for the provider to accept or refuse — and a mock
|
|
149
|
+
* token that "passed" would switch on a store that cannot charge.
|
|
150
|
+
*/
|
|
151
|
+
async function runCharge(request: ChargeRequest): Promise<ActivationChargeState> {
|
|
152
|
+
const tokenizer = tokenizerFor(request.provider);
|
|
153
|
+
if (!tokenizer) {
|
|
154
|
+
return {
|
|
155
|
+
kind: 'failed',
|
|
156
|
+
reason: request.copy.noTokenizer.replace('{provider}', request.provider),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tokenized = await tokenizeCard(request.card, request.publicKey, tokenizer);
|
|
161
|
+
if (!tokenized.ok) return { kind: 'failed', reason: tokenized.error };
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(request.verifyChargeUrl, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
token: tokenized.data.token,
|
|
169
|
+
taxId: onlyDigits(request.cpf),
|
|
170
|
+
holderName: request.card.holder.trim(),
|
|
171
|
+
email: request.email,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
const body = (await response.json().catch(() => null)) as
|
|
175
|
+
| { ok?: boolean; refunded?: boolean; reason?: string; providerMessage?: string }
|
|
176
|
+
| null;
|
|
177
|
+
|
|
178
|
+
if (!body?.ok) {
|
|
179
|
+
return {
|
|
180
|
+
kind: 'failed',
|
|
181
|
+
reason: body?.reason ?? request.copy.chargeFailed,
|
|
182
|
+
providerMessage: body?.providerMessage,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { kind: 'passed', refunded: body.refunded === true };
|
|
186
|
+
} catch {
|
|
187
|
+
return { kind: 'failed', reason: request.copy.unreachable };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The typed-in card + CPF and their validation messages. */
|
|
192
|
+
function useCardForm() {
|
|
193
|
+
const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
|
|
194
|
+
const [fieldErrors, setFieldErrors] = useState<CardFieldErrors>({});
|
|
195
|
+
const [cpf, setCpf] = useState('');
|
|
196
|
+
const [cpfError, setCpfError] = useState<string | undefined>(undefined);
|
|
197
|
+
|
|
198
|
+
const clear = useCallback(() => {
|
|
199
|
+
setCard(EMPTY_CARD);
|
|
200
|
+
setCpf('');
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
return { card, setCard, fieldErrors, setFieldErrors, cpf, setCpf, cpfError, setCpfError, clear };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function useActivationCharge(options: ActivationChargeOptions): ActivationCharge {
|
|
207
|
+
const { verifyChargeUrl, provider, email, onVerified, copy } = options;
|
|
208
|
+
const publicKey = usePublicKey(verifyChargeUrl);
|
|
209
|
+
const form = useCardForm();
|
|
210
|
+
const [state, setState] = useState<ActivationChargeState>({ kind: 'idle' });
|
|
211
|
+
const { card, cpf, setFieldErrors, setCpfError, clear } = form;
|
|
212
|
+
|
|
213
|
+
const submit = useCallback(async () => {
|
|
214
|
+
const validation = validateAll(card, cpf);
|
|
215
|
+
setFieldErrors(validation.fieldErrors);
|
|
216
|
+
setCpfError(validation.cpfError);
|
|
217
|
+
if (Object.values(validation.fieldErrors).some(Boolean) || validation.cpfError) return;
|
|
218
|
+
|
|
219
|
+
setState({ kind: 'submitting' });
|
|
220
|
+
const next = await runCharge({
|
|
221
|
+
verifyChargeUrl,
|
|
222
|
+
provider,
|
|
223
|
+
card,
|
|
224
|
+
cpf,
|
|
225
|
+
publicKey,
|
|
226
|
+
email,
|
|
227
|
+
copy,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// The card is cleared only once it has served its purpose; a failure leaves
|
|
231
|
+
// it typed in so the owner can fix one field rather than start over.
|
|
232
|
+
if (next.kind === 'passed') clear();
|
|
233
|
+
setState(next);
|
|
234
|
+
if (next.kind === 'passed') onVerified();
|
|
235
|
+
}, [
|
|
236
|
+
card,
|
|
237
|
+
cpf,
|
|
238
|
+
publicKey,
|
|
239
|
+
verifyChargeUrl,
|
|
240
|
+
provider,
|
|
241
|
+
email,
|
|
242
|
+
copy,
|
|
243
|
+
onVerified,
|
|
244
|
+
setFieldErrors,
|
|
245
|
+
setCpfError,
|
|
246
|
+
clear,
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
const reset = useCallback(() => {
|
|
250
|
+
setState({ kind: 'idle' });
|
|
251
|
+
setFieldErrors({});
|
|
252
|
+
setCpfError(undefined);
|
|
253
|
+
}, [setFieldErrors, setCpfError]);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
card: form.card,
|
|
257
|
+
setCard: form.setCard,
|
|
258
|
+
fieldErrors: form.fieldErrors,
|
|
259
|
+
setFieldErrors: form.setFieldErrors,
|
|
260
|
+
cpf: form.cpf,
|
|
261
|
+
setCpf: form.setCpf,
|
|
262
|
+
cpfError: form.cpfError,
|
|
263
|
+
state,
|
|
264
|
+
submit,
|
|
265
|
+
reset,
|
|
266
|
+
};
|
|
267
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -242,3 +242,23 @@ export {
|
|
|
242
242
|
* taking a direct dependency on the backend package.
|
|
243
243
|
*/
|
|
244
244
|
export type { PaymentEnvironment } from '@12-apps/payments-backend';
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// The ACTIVATION CHARGE (FUT-463, packaged by FUT-763) — proving a connection
|
|
248
|
+
// can charge, for a provider whose payer pays HERE.
|
|
249
|
+
//
|
|
250
|
+
// A connection is not a capability: a completed grant says the owner authorized
|
|
251
|
+
// us, not that the account can take money. The owner's own card goes through
|
|
252
|
+
// the SAME path a shopper's does — same fields, same validation, same
|
|
253
|
+
// browser-side encryption — for one cent, refunded immediately.
|
|
254
|
+
//
|
|
255
|
+
// The sibling of `useRedirectActivation` for the other half of the same step.
|
|
256
|
+
// As there, the SCREEN stays the host's.
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
export {
|
|
259
|
+
useActivationCharge,
|
|
260
|
+
type ActivationCharge,
|
|
261
|
+
type ActivationChargeOptions,
|
|
262
|
+
type ActivationChargeState,
|
|
263
|
+
} from './activation/use-activation-charge';
|
|
264
|
+
export { type ActivationChargeCopy } from './activation/charge-copy';
|