@12-apps/payments-frontend 3.2.4 → 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": {
|
|
@@ -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,316 @@
|
|
|
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 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
|
+
*
|
|
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
|
+
/**
|
|
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;
|
|
103
|
+
state: ActivationChargeState;
|
|
104
|
+
submit: () => Promise<void>;
|
|
105
|
+
/** Back to the form from a settled state, to try another card. */
|
|
106
|
+
reset: () => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What the verification endpoint says about the charge BEFORE it is made.
|
|
111
|
+
*
|
|
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.
|
|
121
|
+
*/
|
|
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);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
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);
|
|
154
|
+
void fetch(verifyChargeUrl)
|
|
155
|
+
.then((res) => (res.ok ? (res.json() as Promise<ProbeBody>) : null))
|
|
156
|
+
.then((body) => {
|
|
157
|
+
if (alive.current) setProbe(readProbe(body));
|
|
158
|
+
})
|
|
159
|
+
.catch(() => undefined);
|
|
160
|
+
return () => {
|
|
161
|
+
alive.current = false;
|
|
162
|
+
};
|
|
163
|
+
}, [verifyChargeUrl]);
|
|
164
|
+
|
|
165
|
+
return probe;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Local validation — nothing reaches the provider until the card is well-formed. */
|
|
169
|
+
function validateAll(card: CardDetails, cpf: string) {
|
|
170
|
+
const brand = detectBrand(onlyDigits(card.number));
|
|
171
|
+
return {
|
|
172
|
+
fieldErrors: {
|
|
173
|
+
number: validateCardNumber(card.number),
|
|
174
|
+
holder: validateHolder(card.holder),
|
|
175
|
+
expiry: validateExpiry(card.expiry),
|
|
176
|
+
cvv: validateCvv(card.cvv, brand),
|
|
177
|
+
} satisfies CardFieldErrors,
|
|
178
|
+
cpfError: validateCpf(cpf),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface ChargeRequest {
|
|
183
|
+
verifyChargeUrl: string;
|
|
184
|
+
provider: string;
|
|
185
|
+
card: CardDetails;
|
|
186
|
+
cpf: string;
|
|
187
|
+
publicKey: string | null;
|
|
188
|
+
email: string;
|
|
189
|
+
copy: ActivationChargeCopy;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Tokenize the owner's card and ask the server to charge the cent.
|
|
194
|
+
*
|
|
195
|
+
* Strict tokenization on purpose: with no public key there is no encryption, so
|
|
196
|
+
* there would be nothing for the provider to accept or refuse — and a mock
|
|
197
|
+
* token that "passed" would switch on a store that cannot charge.
|
|
198
|
+
*/
|
|
199
|
+
async function runCharge(request: ChargeRequest): Promise<ActivationChargeState> {
|
|
200
|
+
const tokenizer = tokenizerFor(request.provider);
|
|
201
|
+
if (!tokenizer) {
|
|
202
|
+
return {
|
|
203
|
+
kind: 'failed',
|
|
204
|
+
reason: request.copy.noTokenizer.replace('{provider}', request.provider),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const tokenized = await tokenizeCard(request.card, request.publicKey, tokenizer);
|
|
209
|
+
if (!tokenized.ok) return { kind: 'failed', reason: tokenized.error };
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const response = await fetch(request.verifyChargeUrl, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: { 'content-type': 'application/json' },
|
|
215
|
+
body: JSON.stringify({
|
|
216
|
+
token: tokenized.data.token,
|
|
217
|
+
taxId: onlyDigits(request.cpf),
|
|
218
|
+
holderName: request.card.holder.trim(),
|
|
219
|
+
email: request.email,
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
const body = (await response.json().catch(() => null)) as
|
|
223
|
+
| { ok?: boolean; refunded?: boolean; reason?: string; providerMessage?: string }
|
|
224
|
+
| null;
|
|
225
|
+
|
|
226
|
+
if (!body?.ok) {
|
|
227
|
+
return {
|
|
228
|
+
kind: 'failed',
|
|
229
|
+
reason: body?.reason ?? request.copy.chargeFailed,
|
|
230
|
+
providerMessage: body?.providerMessage,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { kind: 'passed', refunded: body.refunded === true };
|
|
234
|
+
} catch {
|
|
235
|
+
return { kind: 'failed', reason: request.copy.unreachable };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The typed-in card + CPF and their validation messages. */
|
|
240
|
+
function useCardForm() {
|
|
241
|
+
const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
|
|
242
|
+
const [fieldErrors, setFieldErrors] = useState<CardFieldErrors>({});
|
|
243
|
+
const [cpf, setCpf] = useState('');
|
|
244
|
+
const [cpfError, setCpfError] = useState<string | undefined>(undefined);
|
|
245
|
+
|
|
246
|
+
const clear = useCallback(() => {
|
|
247
|
+
setCard(EMPTY_CARD);
|
|
248
|
+
setCpf('');
|
|
249
|
+
}, []);
|
|
250
|
+
|
|
251
|
+
return { card, setCard, fieldErrors, setFieldErrors, cpf, setCpf, cpfError, setCpfError, clear };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function useActivationCharge(options: ActivationChargeOptions): ActivationCharge {
|
|
255
|
+
const { verifyChargeUrl, provider, email, onVerified, copy } = options;
|
|
256
|
+
const probe = useActivationProbe(verifyChargeUrl);
|
|
257
|
+
const form = useCardForm();
|
|
258
|
+
const [state, setState] = useState<ActivationChargeState>({ kind: 'idle' });
|
|
259
|
+
const { card, cpf, setFieldErrors, setCpfError, clear } = form;
|
|
260
|
+
|
|
261
|
+
const submit = useCallback(async () => {
|
|
262
|
+
const validation = validateAll(card, cpf);
|
|
263
|
+
setFieldErrors(validation.fieldErrors);
|
|
264
|
+
setCpfError(validation.cpfError);
|
|
265
|
+
if (Object.values(validation.fieldErrors).some(Boolean) || validation.cpfError) return;
|
|
266
|
+
|
|
267
|
+
setState({ kind: 'submitting' });
|
|
268
|
+
const next = await runCharge({
|
|
269
|
+
verifyChargeUrl,
|
|
270
|
+
provider,
|
|
271
|
+
card,
|
|
272
|
+
cpf,
|
|
273
|
+
publicKey: probe.publicKey,
|
|
274
|
+
email,
|
|
275
|
+
copy,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// The card is cleared only once it has served its purpose; a failure leaves
|
|
279
|
+
// it typed in so the owner can fix one field rather than start over.
|
|
280
|
+
if (next.kind === 'passed') clear();
|
|
281
|
+
setState(next);
|
|
282
|
+
if (next.kind === 'passed') onVerified();
|
|
283
|
+
}, [
|
|
284
|
+
card,
|
|
285
|
+
cpf,
|
|
286
|
+
probe.publicKey,
|
|
287
|
+
verifyChargeUrl,
|
|
288
|
+
provider,
|
|
289
|
+
email,
|
|
290
|
+
copy,
|
|
291
|
+
onVerified,
|
|
292
|
+
setFieldErrors,
|
|
293
|
+
setCpfError,
|
|
294
|
+
clear,
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
const reset = useCallback(() => {
|
|
298
|
+
setState({ kind: 'idle' });
|
|
299
|
+
setFieldErrors({});
|
|
300
|
+
setCpfError(undefined);
|
|
301
|
+
}, [setFieldErrors, setCpfError]);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
card: form.card,
|
|
305
|
+
setCard: form.setCard,
|
|
306
|
+
fieldErrors: form.fieldErrors,
|
|
307
|
+
setFieldErrors: form.setFieldErrors,
|
|
308
|
+
cpf: form.cpf,
|
|
309
|
+
setCpf: form.setCpf,
|
|
310
|
+
cpfError: form.cpfError,
|
|
311
|
+
amountCents: probe.amountCents,
|
|
312
|
+
state,
|
|
313
|
+
submit,
|
|
314
|
+
reset,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -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,
|
|
@@ -242,3 +251,36 @@ export {
|
|
|
242
251
|
* taking a direct dependency on the backend package.
|
|
243
252
|
*/
|
|
244
253
|
export type { PaymentEnvironment } from '@12-apps/payments-backend';
|
|
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
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// The ACTIVATION CHARGE (FUT-463, packaged by FUT-763) — proving a connection
|
|
270
|
+
// can charge, for a provider whose payer pays HERE.
|
|
271
|
+
//
|
|
272
|
+
// A connection is not a capability: a completed grant says the owner authorized
|
|
273
|
+
// us, not that the account can take money. The owner's own card goes through
|
|
274
|
+
// the SAME path a shopper's does — same fields, same validation, same
|
|
275
|
+
// browser-side encryption — for one cent, refunded immediately.
|
|
276
|
+
//
|
|
277
|
+
// The sibling of `useRedirectActivation` for the other half of the same step.
|
|
278
|
+
// As there, the SCREEN stays the host's.
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
export {
|
|
281
|
+
useActivationCharge,
|
|
282
|
+
type ActivationCharge,
|
|
283
|
+
type ActivationChargeOptions,
|
|
284
|
+
type ActivationChargeState,
|
|
285
|
+
} from './activation/use-activation-charge';
|
|
286
|
+
export { type ActivationChargeCopy } from './activation/charge-copy';
|