@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.4.0",
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.20.0",
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