@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.3.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": {
@@ -17,7 +17,7 @@
17
17
  "storybook:build": "storybook build"
18
18
  },
19
19
  "dependencies": {
20
- "@12-apps/payments-backend": "^4.12.0",
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.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
+ }
@@ -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 one cent,
34
- * refunded immediately. Whatever would break for a buyer breaks here, in front
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
- * The store's own card public key, fetched through the VERIFICATION endpoint.
110
+ * What the verification endpoint says about the charge BEFORE it is made.
97
111
  *
98
- * Not the checkout one: that reads credentials through the enabled gate, and a
99
- * provider being verified is by definition still disabled.
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
- function usePublicKey(verifyChargeUrl: string): string | null {
102
- const [publicKey, setPublicKey] = useState<string | null>(null);
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<{ publicKey?: string | null }>) : null))
155
+ .then((res) => (res.ok ? (res.json() as Promise<ProbeBody>) : null))
108
156
  .then((body) => {
109
- if (alive.current && body?.publicKey) setPublicKey(body.publicKey);
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 publicKey;
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 publicKey = usePublicKey(verifyChargeUrl);
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.