@12-apps/payments-frontend 3.4.0 → 3.6.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.6.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.13.0",
20
+ "@12-apps/payments-backend": "^4.14.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
+ }