@12-apps/payments-frontend 3.2.3 → 3.3.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.2.3",
3
+ "version": "3.3.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.5.0",
20
+ "@12-apps/payments-backend": "^4.12.0",
21
21
  "react-qr-code": "^2.2.0"
22
22
  },
23
23
  "peerDependencies": {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The three sentences the card activation charge has to put on screen itself
3
+ * (FUT-763).
4
+ *
5
+ * Same rule as `RedirectActivationCopy`, for the same reason: these states are
6
+ * reached inside the flow and carry no message from the provider, so something
7
+ * has to be shown — and a fallback string compiled into the package is how one
8
+ * product's voice reaches every adopter. No defaults, and the field is required.
9
+ */
10
+ export interface ActivationChargeCopy {
11
+ /**
12
+ * No tokenizer is registered for this provider, so nothing can be encrypted
13
+ * and there is no charge to make.
14
+ *
15
+ * `{provider}` is substituted with the provider's name — the one word that
16
+ * makes the sentence actionable on a screen listing several.
17
+ */
18
+ noTokenizer: string;
19
+ /** The server refused the charge and sent no reason of its own. */
20
+ chargeFailed: string;
21
+ /** The request never got out — the browser's own fetch threw. */
22
+ unreachable: string;
23
+ }
@@ -0,0 +1,267 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import type React from 'react';
5
+
6
+ import {
7
+ detectBrand,
8
+ onlyDigits,
9
+ tokenizeCard,
10
+ tokenizerFor,
11
+ validateCardNumber,
12
+ validateCpf,
13
+ validateCvv,
14
+ validateExpiry,
15
+ validateHolder,
16
+ type CardDetails,
17
+ type CardFieldErrors,
18
+ } from '../card';
19
+
20
+ import type { ActivationChargeCopy } from './charge-copy';
21
+
22
+ /**
23
+ * The activation charge for a provider whose payer pays HERE (FUT-463, moved
24
+ * into the package by FUT-763).
25
+ *
26
+ * A connection is not a capability. An OAuth grant completing tells you the
27
+ * owner authorized us; it does not tell you the account can take money, and the
28
+ * gap between those two is where a store ships broken — connected, switched on,
29
+ * and every real shopper met an access error because the integration had never
30
+ * been homologated.
31
+ *
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.
36
+ *
37
+ * The sibling of `useRedirectActivation`, for the other half of the same step:
38
+ * that one is for a provider whose payer leaves for its own page. Both prove
39
+ * the same fact and both leave the SCREEN to the host.
40
+ */
41
+
42
+ const EMPTY_CARD: CardDetails = { number: '', holder: '', expiry: '', cvv: '' };
43
+
44
+ export type ActivationChargeState =
45
+ | { kind: 'idle' }
46
+ | { kind: 'submitting' }
47
+ /**
48
+ * The charge did not go through — the provider's reason, verbatim enough to
49
+ * act on. `providerMessage` carries the provider's RAW refusal when `reason`
50
+ * is a rewording of it, so the screen can show both.
51
+ */
52
+ | { kind: 'failed'; reason: string; providerMessage?: string }
53
+ /** Money moved. The server has already enabled the provider. */
54
+ | { kind: 'passed'; refunded: boolean };
55
+
56
+ export interface ActivationChargeOptions {
57
+ /**
58
+ * The host's verify-charge endpoint for this provider.
59
+ *
60
+ * `GET` answers the store's card public key; `POST` takes the tokenized card
61
+ * and makes the charge. A whole URL, not the parts of one — the route shape
62
+ * belongs to the host.
63
+ */
64
+ verifyChargeUrl: string;
65
+ /**
66
+ * Which provider is being activated.
67
+ *
68
+ * Named rather than derived from a capability: two providers can both declare
69
+ * `tokenization: 'PUBLIC_KEY'` while speaking different protocols, so
70
+ * choosing by capability would silently mint one vendor's blob with another's
71
+ * key and report the second's rejection as though the card were bad.
72
+ */
73
+ provider: string;
74
+ /** The signed-in owner's e-mail — the charge's customer record. */
75
+ email: string;
76
+ /** The charge landed; the caller refreshes so the provider shows as active. */
77
+ onVerified: () => void;
78
+ copy: ActivationChargeCopy;
79
+ }
80
+
81
+ export interface ActivationCharge {
82
+ card: CardDetails;
83
+ setCard: React.Dispatch<React.SetStateAction<CardDetails>>;
84
+ fieldErrors: CardFieldErrors;
85
+ setFieldErrors: React.Dispatch<React.SetStateAction<CardFieldErrors>>;
86
+ cpf: string;
87
+ setCpf: (value: string) => void;
88
+ cpfError: string | undefined;
89
+ state: ActivationChargeState;
90
+ submit: () => Promise<void>;
91
+ /** Back to the form from a settled state, to try another card. */
92
+ reset: () => void;
93
+ }
94
+
95
+ /**
96
+ * The store's own card public key, fetched through the VERIFICATION endpoint.
97
+ *
98
+ * Not the checkout one: that reads credentials through the enabled gate, and a
99
+ * provider being verified is by definition still disabled.
100
+ */
101
+ function usePublicKey(verifyChargeUrl: string): string | null {
102
+ const [publicKey, setPublicKey] = useState<string | null>(null);
103
+
104
+ useEffect(() => {
105
+ const alive = { current: true };
106
+ void fetch(verifyChargeUrl)
107
+ .then((res) => (res.ok ? (res.json() as Promise<{ publicKey?: string | null }>) : null))
108
+ .then((body) => {
109
+ if (alive.current && body?.publicKey) setPublicKey(body.publicKey);
110
+ })
111
+ .catch(() => undefined);
112
+ return () => {
113
+ alive.current = false;
114
+ };
115
+ }, [verifyChargeUrl]);
116
+
117
+ return publicKey;
118
+ }
119
+
120
+ /** Local validation — nothing reaches the provider until the card is well-formed. */
121
+ function validateAll(card: CardDetails, cpf: string) {
122
+ const brand = detectBrand(onlyDigits(card.number));
123
+ return {
124
+ fieldErrors: {
125
+ number: validateCardNumber(card.number),
126
+ holder: validateHolder(card.holder),
127
+ expiry: validateExpiry(card.expiry),
128
+ cvv: validateCvv(card.cvv, brand),
129
+ } satisfies CardFieldErrors,
130
+ cpfError: validateCpf(cpf),
131
+ };
132
+ }
133
+
134
+ interface ChargeRequest {
135
+ verifyChargeUrl: string;
136
+ provider: string;
137
+ card: CardDetails;
138
+ cpf: string;
139
+ publicKey: string | null;
140
+ email: string;
141
+ copy: ActivationChargeCopy;
142
+ }
143
+
144
+ /**
145
+ * Tokenize the owner's card and ask the server to charge the cent.
146
+ *
147
+ * Strict tokenization on purpose: with no public key there is no encryption, so
148
+ * there would be nothing for the provider to accept or refuse — and a mock
149
+ * token that "passed" would switch on a store that cannot charge.
150
+ */
151
+ async function runCharge(request: ChargeRequest): Promise<ActivationChargeState> {
152
+ const tokenizer = tokenizerFor(request.provider);
153
+ if (!tokenizer) {
154
+ return {
155
+ kind: 'failed',
156
+ reason: request.copy.noTokenizer.replace('{provider}', request.provider),
157
+ };
158
+ }
159
+
160
+ const tokenized = await tokenizeCard(request.card, request.publicKey, tokenizer);
161
+ if (!tokenized.ok) return { kind: 'failed', reason: tokenized.error };
162
+
163
+ try {
164
+ const response = await fetch(request.verifyChargeUrl, {
165
+ method: 'POST',
166
+ headers: { 'content-type': 'application/json' },
167
+ body: JSON.stringify({
168
+ token: tokenized.data.token,
169
+ taxId: onlyDigits(request.cpf),
170
+ holderName: request.card.holder.trim(),
171
+ email: request.email,
172
+ }),
173
+ });
174
+ const body = (await response.json().catch(() => null)) as
175
+ | { ok?: boolean; refunded?: boolean; reason?: string; providerMessage?: string }
176
+ | null;
177
+
178
+ if (!body?.ok) {
179
+ return {
180
+ kind: 'failed',
181
+ reason: body?.reason ?? request.copy.chargeFailed,
182
+ providerMessage: body?.providerMessage,
183
+ };
184
+ }
185
+ return { kind: 'passed', refunded: body.refunded === true };
186
+ } catch {
187
+ return { kind: 'failed', reason: request.copy.unreachable };
188
+ }
189
+ }
190
+
191
+ /** The typed-in card + CPF and their validation messages. */
192
+ function useCardForm() {
193
+ const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
194
+ const [fieldErrors, setFieldErrors] = useState<CardFieldErrors>({});
195
+ const [cpf, setCpf] = useState('');
196
+ const [cpfError, setCpfError] = useState<string | undefined>(undefined);
197
+
198
+ const clear = useCallback(() => {
199
+ setCard(EMPTY_CARD);
200
+ setCpf('');
201
+ }, []);
202
+
203
+ return { card, setCard, fieldErrors, setFieldErrors, cpf, setCpf, cpfError, setCpfError, clear };
204
+ }
205
+
206
+ export function useActivationCharge(options: ActivationChargeOptions): ActivationCharge {
207
+ const { verifyChargeUrl, provider, email, onVerified, copy } = options;
208
+ const publicKey = usePublicKey(verifyChargeUrl);
209
+ const form = useCardForm();
210
+ const [state, setState] = useState<ActivationChargeState>({ kind: 'idle' });
211
+ const { card, cpf, setFieldErrors, setCpfError, clear } = form;
212
+
213
+ const submit = useCallback(async () => {
214
+ const validation = validateAll(card, cpf);
215
+ setFieldErrors(validation.fieldErrors);
216
+ setCpfError(validation.cpfError);
217
+ if (Object.values(validation.fieldErrors).some(Boolean) || validation.cpfError) return;
218
+
219
+ setState({ kind: 'submitting' });
220
+ const next = await runCharge({
221
+ verifyChargeUrl,
222
+ provider,
223
+ card,
224
+ cpf,
225
+ publicKey,
226
+ email,
227
+ copy,
228
+ });
229
+
230
+ // The card is cleared only once it has served its purpose; a failure leaves
231
+ // it typed in so the owner can fix one field rather than start over.
232
+ if (next.kind === 'passed') clear();
233
+ setState(next);
234
+ if (next.kind === 'passed') onVerified();
235
+ }, [
236
+ card,
237
+ cpf,
238
+ publicKey,
239
+ verifyChargeUrl,
240
+ provider,
241
+ email,
242
+ copy,
243
+ onVerified,
244
+ setFieldErrors,
245
+ setCpfError,
246
+ clear,
247
+ ]);
248
+
249
+ const reset = useCallback(() => {
250
+ setState({ kind: 'idle' });
251
+ setFieldErrors({});
252
+ setCpfError(undefined);
253
+ }, [setFieldErrors, setCpfError]);
254
+
255
+ return {
256
+ card: form.card,
257
+ setCard: form.setCard,
258
+ fieldErrors: form.fieldErrors,
259
+ setFieldErrors: form.setFieldErrors,
260
+ cpf: form.cpf,
261
+ setCpf: form.setCpf,
262
+ cpfError: form.cpfError,
263
+ state,
264
+ submit,
265
+ reset,
266
+ };
267
+ }
@@ -177,21 +177,40 @@ function handOverToProvider(
177
177
  /**
178
178
  * How long the resumed screen keeps asking, and how often.
179
179
  *
180
- * 180 polls at 5 s 15 minutes. Both halves are chosen against what actually
181
- * settles a hosted charge, which is the WEBHOOK: it lands seconds after the
182
- * payment, so a faster interval buys nothing, and by a quarter of an hour a
183
- * delivery that was ever coming has come. Past that the answer is not going to
184
- * change while the buyer watches — the scheduled reconciliation is what
185
- * rescues a genuinely late one, and it does that whether the tab is open or
186
- * not.
180
+ * TWO RATES, because one rate cannot serve this wait. The interval decides two
181
+ * things that pull opposite ways: how fast a buyer WHO PAID is told so, and
182
+ * what an abandoned checkout costs for the rest of the window. Every poll is a
183
+ * provider round trip, so a slow rate is cheap and leaves a paying buyer
184
+ * watching a spinner seconds longer than they need to and the person on this
185
+ * screen has almost always paid. A single number picks one of them to lose;
186
+ * this shipped at a flat 5 s and picked the wrong one.
187
+ *
188
+ * So: 2.5 s for the first two minutes, which is where essentially every real
189
+ * webhook lands (it fires within seconds of the payment, and this rate matches
190
+ * what card and PIX already use), then 10 s for the remaining thirteen. A
191
+ * confirmation is at most 2.5 s late, and an abandoned checkout costs ~126
192
+ * polls instead of the 360 a flat 2.5 s would have.
193
+ *
194
+ * The three constants are ONE decision — 48 × 2.5 s + 78 × 10 s ≈ 15 min — so
195
+ * the cap is derived rather than typed, and cannot drift from the comment.
196
+ *
197
+ * Fifteen minutes because by then a webhook that was ever coming has come. Past
198
+ * that the answer will not change while the buyer watches: the scheduled
199
+ * reconciliation is what rescues a genuinely late one, and it does that whether
200
+ * the tab is open or not.
187
201
  *
188
202
  * The card wait is bounded at 90 s (`CARD_AWAITING_POLL_CAP`) because a card
189
203
  * authorises inline and a buyer is holding their phone. This leg is the other
190
204
  * shape: the buyer has already been off to another site and back, and may
191
205
  * legitimately still be finishing there.
192
206
  */
193
- const HOSTED_RESUME_POLL_MS = 5_000;
194
- const HOSTED_RESUME_POLL_CAP = 180;
207
+ const HOSTED_RESUME_FAST_MS = 2_500;
208
+ const HOSTED_RESUME_SLOW_MS = 10_000;
209
+ /** Two minutes at the fast rate, before the wait is worth economising on. */
210
+ const HOSTED_RESUME_FAST_POLLS = (2 * 60_000) / HOSTED_RESUME_FAST_MS;
211
+ /** Thirteen more at the slow one — 15 minutes all told. */
212
+ const HOSTED_RESUME_POLL_CAP =
213
+ HOSTED_RESUME_FAST_POLLS + (13 * 60_000) / HOSTED_RESUME_SLOW_MS;
195
214
 
196
215
  /**
197
216
  * The leg of checkout that resumes after a hosted provider sent the buyer back
@@ -218,7 +237,9 @@ function useHostedResume(tenantSlug?: string): {
218
237
  const [order] = useState(() => takeHostedOrder(tenantSlug));
219
238
  const { status, timedOut } = usePaymentPolling(order?.orderId ?? null, {
220
239
  enabled: Boolean(order),
221
- intervalMs: HOSTED_RESUME_POLL_MS,
240
+ intervalMs: HOSTED_RESUME_FAST_MS,
241
+ slowAfterPolls: HOSTED_RESUME_FAST_POLLS,
242
+ slowIntervalMs: HOSTED_RESUME_SLOW_MS,
222
243
  maxHealthyPolls: HOSTED_RESUME_POLL_CAP,
223
244
  });
224
245
  return { order, status, timedOut };
@@ -14,11 +14,45 @@ interface PollingOptions {
14
14
  * Undefined ⇒ unbounded, today's behavior (the PIX consumer passes no cap).
15
15
  */
16
16
  maxHealthyPolls?: number;
17
+ /**
18
+ * Opt-in BACKOFF: after this many healthy polls, keep asking at
19
+ * {@link slowIntervalMs} instead of {@link intervalMs}.
20
+ *
21
+ * A single interval cannot serve a long wait, because the two things it
22
+ * decides pull opposite ways. It is how fast a buyer WHO PAID learns that
23
+ * they did — every poll is a provider round trip, and the answer lands within
24
+ * seconds of the webhook — and it is also what an abandoned checkout costs
25
+ * for the rest of the window. Tuning one picks the other's loser: a slow
26
+ * interval taxes the common case (the person on this screen almost always
27
+ * paid) to subsidise the rare one.
28
+ *
29
+ * Splitting them costs neither. Both must be set for backoff to apply.
30
+ */
31
+ slowAfterPolls?: number;
32
+ slowIntervalMs?: number;
17
33
  }
18
34
 
19
35
  /** Consecutive poll errors tolerated before giving up (avoids an infinite spinner). */
20
36
  const MAX_POLL_ERRORS = 4;
21
37
 
38
+ /**
39
+ * How long before the next ask, given how many healthy polls have happened.
40
+ *
41
+ * A pure function of the options, so it lives out here rather than inside the
42
+ * effect — the hook is at its size gate, and a scheduling RULE is easier to
43
+ * read (and to test) stated once than threaded through a closure.
44
+ *
45
+ * Reads the count AFTER the poll just made, so the slow phase begins on the
46
+ * poll FOLLOWING the threshold rather than one early: a wait described as "N
47
+ * fast polls" has to actually make N of them.
48
+ */
49
+ function pollDelay(healthy: number, options: PollingOptions): number {
50
+ const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
51
+ const backingOff =
52
+ slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
53
+ return backingOff ? slowIntervalMs : intervalMs;
54
+ }
55
+
22
56
  /**
23
57
  * Poll an order's payment status until it reaches a terminal state.
24
58
  *
@@ -29,7 +63,13 @@ const MAX_POLL_ERRORS = 4;
29
63
  */
30
64
  export function usePaymentPolling(
31
65
  orderId: string | null,
32
- { intervalMs = 2500, enabled = true, maxHealthyPolls }: PollingOptions = {},
66
+ {
67
+ intervalMs = 2500,
68
+ enabled = true,
69
+ maxHealthyPolls,
70
+ slowAfterPolls,
71
+ slowIntervalMs,
72
+ }: PollingOptions = {},
33
73
  ): { status: OrderStatus | null; error: string | null; timedOut: boolean } {
34
74
  const [status, setStatus] = useState<OrderStatus | null>(null);
35
75
  const [error, setError] = useState<string | null>(null);
@@ -80,7 +120,7 @@ export function usePaymentPolling(
80
120
  }
81
121
  timer = setTimeout(() => {
82
122
  void tick();
83
- }, intervalMs);
123
+ }, pollDelay(healthyCount, { intervalMs, slowAfterPolls, slowIntervalMs }));
84
124
  };
85
125
 
86
126
  void tick();
@@ -91,7 +131,7 @@ export function usePaymentPolling(
91
131
  clearTimeout(timer);
92
132
  }
93
133
  };
94
- }, [orderId, intervalMs, enabled, maxHealthyPolls, client]);
134
+ }, [orderId, intervalMs, enabled, maxHealthyPolls, slowAfterPolls, slowIntervalMs, client]);
95
135
 
96
136
  return { status, error, timedOut };
97
137
  }
@@ -99,16 +99,22 @@ function buildHostedHandoff(runtime: FlowsRuntime): CheckoutScreens["HostedHando
99
99
  /**
100
100
  * How long this screen keeps asking, and how often — see the twin constants in
101
101
  * `use-checkout-controller.ts`, which bounds the same wait for the components
102
- * layer. 180 polls at 5 s 15 minutes.
102
+ * layer. 2.5 s for two minutes, then 10 s for thirteen: 126 polls, 15 minutes.
103
+ *
104
+ * Two rates because one cannot serve both ends of this wait — a paying buyer
105
+ * learns within 2.5 s, an abandoned checkout costs a third of what a flat fast
106
+ * rate would. The reasoning is on the twin constants.
103
107
  *
104
108
  * Stated here rather than imported because the two waits are the same DECISION
105
- * arrived at twice, not one shared implementation: this screen takes its
109
+ * arrived at twice, not one shared implementation: this screen takes its FAST
106
110
  * interval from the host's `polling` config when there is one, and a host that
107
111
  * tunes that must not have this package's cap silently mean a different
108
112
  * wall-clock window than the constant's comment claims.
109
113
  */
110
- const RETURN_POLL_MS = 5_000;
111
- const RETURN_POLL_CAP = 180;
114
+ const RETURN_FAST_MS = 2_500;
115
+ const RETURN_SLOW_MS = 10_000;
116
+ const RETURN_FAST_POLLS = (2 * 60_000) / RETURN_FAST_MS;
117
+ const RETURN_POLL_CAP = RETURN_FAST_POLLS + (13 * 60_000) / RETURN_SLOW_MS;
112
118
 
113
119
  function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn"] {
114
120
  function HostedReturnBody({
@@ -125,7 +131,9 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
125
131
  // watches until they close the tab.
126
132
  const { status, timedOut } = usePaymentPolling(parked?.orderId ?? null, {
127
133
  enabled: Boolean(parked),
128
- intervalMs: runtime.config.polling?.intervalMs ?? RETURN_POLL_MS,
134
+ intervalMs: runtime.config.polling?.intervalMs ?? RETURN_FAST_MS,
135
+ slowAfterPolls: RETURN_FAST_POLLS,
136
+ slowIntervalMs: RETURN_SLOW_MS,
129
137
  maxHealthyPolls: RETURN_POLL_CAP,
130
138
  });
131
139
 
package/src/index.ts CHANGED
@@ -242,3 +242,23 @@ export {
242
242
  * taking a direct dependency on the backend package.
243
243
  */
244
244
  export type { PaymentEnvironment } from '@12-apps/payments-backend';
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // The ACTIVATION CHARGE (FUT-463, packaged by FUT-763) — proving a connection
248
+ // can charge, for a provider whose payer pays HERE.
249
+ //
250
+ // A connection is not a capability: a completed grant says the owner authorized
251
+ // us, not that the account can take money. The owner's own card goes through
252
+ // the SAME path a shopper's does — same fields, same validation, same
253
+ // browser-side encryption — for one cent, refunded immediately.
254
+ //
255
+ // The sibling of `useRedirectActivation` for the other half of the same step.
256
+ // As there, the SCREEN stays the host's.
257
+ // ---------------------------------------------------------------------------
258
+ export {
259
+ useActivationCharge,
260
+ type ActivationCharge,
261
+ type ActivationChargeOptions,
262
+ type ActivationChargeState,
263
+ } from './activation/use-activation-charge';
264
+ export { type ActivationChargeCopy } from './activation/charge-copy';