@12-apps/payments-frontend 3.3.0 → 3.4.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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.13.0",
|
|
21
21
|
"react-qr-code": "^2.2.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
@@ -30,9 +30,9 @@ import type { ActivationChargeCopy } from './charge-copy';
|
|
|
30
30
|
* been homologated.
|
|
31
31
|
*
|
|
32
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
|
|
34
|
-
*
|
|
35
|
-
* of the person who can fix it.
|
|
33
|
+
* fields, same validation, same browser-side encryption — for the smallest
|
|
34
|
+
* amount that provider will actually accept, refunded immediately. Whatever
|
|
35
|
+
* would break for a buyer breaks here, in front of the person who can fix it.
|
|
36
36
|
*
|
|
37
37
|
* The sibling of `useRedirectActivation`, for the other half of the same step:
|
|
38
38
|
* that one is for a provider whose payer leaves for its own page. Both prove
|
|
@@ -86,6 +86,20 @@ export interface ActivationCharge {
|
|
|
86
86
|
cpf: string;
|
|
87
87
|
setCpf: (value: string) => void;
|
|
88
88
|
cpfError: string | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* What this charge will COST, in cents — `null` until the endpoint answers.
|
|
91
|
+
*
|
|
92
|
+
* Not always one cent, which is the whole reason it is asked for rather than
|
|
93
|
+
* assumed: at least one provider refuses a one-cent total outright, so its
|
|
94
|
+
* verification charge is worth more, and the minimum is a fact about that
|
|
95
|
+
* provider's API rather than a number this package may pick.
|
|
96
|
+
*
|
|
97
|
+
* `null` rather than a fallback for the same reason the copy has no
|
|
98
|
+
* defaults: what to put on a button before the truth arrives is the host's
|
|
99
|
+
* sentence to write, and a package guessing here would have the screen
|
|
100
|
+
* promise one amount and charge another.
|
|
101
|
+
*/
|
|
102
|
+
amountCents: number | null;
|
|
89
103
|
state: ActivationChargeState;
|
|
90
104
|
submit: () => Promise<void>;
|
|
91
105
|
/** Back to the form from a settled state, to try another card. */
|
|
@@ -93,20 +107,54 @@ export interface ActivationCharge {
|
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
/**
|
|
96
|
-
*
|
|
110
|
+
* What the verification endpoint says about the charge BEFORE it is made.
|
|
97
111
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
112
|
+
* Two facts, one request, because the endpoint answers both in one body and
|
|
113
|
+
* they are needed on the same screen at the same moment. They were two asks
|
|
114
|
+
* for the same URL — the key read here, the amount read by the host — which is
|
|
115
|
+
* one request per render pass more than the truth costs, and two places for
|
|
116
|
+
* the answer to be interpreted differently.
|
|
117
|
+
*
|
|
118
|
+
* The endpoint is the VERIFICATION one, not checkout's: that reads credentials
|
|
119
|
+
* through the enabled gate, and a provider being verified is by definition
|
|
120
|
+
* still disabled.
|
|
100
121
|
*/
|
|
101
|
-
|
|
102
|
-
|
|
122
|
+
interface ActivationProbe {
|
|
123
|
+
publicKey: string | null;
|
|
124
|
+
amountCents: number | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Before the endpoint has answered, both facts are simply unknown. */
|
|
128
|
+
const UNKNOWN_PROBE: ActivationProbe = { publicKey: null, amountCents: null };
|
|
129
|
+
|
|
130
|
+
/** The endpoint's `GET` body — every field optional; a host may answer neither. */
|
|
131
|
+
interface ProbeBody {
|
|
132
|
+
publicKey?: string | null;
|
|
133
|
+
amountCents?: number | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readProbe(body: ProbeBody | null): ActivationProbe {
|
|
137
|
+
return {
|
|
138
|
+
publicKey: body?.publicKey ? body.publicKey : null,
|
|
139
|
+
amountCents: typeof body?.amountCents === 'number' ? body.amountCents : null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function useActivationProbe(verifyChargeUrl: string): ActivationProbe {
|
|
144
|
+
const [probe, setProbe] = useState<ActivationProbe>(UNKNOWN_PROBE);
|
|
103
145
|
|
|
104
146
|
useEffect(() => {
|
|
105
147
|
const alive = { current: true };
|
|
148
|
+
// Forgotten FIRST, before the new answer is asked for. A screen that moves
|
|
149
|
+
// between providers keeps this hook mounted, and holding the previous
|
|
150
|
+
// provider's key across the gap would tokenize the card with one vendor's
|
|
151
|
+
// key and send the blob to another — which arrives as that second
|
|
152
|
+
// provider's refusal, reading exactly like a bad card.
|
|
153
|
+
setProbe(UNKNOWN_PROBE);
|
|
106
154
|
void fetch(verifyChargeUrl)
|
|
107
|
-
.then((res) => (res.ok ? (res.json() as Promise<
|
|
155
|
+
.then((res) => (res.ok ? (res.json() as Promise<ProbeBody>) : null))
|
|
108
156
|
.then((body) => {
|
|
109
|
-
if (alive.current
|
|
157
|
+
if (alive.current) setProbe(readProbe(body));
|
|
110
158
|
})
|
|
111
159
|
.catch(() => undefined);
|
|
112
160
|
return () => {
|
|
@@ -114,7 +162,7 @@ function usePublicKey(verifyChargeUrl: string): string | null {
|
|
|
114
162
|
};
|
|
115
163
|
}, [verifyChargeUrl]);
|
|
116
164
|
|
|
117
|
-
return
|
|
165
|
+
return probe;
|
|
118
166
|
}
|
|
119
167
|
|
|
120
168
|
/** Local validation — nothing reaches the provider until the card is well-formed. */
|
|
@@ -205,7 +253,7 @@ function useCardForm() {
|
|
|
205
253
|
|
|
206
254
|
export function useActivationCharge(options: ActivationChargeOptions): ActivationCharge {
|
|
207
255
|
const { verifyChargeUrl, provider, email, onVerified, copy } = options;
|
|
208
|
-
const
|
|
256
|
+
const probe = useActivationProbe(verifyChargeUrl);
|
|
209
257
|
const form = useCardForm();
|
|
210
258
|
const [state, setState] = useState<ActivationChargeState>({ kind: 'idle' });
|
|
211
259
|
const { card, cpf, setFieldErrors, setCpfError, clear } = form;
|
|
@@ -222,7 +270,7 @@ export function useActivationCharge(options: ActivationChargeOptions): Activatio
|
|
|
222
270
|
provider,
|
|
223
271
|
card,
|
|
224
272
|
cpf,
|
|
225
|
-
publicKey,
|
|
273
|
+
publicKey: probe.publicKey,
|
|
226
274
|
email,
|
|
227
275
|
copy,
|
|
228
276
|
});
|
|
@@ -235,7 +283,7 @@ export function useActivationCharge(options: ActivationChargeOptions): Activatio
|
|
|
235
283
|
}, [
|
|
236
284
|
card,
|
|
237
285
|
cpf,
|
|
238
|
-
publicKey,
|
|
286
|
+
probe.publicKey,
|
|
239
287
|
verifyChargeUrl,
|
|
240
288
|
provider,
|
|
241
289
|
email,
|
|
@@ -260,6 +308,7 @@ export function useActivationCharge(options: ActivationChargeOptions): Activatio
|
|
|
260
308
|
cpf: form.cpf,
|
|
261
309
|
setCpf: form.setCpf,
|
|
262
310
|
cpfError: form.cpfError,
|
|
311
|
+
amountCents: probe.amountCents,
|
|
263
312
|
state,
|
|
264
313
|
submit,
|
|
265
314
|
reset,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { PaymentEnvironment } from '@12-apps/payments-backend';
|
|
2
|
+
|
|
3
|
+
import type { PrepareConnect } from './ProviderPanel';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The START of the connect round trip (FUT-763) — the sibling of
|
|
7
|
+
* `takeConnectReturn`, which owns its end.
|
|
8
|
+
*
|
|
9
|
+
* Before an owner is sent to the provider's site, a CSRF state is minted on
|
|
10
|
+
* the host's server, pinned to an httpOnly cookie there and compared on the
|
|
11
|
+
* way back. The browser only relays it, so a forged connect cannot start here
|
|
12
|
+
* — and the environment travels sealed into that same cookie, so a SANDBOX
|
|
13
|
+
* choice cannot come back as a PRODUCTION grant.
|
|
14
|
+
*
|
|
15
|
+
* The ROUTE that mints it is the host's; everything else about the exchange is
|
|
16
|
+
* this package's, and was being restated by every host that implemented
|
|
17
|
+
* `prepareConnect` by hand: the method, the content type, what a failure is,
|
|
18
|
+
* and the shape of the answer. That last one is the reason this exists rather
|
|
19
|
+
* than a copied snippet — see below.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** What the host's prepare endpoint answers. */
|
|
23
|
+
interface PreparedConnect {
|
|
24
|
+
state: string;
|
|
25
|
+
redirectUri: string;
|
|
26
|
+
environment?: PaymentEnvironment;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ConnectPreparerOptions {
|
|
30
|
+
/**
|
|
31
|
+
* The host's OAuth-prepare endpoint for one provider and environment.
|
|
32
|
+
*
|
|
33
|
+
* A builder rather than a whole URL because the provider is not fixed for
|
|
34
|
+
* this screen the way it is for a verification charge — the owner picks one,
|
|
35
|
+
* and the route shape still belongs to the host.
|
|
36
|
+
*/
|
|
37
|
+
prepareUrl: (provider: string, environment: PaymentEnvironment) => string;
|
|
38
|
+
/**
|
|
39
|
+
* What the owner is told when no connect could be started.
|
|
40
|
+
*
|
|
41
|
+
* Required and with no default, like every other sentence this package
|
|
42
|
+
* needs: a fallback compiled in here is how one product's voice reaches
|
|
43
|
+
* every adopter.
|
|
44
|
+
*/
|
|
45
|
+
mintFailed: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The two environments, for checking what came back is one of them. */
|
|
49
|
+
const ENVIRONMENTS: readonly PaymentEnvironment[] = ['SANDBOX', 'PRODUCTION'];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Refuse an answer that cannot start a connect.
|
|
53
|
+
*
|
|
54
|
+
* A hand-written preparer casts the body and hands it straight on, so a `200`
|
|
55
|
+
* carrying the wrong shape sends the owner to the provider with
|
|
56
|
+
* `state=undefined` in the URL. That does not fail here — it fails on the way
|
|
57
|
+
* BACK, as `state_mismatch`, two steps and one provider site later, and reads
|
|
58
|
+
* as "the connection expired" to someone whose connection never started.
|
|
59
|
+
*
|
|
60
|
+
* The mint either produced a usable state and a place to send them, or it
|
|
61
|
+
* failed. There is no third answer worth acting on.
|
|
62
|
+
*/
|
|
63
|
+
function usable(body: unknown): body is PreparedConnect {
|
|
64
|
+
if (typeof body !== 'object' || body === null) return false;
|
|
65
|
+
const candidate = body as Partial<PreparedConnect>;
|
|
66
|
+
return typeof candidate.state === 'string' && candidate.state.length > 0
|
|
67
|
+
&& typeof candidate.redirectUri === 'string' && candidate.redirectUri.length > 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the `prepareConnect` a host hands to `PaymentProviderSettings`.
|
|
72
|
+
*
|
|
73
|
+
* The environment is echoed back only when the answer names one this package
|
|
74
|
+
* knows. The SERVER is the authority on it — it is what sealed the cookie — so
|
|
75
|
+
* an unrecognised value is dropped rather than argued with, and the caller
|
|
76
|
+
* keeps the environment it asked for.
|
|
77
|
+
*/
|
|
78
|
+
export function createConnectPreparer(options: ConnectPreparerOptions): PrepareConnect {
|
|
79
|
+
return async (provider, environment) => {
|
|
80
|
+
const response = await fetch(options.prepareUrl(provider, environment), {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'content-type': 'application/json' },
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) throw new Error(options.mintFailed);
|
|
85
|
+
|
|
86
|
+
const body: unknown = await response.json().catch(() => null);
|
|
87
|
+
if (!usable(body)) throw new Error(options.mintFailed);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
state: body.state,
|
|
91
|
+
redirectUri: body.redirectUri,
|
|
92
|
+
environment: ENVIRONMENTS.includes(body.environment as PaymentEnvironment)
|
|
93
|
+
? body.environment
|
|
94
|
+
: undefined,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the OAuth connect callback redirected back with — read once, then
|
|
7
|
+
* erased from the address bar (FUT-763).
|
|
8
|
+
*
|
|
9
|
+
* The owner leaves for the provider's site to authorize us and comes back to a
|
|
10
|
+
* URL carrying the verdict. That round trip is the package's: `OAuthPanel`
|
|
11
|
+
* starts it, `PaymentProviderSettings` already reads `?connected=` as the raw
|
|
12
|
+
* provider name to reopen the right panel, and the codes below are the ones a
|
|
13
|
+
* connect can fail with. A host re-deriving any of it is re-deriving this
|
|
14
|
+
* package's own contract from the outside.
|
|
15
|
+
*
|
|
16
|
+
* What is NOT here is the sentence. `errorCode` comes back as a CODE precisely
|
|
17
|
+
* so the words stay the host's, which is the same rule the activation copy
|
|
18
|
+
* follows — a fallback string compiled in here is how one product's voice
|
|
19
|
+
* reaches every adopter.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How a connect can fail, as the callback spells it.
|
|
24
|
+
*
|
|
25
|
+
* A union rather than a loose string because these five are shared between a
|
|
26
|
+
* host's callback ROUTE, which emits them, and its copy map, which renders
|
|
27
|
+
* them — two files that today agree by luck. Typed, a host's
|
|
28
|
+
* `Record<ConnectErrorCode, string>` is checked for exhaustiveness, and a
|
|
29
|
+
* provider failure mode nobody wrote a sentence for stops compiling.
|
|
30
|
+
*/
|
|
31
|
+
export type ConnectErrorCode =
|
|
32
|
+
/** The owner declined on the provider's site. Nothing changed. */
|
|
33
|
+
| 'access_denied'
|
|
34
|
+
/** The CSRF state did not match — expired, or started in another tab. */
|
|
35
|
+
| 'state_mismatch'
|
|
36
|
+
/** The provider came back without an authorization code. */
|
|
37
|
+
| 'missing_code'
|
|
38
|
+
/** The callback did not say which provider it was for. */
|
|
39
|
+
| 'missing_provider'
|
|
40
|
+
/** The code could not be exchanged for a grant. */
|
|
41
|
+
| 'exchange_failed';
|
|
42
|
+
|
|
43
|
+
export interface ConnectReturn {
|
|
44
|
+
/**
|
|
45
|
+
* The provider that was just connected, as the callback spells it — which is
|
|
46
|
+
* the RAW name, not the URL slug. `PaymentProviderSettings` resolves either.
|
|
47
|
+
*/
|
|
48
|
+
connected: string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Why it failed, as a code — `null` when nothing failed.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately widened to `string`: a code outside {@link ConnectErrorCode}
|
|
53
|
+
* is passed through rather than dropped, because a host that has taught its
|
|
54
|
+
* own callback a new failure is not wrong — it just has a sentence this
|
|
55
|
+
* package does not know about. The union is what its copy map is keyed by;
|
|
56
|
+
* this is what its callback actually said.
|
|
57
|
+
*/
|
|
58
|
+
errorCode: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const NOTHING: ConnectReturn = { connected: null, errorCode: null };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The params the connect callback owns. Erased together, and ONLY these — a
|
|
65
|
+
* host's own query string survives the scrub.
|
|
66
|
+
*/
|
|
67
|
+
const CONNECT_PARAMS = ['connected', 'connectError', 'provider'] as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Take the callback's verdict out of the address bar.
|
|
71
|
+
*
|
|
72
|
+
* Erasing is the point, not tidiness: the query string is the only place this
|
|
73
|
+
* state lives, so leaving it there means a reload re-announces a connection
|
|
74
|
+
* that already happened — and, worse, re-announces a FAILURE the owner has
|
|
75
|
+
* since fixed.
|
|
76
|
+
*
|
|
77
|
+
* Take-once by construction: the second call finds nothing, because the first
|
|
78
|
+
* removed it. Callers hold the result.
|
|
79
|
+
*/
|
|
80
|
+
export function takeConnectReturn(): ConnectReturn {
|
|
81
|
+
// Server-rendered, or a test with no DOM: there is no address bar to read.
|
|
82
|
+
if (typeof window === 'undefined') return NOTHING;
|
|
83
|
+
|
|
84
|
+
const params = new URLSearchParams(window.location.search);
|
|
85
|
+
const connected = params.get('connected');
|
|
86
|
+
const errorCode = params.get('connectError');
|
|
87
|
+
if (!connected && !errorCode) return NOTHING;
|
|
88
|
+
|
|
89
|
+
for (const key of CONNECT_PARAMS) params.delete(key);
|
|
90
|
+
const query = params.toString();
|
|
91
|
+
window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`);
|
|
92
|
+
|
|
93
|
+
return { connected, errorCode };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* {@link takeConnectReturn} for a screen: taken after mount, held across every
|
|
98
|
+
* later render.
|
|
99
|
+
*
|
|
100
|
+
* Only ever SETS when something was found, which is what keeps it correct
|
|
101
|
+
* under a StrictMode double-mount: the second run finds an already-scrubbed
|
|
102
|
+
* URL, and must not overwrite the verdict the first one caught.
|
|
103
|
+
*/
|
|
104
|
+
export function useConnectReturn(): ConnectReturn {
|
|
105
|
+
const [taken, setTaken] = useState<ConnectReturn>(NOTHING);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const outcome = takeConnectReturn();
|
|
109
|
+
if (outcome.connected || outcome.errorCode) setTaken(outcome);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
return taken;
|
|
113
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -209,6 +209,15 @@ export {
|
|
|
209
209
|
type SetupGuideSectionProps,
|
|
210
210
|
} from './components/SetupGuideSection';
|
|
211
211
|
export { ProviderStatusBar, statusBadge } from './components/ProviderStatusBar';
|
|
212
|
+
// The START of the connect round trip (FUT-763): the `prepareConnect` a host
|
|
213
|
+
// hands to the settings screen, built from its own prepare route. The route is
|
|
214
|
+
// the host's; the exchange — method, shape, and what counts as a failure — is
|
|
215
|
+
// this package's, and a hand-written one casts the answer instead of checking
|
|
216
|
+
// it.
|
|
217
|
+
export {
|
|
218
|
+
createConnectPreparer,
|
|
219
|
+
type ConnectPreparerOptions,
|
|
220
|
+
} from './components/connect-preparer';
|
|
212
221
|
export {
|
|
213
222
|
PaymentProviderSettings,
|
|
214
223
|
type PaymentProviderSettingsProps,
|
|
@@ -243,6 +252,19 @@ export {
|
|
|
243
252
|
*/
|
|
244
253
|
export type { PaymentEnvironment } from '@12-apps/payments-backend';
|
|
245
254
|
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// The connect ROUND TRIP's other end (FUT-763): what the OAuth callback
|
|
257
|
+
// redirected back with, taken out of the address bar once. The codes are a
|
|
258
|
+
// union so a host's copy map is exhaustiveness-checked; the sentences stay the
|
|
259
|
+
// host's, as everywhere else in this package.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
export {
|
|
262
|
+
takeConnectReturn,
|
|
263
|
+
useConnectReturn,
|
|
264
|
+
type ConnectErrorCode,
|
|
265
|
+
type ConnectReturn,
|
|
266
|
+
} from './components/connect-return';
|
|
267
|
+
|
|
246
268
|
// ---------------------------------------------------------------------------
|
|
247
269
|
// The ACTIVATION CHARGE (FUT-463, packaged by FUT-763) — proving a connection
|
|
248
270
|
// can charge, for a provider whose payer pays HERE.
|