@12-apps/payments-frontend 3.3.0 → 3.5.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 +3 -3
- package/src/activation/copy.ts +36 -0
- package/src/activation/mint-charge.ts +77 -0
- package/src/activation/redirect-state.ts +184 -0
- package/src/activation/returned-settlement.ts +117 -0
- package/src/activation/use-activation-charge.ts +63 -14
- package/src/activation/use-redirect-activation.ts +341 -0
- package/src/components/connect-preparer.ts +97 -0
- package/src/components/connect-return.ts +113 -0
- package/src/index.ts +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"react-dom": ">=19.0.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@12-apps/eslint-config": "^1.
|
|
31
|
+
"@12-apps/eslint-config": "^1.21.0",
|
|
32
32
|
"@12-apps/typescript-config": "^1.20.0",
|
|
33
33
|
"@emotion/react": "^11.14.0",
|
|
34
34
|
"@emotion/styled": "^11.14.0",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four sentences the redirect activation protocol has to put on screen
|
|
3
|
+
* itself (FUT-763).
|
|
4
|
+
*
|
|
5
|
+
* It renders nothing — the host owns the screen — but four states are reached
|
|
6
|
+
* INSIDE the protocol and carry a reason the host never sees the raw form of:
|
|
7
|
+
* the provider answered with no message, the poll gave up, the link expired.
|
|
8
|
+
* Something has to be shown, and a fallback string compiled into the package is
|
|
9
|
+
* how one product's voice reaches every adopter (`DEFAULT_CHECKOUT_COPY_FE`,
|
|
10
|
+
* removed for exactly that in FUT-760).
|
|
11
|
+
*
|
|
12
|
+
* So there are no defaults and the field is required. A host that has not
|
|
13
|
+
* written these sentences finds out at the type level rather than by reading
|
|
14
|
+
* another company's tone of voice on its own settings screen.
|
|
15
|
+
*/
|
|
16
|
+
export interface RedirectActivationCopy {
|
|
17
|
+
/**
|
|
18
|
+
* The activation link's window elapsed with nothing paid.
|
|
19
|
+
*
|
|
20
|
+
* Distinct from a refusal on purpose: nothing was charged, so the only
|
|
21
|
+
* useful offer is another link.
|
|
22
|
+
*/
|
|
23
|
+
chargeExpired: string;
|
|
24
|
+
/** The provider answered "not paid" definitively, with no message of its own. */
|
|
25
|
+
confirmFailed: string;
|
|
26
|
+
/** The provider would not mint the link at all. */
|
|
27
|
+
createFailed: string;
|
|
28
|
+
/**
|
|
29
|
+
* The bounded poll elapsed while the charge was still live.
|
|
30
|
+
*
|
|
31
|
+
* The wording is the part that matters and the part only a host can own: the
|
|
32
|
+
* charge IS still payable, and telling an owner who has genuinely paid that
|
|
33
|
+
* their payment did not arrive in time is worse than saying nothing.
|
|
34
|
+
*/
|
|
35
|
+
confirmTimedOut: string;
|
|
36
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
creationFailure,
|
|
3
|
+
postActivation,
|
|
4
|
+
refusedByProvider,
|
|
5
|
+
type ActivationClock,
|
|
6
|
+
type RedirectActivationState,
|
|
7
|
+
} from './redirect-state';
|
|
8
|
+
import type { RedirectActivationCopy } from './copy';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Getting the owner onto the provider's payment page, in one press (FUT-763).
|
|
12
|
+
*
|
|
13
|
+
* Its own file because the ORDER of what happens inside that click is the whole
|
|
14
|
+
* content of it, and it is easy to lose in a hook full of effects.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A tab claimed for the payment page, before its address is known. */
|
|
18
|
+
interface PendingTab {
|
|
19
|
+
send: (url: string) => void;
|
|
20
|
+
discard: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Claim a tab SYNCHRONOUSLY, to be pointed somewhere once the link exists.
|
|
25
|
+
*
|
|
26
|
+
* Pressing the pay button should land the owner on the payment page — that is
|
|
27
|
+
* the whole action, and making them find a second button afterwards is the
|
|
28
|
+
* extra step this flow exists to remove. But the URL does not exist yet: it is
|
|
29
|
+
* minted by a request, and a `window.open` issued after that `await` has lost
|
|
30
|
+
* the user's gesture, so every popup blocker eats it.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately without `noopener`: that flag makes `window.open` return null,
|
|
33
|
+
* leaving no handle to navigate. `opener` is cleared by hand instead.
|
|
34
|
+
*/
|
|
35
|
+
function claimTab(): PendingTab {
|
|
36
|
+
const tab = window.open('', '_blank');
|
|
37
|
+
if (tab) tab.opener = null;
|
|
38
|
+
return {
|
|
39
|
+
// `replace`, so the blank entry does not become a Back destination.
|
|
40
|
+
send: (target) => tab?.location.replace(target),
|
|
41
|
+
discard: () => tab?.close(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mint the link and point the claimed tab at it.
|
|
47
|
+
*
|
|
48
|
+
* The ORDER here is load-bearing: the tab is claimed inside the click (see
|
|
49
|
+
* {@link claimTab}), discarded rather than stranded on a refusal, and the poll
|
|
50
|
+
* clock is only started once a link actually exists.
|
|
51
|
+
*/
|
|
52
|
+
export async function mintCharge(io: {
|
|
53
|
+
url: string;
|
|
54
|
+
live: ActivationClock;
|
|
55
|
+
setState: (next: RedirectActivationState) => void;
|
|
56
|
+
copy: RedirectActivationCopy;
|
|
57
|
+
onCreateFailed?: () => void;
|
|
58
|
+
}): Promise<void> {
|
|
59
|
+
const tab = claimTab();
|
|
60
|
+
|
|
61
|
+
io.setState({ kind: 'creating' });
|
|
62
|
+
const body = await postActivation(io.url, 'start');
|
|
63
|
+
if (!body?.ok || !body.checkoutUrl) {
|
|
64
|
+
// Never strand a blank tab on a failure the owner is about to read here.
|
|
65
|
+
tab.discard();
|
|
66
|
+
io.setState(creationFailure(body, io.copy));
|
|
67
|
+
// A dropped request means the provider refused NOTHING, so nothing the
|
|
68
|
+
// owner told us is called into question. Only an actual refusal withdraws
|
|
69
|
+
// their confirmation of the provider-side step.
|
|
70
|
+
if (refusedByProvider(body)) io.onCreateFailed?.();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
tab.send(body.checkoutUrl);
|
|
74
|
+
io.live.current.polling = true;
|
|
75
|
+
io.live.current.startedAt = Date.now();
|
|
76
|
+
io.setState({ kind: 'awaiting', checkoutUrl: body.checkoutUrl });
|
|
77
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { MutableRefObject } from 'react';
|
|
3
|
+
|
|
4
|
+
import type { RedirectActivationCopy } from './copy';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the redirect activation step can BE, and how one answer from the
|
|
8
|
+
* provider moves it — with no React and no requests anywhere near (FUT-763).
|
|
9
|
+
*
|
|
10
|
+
* Split from the hook because these are the decisions and the hook is the
|
|
11
|
+
* wiring: timers, mount effects, a claimed tab, a memoized callback. Together
|
|
12
|
+
* they were one file nobody could read the rules out of, and the rules are the
|
|
13
|
+
* part that costs money when it is wrong.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type RedirectActivationState =
|
|
17
|
+
| { kind: 'idle' }
|
|
18
|
+
| { kind: 'creating' }
|
|
19
|
+
/** Link live (URL present) or confirming a return trip (URL null). */
|
|
20
|
+
| {
|
|
21
|
+
kind: 'awaiting';
|
|
22
|
+
checkoutUrl: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* A payment ATTEMPT was refused, on a charge that is still payable.
|
|
25
|
+
*
|
|
26
|
+
* It stays `awaiting` rather than becoming a failure because nothing
|
|
27
|
+
* about the link changed: the card said no, and the next move is another
|
|
28
|
+
* card or another method on this same link. Settling it would clear the
|
|
29
|
+
* outstanding charge and put a button offering to mint a SECOND real one
|
|
30
|
+
* on screen, while the first sits live at the provider.
|
|
31
|
+
*/
|
|
32
|
+
declined?: string;
|
|
33
|
+
}
|
|
34
|
+
/** The link's window elapsed unpaid. Nothing was charged; offer another. */
|
|
35
|
+
| { kind: 'expired'; reason: string }
|
|
36
|
+
| { kind: 'passed' }
|
|
37
|
+
| {
|
|
38
|
+
kind: 'failed';
|
|
39
|
+
reason: string;
|
|
40
|
+
providerMessage?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The provider refused to CREATE the link, rather than refusing the
|
|
43
|
+
* payment. A different failure with a different owner: no money moved and
|
|
44
|
+
* nothing is outstanding, and the overwhelmingly likely cause is a
|
|
45
|
+
* provider-side switch still being off — which is a step, not an error.
|
|
46
|
+
*/
|
|
47
|
+
atCreation?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* …unless the provider was never reached at all, in which case it refused
|
|
50
|
+
* nothing and the step the owner already completed must survive untouched.
|
|
51
|
+
*/
|
|
52
|
+
transport?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** One answer from the host's verify-charge endpoint. */
|
|
56
|
+
export interface ActivationPollBody {
|
|
57
|
+
ok?: boolean;
|
|
58
|
+
pending?: boolean;
|
|
59
|
+
reason?: string;
|
|
60
|
+
providerMessage?: string;
|
|
61
|
+
checkoutUrl?: string;
|
|
62
|
+
/** Which settled-and-negative answer this was. */
|
|
63
|
+
outcome?: 'expired' | 'declined' | 'refused';
|
|
64
|
+
/** The charge is still payable; the server deliberately kept it outstanding. */
|
|
65
|
+
retryable?: boolean;
|
|
66
|
+
/** `start` only: the provider was never reached, so it refused nothing. */
|
|
67
|
+
transport?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The outstanding charge, as the server remembers it. */
|
|
71
|
+
export interface ActivationPendingBody {
|
|
72
|
+
reference: string;
|
|
73
|
+
checkoutUrl: string;
|
|
74
|
+
slug?: string;
|
|
75
|
+
startedAt: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Ask the host's endpoint to start, poll, or discard the activation charge.
|
|
80
|
+
*
|
|
81
|
+
* A thrown fetch and an unparsable body both answer `null`, which is the same
|
|
82
|
+
* fact one layer out: nothing came back. {@link refusedByProvider} is what
|
|
83
|
+
* turns that into a decision.
|
|
84
|
+
*/
|
|
85
|
+
export async function postActivation(
|
|
86
|
+
url: string,
|
|
87
|
+
action: 'start' | 'poll' | 'discard',
|
|
88
|
+
extra: Record<string, string> = {},
|
|
89
|
+
): Promise<ActivationPollBody | null> {
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'content-type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ action, ...extra }),
|
|
95
|
+
});
|
|
96
|
+
return (await response.json().catch(() => null)) as ActivationPollBody | null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Live poll bookkeeping, mutated in place so a timer never reads a stale copy. */
|
|
103
|
+
export type ActivationClock = MutableRefObject<{ polling: boolean; startedAt: number }>;
|
|
104
|
+
|
|
105
|
+
/** What {@link settleActivationPoll} needs besides the answer itself. */
|
|
106
|
+
export interface SettlePollIo {
|
|
107
|
+
live: ActivationClock;
|
|
108
|
+
setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>;
|
|
109
|
+
onVerified: () => void;
|
|
110
|
+
/** Drop the parked return-trip ids — the charge is done with them. */
|
|
111
|
+
clearSettlement: () => void;
|
|
112
|
+
copy: RedirectActivationCopy;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Turn one poll answer into the screen's state, and say whether it SETTLED.
|
|
117
|
+
*
|
|
118
|
+
* Four outcomes, and the two in the middle are the ones worth naming. A
|
|
119
|
+
* `retryable` refusal is a payment ATTEMPT that failed on a charge still
|
|
120
|
+
* sitting live at the provider — so it stays `awaiting`, keeps the link on
|
|
121
|
+
* screen (paying it with another method is the entire fix) and does not stop
|
|
122
|
+
* the timer. An `expired` charge is the opposite: nobody can pay it now, so the
|
|
123
|
+
* only useful offer is another one.
|
|
124
|
+
*/
|
|
125
|
+
export function settleActivationPoll(body: ActivationPollBody | null, io: SettlePollIo): boolean {
|
|
126
|
+
if (!body) return false;
|
|
127
|
+
if (body.ok) {
|
|
128
|
+
io.live.current.polling = false;
|
|
129
|
+
io.clearSettlement();
|
|
130
|
+
io.setState({ kind: 'passed' });
|
|
131
|
+
io.onVerified();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (body.pending) return false;
|
|
135
|
+
if (body.retryable) {
|
|
136
|
+
const declined = body.reason ?? '';
|
|
137
|
+
io.setState((current) =>
|
|
138
|
+
current.kind === 'awaiting'
|
|
139
|
+
? { ...current, declined }
|
|
140
|
+
: { kind: 'awaiting', checkoutUrl: null, declined },
|
|
141
|
+
);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
io.live.current.polling = false;
|
|
145
|
+
io.clearSettlement();
|
|
146
|
+
if (body.outcome === 'expired') {
|
|
147
|
+
io.setState({ kind: 'expired', reason: body.reason ?? io.copy.chargeExpired });
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
io.setState({
|
|
151
|
+
kind: 'failed',
|
|
152
|
+
reason: body.reason ?? io.copy.confirmFailed,
|
|
153
|
+
providerMessage: body.providerMessage,
|
|
154
|
+
});
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Did the PROVIDER refuse, or was it simply never reached?
|
|
160
|
+
*
|
|
161
|
+
* `transport` says the request produced no response; a null body says the
|
|
162
|
+
* browser's own fetch threw, which is the same thing one layer out. Either way
|
|
163
|
+
* nothing was refused — and the distinction matters because a refusal, and only
|
|
164
|
+
* a refusal, is evidence that a step the owner ticked off is not in fact done.
|
|
165
|
+
* Treating an outage as one sends someone whose network blinked back to redo a
|
|
166
|
+
* finished step, to change a setting that was already correct.
|
|
167
|
+
*/
|
|
168
|
+
export function refusedByProvider(body: ActivationPollBody | null): boolean {
|
|
169
|
+
return Boolean(body) && !body?.transport;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The failed state a refused (or unreachable) `start` leaves behind. */
|
|
173
|
+
export function creationFailure(
|
|
174
|
+
body: ActivationPollBody | null,
|
|
175
|
+
copy: RedirectActivationCopy,
|
|
176
|
+
): RedirectActivationState {
|
|
177
|
+
return {
|
|
178
|
+
kind: 'failed',
|
|
179
|
+
reason: body?.reason ?? copy.createFailed,
|
|
180
|
+
providerMessage: body?.providerMessage,
|
|
181
|
+
atCreation: true,
|
|
182
|
+
...(refusedByProvider(body) ? {} : { transport: true }),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settlement a redirect provider hands back on the return trip, made
|
|
3
|
+
* durable (FUT-763).
|
|
4
|
+
*
|
|
5
|
+
* The activation charge is paid on the PROVIDER'S site, so "left this page and
|
|
6
|
+
* came back" is the normal path. The provider appends the ids that prove the
|
|
7
|
+
* payment to the return URL, and those ids exist nowhere before the payment
|
|
8
|
+
* and — without care — nowhere after the first render: reading and scrubbing
|
|
9
|
+
* them in one call destroys them on a re-render, and a reload (the natural
|
|
10
|
+
* reaction to a screen that looks stuck) destroys an in-memory copy too.
|
|
11
|
+
*
|
|
12
|
+
* So they are parked in `sessionStorage` BEFORE the URL is scrubbed, survive
|
|
13
|
+
* both, and are cleared only when the charge settles.
|
|
14
|
+
*
|
|
15
|
+
* This is protocol, not presentation, which is why it moved: every host that
|
|
16
|
+
* mounts a redirect provider gets the same return trip, and the ordering above
|
|
17
|
+
* is not something a host should have to rediscover — it was rediscovered once
|
|
18
|
+
* already, by a payment that was confirmed, thrown away, and never asked about
|
|
19
|
+
* again.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The default parking slot.
|
|
24
|
+
*
|
|
25
|
+
* Namespaced by the package rather than by an app, and overridable, so two
|
|
26
|
+
* surfaces of the same host can run the flow without reading each other's
|
|
27
|
+
* parked ids.
|
|
28
|
+
*/
|
|
29
|
+
export const RETURNED_SETTLEMENT_KEY = 'payments:activation-settlement';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The ids a redirect provider appends, and the aliases it may use for them.
|
|
33
|
+
*
|
|
34
|
+
* Both matter, and this was measured against the live API rather than read off
|
|
35
|
+
* a doc page:
|
|
36
|
+
*
|
|
37
|
+
* handle + order id + transaction id + slug → paid
|
|
38
|
+
* the same call minus the slug → not paid
|
|
39
|
+
* the same call minus the transaction id → not paid
|
|
40
|
+
*
|
|
41
|
+
* Neither is optional and neither exists before the payment. Asking with only
|
|
42
|
+
* the transaction id — while expecting the slug from link creation, where the
|
|
43
|
+
* provider does not put it — is a question that always answers no.
|
|
44
|
+
*/
|
|
45
|
+
const TRANSACTION_PARAMS = ['transaction_nsu', 'transaction_id'] as const;
|
|
46
|
+
const SLUG_PARAM = 'slug';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Everything the return trip may carry, scrubbed together.
|
|
50
|
+
*
|
|
51
|
+
* Wider than what is READ: a receipt link and a capture method identify a
|
|
52
|
+
* payment just as well, and have no business persisting in a URL somebody
|
|
53
|
+
* might copy or a history entry a reload might replay.
|
|
54
|
+
*/
|
|
55
|
+
const SETTLEMENT_PARAMS = [
|
|
56
|
+
...TRANSACTION_PARAMS,
|
|
57
|
+
'order_nsu',
|
|
58
|
+
SLUG_PARAM,
|
|
59
|
+
'receipt_url',
|
|
60
|
+
'capture_method',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function parked(storageKey: string): Record<string, string> {
|
|
64
|
+
try {
|
|
65
|
+
const raw = window.sessionStorage.getItem(storageKey);
|
|
66
|
+
return raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The charge settled — either way — so the parked ids have done their job. */
|
|
73
|
+
export function clearReturnedSettlement(storageKey = RETURNED_SETTLEMENT_KEY): void {
|
|
74
|
+
try {
|
|
75
|
+
window.sessionStorage.removeItem(storageKey);
|
|
76
|
+
} catch {
|
|
77
|
+
// Best-effort: a stale parked value is re-sent once and ignored server-side.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the return trip's ids, park them, and take them out of the address bar.
|
|
83
|
+
*
|
|
84
|
+
* Safe to call on every render and every poll — which it is. With nothing in
|
|
85
|
+
* the URL it answers with whatever is already parked, so the fifth ask carries
|
|
86
|
+
* the same proof as the first.
|
|
87
|
+
*/
|
|
88
|
+
export function takeReturnedSettlement(
|
|
89
|
+
storageKey = RETURNED_SETTLEMENT_KEY,
|
|
90
|
+
): Record<string, string> {
|
|
91
|
+
if (typeof window === 'undefined') return {};
|
|
92
|
+
|
|
93
|
+
const params = new URLSearchParams(window.location.search);
|
|
94
|
+
const transactionNsu = TRANSACTION_PARAMS.map((key) => params.get(key)).find(Boolean) ?? '';
|
|
95
|
+
const slug = params.get(SLUG_PARAM) ?? '';
|
|
96
|
+
if (!transactionNsu && !slug) return parked(storageKey);
|
|
97
|
+
|
|
98
|
+
const captured = {
|
|
99
|
+
...(transactionNsu ? { transactionNsu } : {}),
|
|
100
|
+
...(slug ? { slug } : {}),
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
window.sessionStorage.setItem(storageKey, JSON.stringify(captured));
|
|
104
|
+
} catch {
|
|
105
|
+
// Storage refused (private-mode quirks): the in-URL copy still exists for
|
|
106
|
+
// this page-load, and the server persists the slug after one good poll.
|
|
107
|
+
}
|
|
108
|
+
scrubSettlementParams(params);
|
|
109
|
+
return captured;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Take the settlement params back out of the address bar, read once. */
|
|
113
|
+
function scrubSettlementParams(params: URLSearchParams): void {
|
|
114
|
+
for (const key of SETTLEMENT_PARAMS) params.delete(key);
|
|
115
|
+
const query = params.toString();
|
|
116
|
+
window.history.replaceState({}, '', `${window.location.pathname}${query ? `?${query}` : ''}`);
|
|
117
|
+
}
|
|
@@ -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,341 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type React from 'react';
|
|
5
|
+
|
|
6
|
+
import type { RedirectActivationCopy } from './copy';
|
|
7
|
+
import { mintCharge } from './mint-charge';
|
|
8
|
+
import {
|
|
9
|
+
postActivation,
|
|
10
|
+
settleActivationPoll,
|
|
11
|
+
type ActivationClock,
|
|
12
|
+
type ActivationPendingBody,
|
|
13
|
+
type ActivationPollBody,
|
|
14
|
+
type RedirectActivationState,
|
|
15
|
+
} from './redirect-state';
|
|
16
|
+
import { clearReturnedSettlement, takeReturnedSettlement } from './returned-settlement';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The activation step for a provider whose payer pays on ITS page (FUT-463,
|
|
20
|
+
* moved here by FUT-763).
|
|
21
|
+
*
|
|
22
|
+
* Same proof as a card form, different protocol: a REAL link is minted through
|
|
23
|
+
* this store's own connection, the owner pays it on the provider's site, and
|
|
24
|
+
* the provider is then ASKED whether it arrived. Only that answer activates the
|
|
25
|
+
* store. The alternative — telling an owner to "make a real low-value order and
|
|
26
|
+
* confirm the money arrives" — is the dead-end instruction the step exists to
|
|
27
|
+
* abolish.
|
|
28
|
+
*
|
|
29
|
+
* ## What is the package's here, and what is not
|
|
30
|
+
*
|
|
31
|
+
* `renderVerification` on `PaymentProviderSettings` says the package decides
|
|
32
|
+
* only WHERE the step appears, never how it works. That still holds for the
|
|
33
|
+
* SCREEN: the panels, the states an owner reads, the sentences — all of that is
|
|
34
|
+
* the host's, and none of it is here.
|
|
35
|
+
*
|
|
36
|
+
* What is here is the PROTOCOL, which is not a matter of taste and is not
|
|
37
|
+
* something a second host should get to rediscover: pick the outstanding charge
|
|
38
|
+
* back up on mount, ask with the return trip's ids on every tick, keep a
|
|
39
|
+
* refused ATTEMPT distinct from an expired charge, keep an unreachable provider
|
|
40
|
+
* distinct from a refusing one, stop asking eventually. Every one of those was
|
|
41
|
+
* learned from a payment that went wrong, and three of them cost a real charge.
|
|
42
|
+
*
|
|
43
|
+
* ## Why the attempt is the SERVER's
|
|
44
|
+
*
|
|
45
|
+
* It used to live in React state, and that was wrong in a way that costs money.
|
|
46
|
+
* The flow deliberately sends the owner to another site to pay, so "left this
|
|
47
|
+
* page and came back" is the NORMAL path, not an edge case — and it erased the
|
|
48
|
+
* attempt every time. The screen then offered to generate a charge, because as
|
|
49
|
+
* far as it knew none existed: a second real charge on the owner's own card,
|
|
50
|
+
* while the first, already paid, was never asked about again.
|
|
51
|
+
*
|
|
52
|
+
* So the outstanding charge is loaded from the server on mount, and the only
|
|
53
|
+
* things that clear it are settlement and an explicit "give up".
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** How often to ask the provider whether the payment landed. */
|
|
57
|
+
const DEFAULT_POLL_MS = 4000;
|
|
58
|
+
|
|
59
|
+
/** Give up asking after this long, rather than polling a tab forever. */
|
|
60
|
+
const DEFAULT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
61
|
+
|
|
62
|
+
export interface RedirectActivationOptions {
|
|
63
|
+
/**
|
|
64
|
+
* The host's verify-charge endpoint for this provider.
|
|
65
|
+
*
|
|
66
|
+
* A whole URL, not the parts of one: the route shape belongs to the host and
|
|
67
|
+
* the package has no business assembling it. `GET` answers the outstanding
|
|
68
|
+
* charge; `POST` takes `{ action: 'start' | 'poll' | 'discard' }`.
|
|
69
|
+
*/
|
|
70
|
+
verifyChargeUrl: string;
|
|
71
|
+
/** A passing charge — the caller refreshes so the provider shows as active. */
|
|
72
|
+
onVerified: () => void;
|
|
73
|
+
/**
|
|
74
|
+
* The provider would not mint a link. Called instead of, not as well as,
|
|
75
|
+
* settling anything: no charge exists, so there is nothing to confirm — the
|
|
76
|
+
* caller uses it to reopen the setup step this failure implicates.
|
|
77
|
+
*/
|
|
78
|
+
onCreateFailed?: () => void;
|
|
79
|
+
copy: RedirectActivationCopy;
|
|
80
|
+
/** Where the return trip's ids are parked. Defaults per the package. */
|
|
81
|
+
storageKey?: string;
|
|
82
|
+
pollMs?: number;
|
|
83
|
+
pollTimeoutMs?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RedirectActivation {
|
|
87
|
+
state: RedirectActivationState;
|
|
88
|
+
/**
|
|
89
|
+
* When the provider last answered, as epoch ms (0 before the first ask).
|
|
90
|
+
*
|
|
91
|
+
* Shown as "last checked Ns ago". A spinner alone says "something is
|
|
92
|
+
* happening"; this says the screen is still asking, which is the reassurance
|
|
93
|
+
* someone who has just paid on another site actually wants — and it is what
|
|
94
|
+
* stops them pressing a pay button again to be sure.
|
|
95
|
+
*/
|
|
96
|
+
lastCheckedAt: number;
|
|
97
|
+
/** Mint the link and begin asking. */
|
|
98
|
+
start: () => Promise<void>;
|
|
99
|
+
/** Ask right now, instead of waiting for the next tick. */
|
|
100
|
+
checkNow: () => Promise<void>;
|
|
101
|
+
/** Abandon the outstanding charge and offer a fresh one. */
|
|
102
|
+
reset: () => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What the effects call, read through a ref rather than through their deps.
|
|
107
|
+
*
|
|
108
|
+
* NOT a style preference — it is what makes the hook safe to hand to a host.
|
|
109
|
+
* Every callback here changes identity when the caller re-renders without
|
|
110
|
+
* memoizing, and both effects below would then re-run on it: the resume effect
|
|
111
|
+
* would re-mount the whole sequence (and, since it sets state, re-render, and
|
|
112
|
+
* loop), and the poll timer would be cleared and restarted before it ever
|
|
113
|
+
* ticked — a screen that asks the provider nothing, for ever, while looking
|
|
114
|
+
* exactly like one that is asking.
|
|
115
|
+
*
|
|
116
|
+
* The host that wrote this flow happened to memoize, so neither ever fired
|
|
117
|
+
* there. A package cannot rely on that: `useRedirectActivation({ onVerified:
|
|
118
|
+
* () => reload() })` is the obvious way to call it.
|
|
119
|
+
*/
|
|
120
|
+
interface ActivationCallbacks {
|
|
121
|
+
applyPoll: (body: ActivationPollBody | null) => boolean;
|
|
122
|
+
returned: () => Record<string, string>;
|
|
123
|
+
timedOut: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Ask the provider every few seconds, and stop asking eventually.
|
|
128
|
+
*
|
|
129
|
+
* The give-up is a `failed` state carrying the host's own sentence, because the
|
|
130
|
+
* wording is load-bearing: the charge is STILL valid, and an owner who has
|
|
131
|
+
* genuinely paid must not be told they did not pay in time.
|
|
132
|
+
*/
|
|
133
|
+
function usePollTimer(
|
|
134
|
+
active: boolean,
|
|
135
|
+
url: string,
|
|
136
|
+
live: ActivationClock,
|
|
137
|
+
latest: React.MutableRefObject<ActivationCallbacks>,
|
|
138
|
+
setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>,
|
|
139
|
+
pollMs: number,
|
|
140
|
+
pollTimeoutMs: number,
|
|
141
|
+
): void {
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!active) return;
|
|
144
|
+
const timer = setInterval(() => {
|
|
145
|
+
if (!live.current.polling) return;
|
|
146
|
+
if (Date.now() - live.current.startedAt > pollTimeoutMs) {
|
|
147
|
+
live.current.polling = false;
|
|
148
|
+
setState({ kind: 'failed', reason: latest.current.timedOut });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// WITH the ids, every tick. A hint-less poll is measured to answer "not
|
|
152
|
+
// paid" for payments that happened — one hinted ask on resume made a
|
|
153
|
+
// single dropped request fatal.
|
|
154
|
+
void postActivation(url, 'poll', latest.current.returned()).then((body) =>
|
|
155
|
+
latest.current.applyPoll(body),
|
|
156
|
+
);
|
|
157
|
+
}, pollMs);
|
|
158
|
+
return () => clearInterval(timer);
|
|
159
|
+
}, [active, url, live, latest, setState, pollMs, pollTimeoutMs]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pick the outstanding charge back up on mount.
|
|
164
|
+
*
|
|
165
|
+
* This is what makes the return trip work at all. The owner is sent to the
|
|
166
|
+
* provider's site to pay and comes back here holding the ids that let the
|
|
167
|
+
* payment be confirmed — so the very first thing this does on load is ask
|
|
168
|
+
* whether a charge is outstanding and, if so, check it immediately with
|
|
169
|
+
* whatever the redirect brought back.
|
|
170
|
+
*/
|
|
171
|
+
function useResumeOutstanding(
|
|
172
|
+
url: string,
|
|
173
|
+
live: ActivationClock,
|
|
174
|
+
latest: React.MutableRefObject<ActivationCallbacks>,
|
|
175
|
+
setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>,
|
|
176
|
+
): void {
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const alive = { current: true };
|
|
179
|
+
const carried = latest.current.returned();
|
|
180
|
+
|
|
181
|
+
// An owner who came back holding a settlement has DEMONSTRABLY paid, so the
|
|
182
|
+
// screen must be confirming from the first frame — never showing the pay
|
|
183
|
+
// button, whatever the pending row says and whatever any single request
|
|
184
|
+
// does. `checkoutUrl: null` renders the confirming panel without an
|
|
185
|
+
// open-link button. This also arms the timer, so one dropped request costs
|
|
186
|
+
// four seconds rather than the payment.
|
|
187
|
+
if (Object.keys(carried).length > 0) {
|
|
188
|
+
live.current.polling = true;
|
|
189
|
+
live.current.startedAt = Date.now();
|
|
190
|
+
setState({ kind: 'awaiting', checkoutUrl: null });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
void resumeSequence({ url, live, latest, setState }, carried, alive);
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
alive.current = false;
|
|
197
|
+
};
|
|
198
|
+
}, [url, live, latest, setState]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Everything the resume sequence reaches for, gathered once. */
|
|
202
|
+
interface ResumeIo {
|
|
203
|
+
url: string;
|
|
204
|
+
live: ActivationClock;
|
|
205
|
+
latest: React.MutableRefObject<ActivationCallbacks>;
|
|
206
|
+
setState: React.Dispatch<React.SetStateAction<RedirectActivationState>>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The resume sequence, in the order that keeps a paid return confirmable. */
|
|
210
|
+
async function resumeSequence(
|
|
211
|
+
io: ResumeIo,
|
|
212
|
+
carried: Record<string, string>,
|
|
213
|
+
alive: { current: boolean },
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
const cameBack = Object.keys(carried).length > 0;
|
|
216
|
+
// Fired FIRST, and not gated on the pending GET below: this ask depends on
|
|
217
|
+
// nothing but the returned ids, and chaining it behind another request's
|
|
218
|
+
// success is how a mid-refresh session cookie once turned a confirmed payment
|
|
219
|
+
// into a pay button.
|
|
220
|
+
if (cameBack) {
|
|
221
|
+
const polled = await postActivation(io.url, 'poll', carried);
|
|
222
|
+
if (alive.current) io.latest.current.applyPoll(polled);
|
|
223
|
+
}
|
|
224
|
+
await restoreOutstanding(io, carried, alive, cameBack);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The GET's answer: the outstanding charge, and whether the server settled it
|
|
229
|
+
* during this very read (its own heal path).
|
|
230
|
+
*/
|
|
231
|
+
interface OutstandingBody {
|
|
232
|
+
pending?: ActivationPendingBody;
|
|
233
|
+
proven?: boolean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function readOutstanding(url: string): Promise<OutstandingBody | null> {
|
|
237
|
+
const response = await fetch(url).catch(() => null);
|
|
238
|
+
if (!response?.ok) return null;
|
|
239
|
+
return (await response.json().catch(() => null)) as OutstandingBody | null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Restore the server's pending row into the screen, and ask once if idle. */
|
|
243
|
+
async function restoreOutstanding(
|
|
244
|
+
io: ResumeIo,
|
|
245
|
+
carried: Record<string, string>,
|
|
246
|
+
alive: { current: boolean },
|
|
247
|
+
cameBack: boolean,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
const body = await readOutstanding(io.url);
|
|
250
|
+
if (!body || !alive.current) return;
|
|
251
|
+
// Settled server-side while we were reading — the charge is done, and the one
|
|
252
|
+
// thing this must not do now is resume a pay flow for it.
|
|
253
|
+
if (body.proven) {
|
|
254
|
+
io.latest.current.applyPoll({ ok: true });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const pending = body.pending;
|
|
258
|
+
if (!pending) return;
|
|
259
|
+
|
|
260
|
+
io.live.current.polling = true;
|
|
261
|
+
io.live.current.startedAt = Date.parse(pending.startedAt) || Date.now();
|
|
262
|
+
io.setState((current) =>
|
|
263
|
+
current.kind === 'passed' || current.kind === 'failed'
|
|
264
|
+
? current
|
|
265
|
+
: { kind: 'awaiting', checkoutUrl: pending.checkoutUrl },
|
|
266
|
+
);
|
|
267
|
+
if (!cameBack) {
|
|
268
|
+
const polled = await postActivation(io.url, 'poll', carried);
|
|
269
|
+
if (alive.current) io.latest.current.applyPoll(polled);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function useRedirectActivation(options: RedirectActivationOptions): RedirectActivation {
|
|
274
|
+
const {
|
|
275
|
+
verifyChargeUrl: url,
|
|
276
|
+
onVerified,
|
|
277
|
+
onCreateFailed,
|
|
278
|
+
copy,
|
|
279
|
+
storageKey,
|
|
280
|
+
pollMs = DEFAULT_POLL_MS,
|
|
281
|
+
pollTimeoutMs = DEFAULT_POLL_TIMEOUT_MS,
|
|
282
|
+
} = options;
|
|
283
|
+
|
|
284
|
+
const [state, setState] = useState<RedirectActivationState>({ kind: 'idle' });
|
|
285
|
+
const [lastCheckedAt, setLastCheckedAt] = useState(0);
|
|
286
|
+
// Read inside the interval callback so the timer never closes over a stale
|
|
287
|
+
// state — mutating a ref's property rather than reassigning a captured
|
|
288
|
+
// binding, which the flakiness lint rightly rejects.
|
|
289
|
+
const live = useRef({ polling: false, startedAt: 0 });
|
|
290
|
+
|
|
291
|
+
const returned = useCallback(() => takeReturnedSettlement(storageKey), [storageKey]);
|
|
292
|
+
|
|
293
|
+
const applyPoll = useCallback(
|
|
294
|
+
(body: ActivationPollBody | null): boolean => {
|
|
295
|
+
// Stamped even on a dropped request: the counter reports when we last
|
|
296
|
+
// ASKED, and freezing it on a network blip would read as a stalled screen.
|
|
297
|
+
setLastCheckedAt(Date.now());
|
|
298
|
+
return settleActivationPoll(body, {
|
|
299
|
+
live,
|
|
300
|
+
setState,
|
|
301
|
+
onVerified,
|
|
302
|
+
clearSettlement: () => clearReturnedSettlement(storageKey),
|
|
303
|
+
copy,
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
[onVerified, storageKey, copy],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const latest = useRef<ActivationCallbacks>({
|
|
310
|
+
applyPoll,
|
|
311
|
+
returned,
|
|
312
|
+
timedOut: copy.confirmTimedOut,
|
|
313
|
+
});
|
|
314
|
+
latest.current = { applyPoll, returned, timedOut: copy.confirmTimedOut };
|
|
315
|
+
|
|
316
|
+
useResumeOutstanding(url, live, latest, setState);
|
|
317
|
+
usePollTimer(state.kind === 'awaiting', url, live, latest, setState, pollMs, pollTimeoutMs);
|
|
318
|
+
|
|
319
|
+
const start = useCallback(
|
|
320
|
+
() => mintCharge({ url, live, setState, copy, onCreateFailed }),
|
|
321
|
+
[url, copy, onCreateFailed],
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const checkNow = useCallback(async () => {
|
|
325
|
+
applyPoll(await postActivation(url, 'poll', returned()));
|
|
326
|
+
}, [applyPoll, url, returned]);
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Give up on the outstanding charge — and tell the SERVER so.
|
|
330
|
+
*
|
|
331
|
+
* Clearing only the local state would leave the charge on record, so the very
|
|
332
|
+
* next load would resume the attempt the owner just abandoned.
|
|
333
|
+
*/
|
|
334
|
+
const reset = useCallback(() => {
|
|
335
|
+
live.current.polling = false;
|
|
336
|
+
setState({ kind: 'idle' });
|
|
337
|
+
void postActivation(url, 'discard');
|
|
338
|
+
}, [url]);
|
|
339
|
+
|
|
340
|
+
return { state, lastCheckedAt, start, checkNow, reset };
|
|
341
|
+
}
|
|
@@ -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,52 @@ export {
|
|
|
243
252
|
*/
|
|
244
253
|
export type { PaymentEnvironment } from '@12-apps/payments-backend';
|
|
245
254
|
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// The REDIRECT ACTIVATION protocol (FUT-463, packaged by FUT-763) — proving a
|
|
257
|
+
// connection can charge, for a provider whose payer pays on its own page.
|
|
258
|
+
//
|
|
259
|
+
// `renderVerification` above stays what it was: the package decides where the
|
|
260
|
+
// step appears and the host owns the screen. What moved is the protocol behind
|
|
261
|
+
// it — resume-on-mount, the return trip's ids, the refusal/expiry/transport
|
|
262
|
+
// distinctions, the bounded wait. Every one of those was learned from a payment
|
|
263
|
+
// that went wrong, and no second host should have to learn them again.
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
export {
|
|
266
|
+
useRedirectActivation,
|
|
267
|
+
type RedirectActivation,
|
|
268
|
+
type RedirectActivationOptions,
|
|
269
|
+
} from './activation/use-redirect-activation';
|
|
270
|
+
export { type RedirectActivationCopy } from './activation/copy';
|
|
271
|
+
export {
|
|
272
|
+
creationFailure,
|
|
273
|
+
postActivation,
|
|
274
|
+
refusedByProvider,
|
|
275
|
+
settleActivationPoll,
|
|
276
|
+
type ActivationClock,
|
|
277
|
+
type ActivationPendingBody,
|
|
278
|
+
type ActivationPollBody,
|
|
279
|
+
type RedirectActivationState,
|
|
280
|
+
type SettlePollIo,
|
|
281
|
+
} from './activation/redirect-state';
|
|
282
|
+
export {
|
|
283
|
+
clearReturnedSettlement,
|
|
284
|
+
takeReturnedSettlement,
|
|
285
|
+
RETURNED_SETTLEMENT_KEY,
|
|
286
|
+
} from './activation/returned-settlement';
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// The connect ROUND TRIP's other end (FUT-763): what the OAuth callback
|
|
290
|
+
// redirected back with, taken out of the address bar once. The codes are a
|
|
291
|
+
// union so a host's copy map is exhaustiveness-checked; the sentences stay the
|
|
292
|
+
// host's, as everywhere else in this package.
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
export {
|
|
295
|
+
takeConnectReturn,
|
|
296
|
+
useConnectReturn,
|
|
297
|
+
type ConnectErrorCode,
|
|
298
|
+
type ConnectReturn,
|
|
299
|
+
} from './components/connect-return';
|
|
300
|
+
|
|
246
301
|
// ---------------------------------------------------------------------------
|
|
247
302
|
// The ACTIVATION CHARGE (FUT-463, packaged by FUT-763) — proving a connection
|
|
248
303
|
// can charge, for a provider whose payer pays HERE.
|