@12-apps/payments-frontend 3.4.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
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": {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -252,6 +252,39 @@ export {
|
|
|
252
252
|
*/
|
|
253
253
|
export type { PaymentEnvironment } from '@12-apps/payments-backend';
|
|
254
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
|
+
|
|
255
288
|
// ---------------------------------------------------------------------------
|
|
256
289
|
// The connect ROUND TRIP's other end (FUT-763): what the OAuth callback
|
|
257
290
|
// redirected back with, taken out of the address bar once. The codes are a
|