@12-apps/payments-frontend 3.2.2 → 3.2.4

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.2",
3
+ "version": "3.2.4",
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": {
@@ -197,6 +197,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
197
197
  onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
198
198
  onBackToMenu={c.goToMenu}
199
199
  paidExtra={confirmationExtra}
200
+ awaitingTimedOut={c.resumeTimedOut}
200
201
  />
201
202
  ) : null}
202
203
  </Box>
@@ -70,13 +70,24 @@ function isReturnTrip(): boolean {
70
70
  * take the order.
71
71
  */
72
72
  export function hostedCheckoutReturnPending(tenantSlug?: string): boolean {
73
- if (isReturnTrip()) return true;
74
73
  const parked = readParked();
75
- if (!parked) return false;
76
- // Same two questions the resume asks, so a gate and the flow behind it can
77
- // never disagree: another store's hand-off is not this route's business, and
74
+ // THE PARKED ENTRY DECIDES whenever there is one — the same two questions the
75
+ // resume asks, so a gate and the flow behind it cannot disagree about whose
76
+ // return this is. Another store's hand-off is not this route's business, and
78
77
  // a stale one is nobody's.
79
- return belongsHere(parked, tenantSlug) && !isStale(parked);
78
+ //
79
+ // Asking the URL FIRST is what this used to do, and it made the two disagree
80
+ // exactly where it costs something: a provider marker is per-TAB, so store
81
+ // A's abandoned hand-off plus any marked URL had the gate answer "a return is
82
+ // pending here" on store B while `takeHostedOrder` correctly refused to
83
+ // resume it. A gate that stands aside for a return that is not going to
84
+ // happen is a gate that has been talked out of its job.
85
+ if (parked) return belongsHere(parked, tenantSlug) && !isStale(parked);
86
+ // Nothing parked, so the provider's own marker is the only evidence left that
87
+ // a return is in progress. It is kept, and only here, for the case that has
88
+ // no other signal: the flow has already CONSUMED the entry, and a gate
89
+ // re-asking mid-visit must not curtain the confirmation it just let through.
90
+ return isReturnTrip();
80
91
  }
81
92
 
82
93
  /**
@@ -66,6 +66,28 @@ const OUTCOME: Record<OrderStatus, Outcome> = {
66
66
  },
67
67
  };
68
68
 
69
+ /**
70
+ * What the screen says once it has stopped asking (FUT-556).
71
+ *
72
+ * NOT an `OrderStatus`: the order really is still AWAITING_PAYMENT and may yet
73
+ * settle — the scheduled reconciliation keeps asking the provider long after
74
+ * this tab is gone. What ran out is the WAIT, not the order, and saying
75
+ * otherwise would be the more expensive lie: a buyer told "não concluído" who
76
+ * has in fact paid will either pay twice or call the store.
77
+ *
78
+ * So it leads with what is true and unglamorous — we have not heard yet — and
79
+ * spends its remaining words on the one instruction that matters, which is not
80
+ * to pay again.
81
+ */
82
+ const AWAITING_TIMED_OUT: Outcome = {
83
+ heading: "Ainda não recebemos a confirmação",
84
+ support:
85
+ "Se você já pagou, o pedido é confirmado assim que a operadora avisar — " +
86
+ "não pague de novo. Você pode fechar esta tela.",
87
+ icon: <ScheduleIcon fontSize="large" />,
88
+ tone: "warning",
89
+ };
90
+
69
91
  const TONE_COLOR: Record<Outcome["tone"], string> = {
70
92
  success: "success.main",
71
93
  danger: "error.main",
@@ -86,14 +108,29 @@ function orderReference(orderId: string): string {
86
108
  }
87
109
 
88
110
  /** The headline block: icon, outcome, and one supporting line. */
89
- function OutcomeHero({ status }: { status: OrderStatus }): JSX.Element {
111
+ function OutcomeHero({
112
+ status,
113
+ timedOut = false,
114
+ }: {
115
+ status: OrderStatus;
116
+ timedOut?: boolean;
117
+ }): JSX.Element {
90
118
  const { Text } = useCheckoutComponents();
91
- const outcome = OUTCOME[status];
119
+ const outcome = timedOut && status === "AWAITING_PAYMENT" ? AWAITING_TIMED_OUT : OUTCOME[status];
92
120
  return (
93
121
  <Box
94
122
  // `payment-paid` is load-bearing for the storefront journeys — it is how
95
- // they assert the buyer actually got there.
96
- data-testid={status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`}
123
+ // they assert the buyer actually got there. The timed-out wait gets its
124
+ // OWN id rather than reusing `payment-awaiting_payment`: a test that
125
+ // cannot tell "still asking" from "stopped asking" is a test that would
126
+ // pass against the unbounded spinner this replaced.
127
+ data-testid={
128
+ timedOut && status === "AWAITING_PAYMENT"
129
+ ? "payment-awaiting-timeout"
130
+ : status === "PAID"
131
+ ? "payment-paid"
132
+ : `payment-${status.toLowerCase()}`
133
+ }
97
134
  sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
98
135
  >
99
136
  <Box sx={{ color: TONE_COLOR[outcome.tone], display: "flex" }}>{outcome.icon}</Box>
@@ -206,6 +243,7 @@ export function PaymentStatus({
206
243
  onRegenerate,
207
244
  onBackToMenu,
208
245
  paidExtra,
246
+ awaitingTimedOut = false,
209
247
  }: {
210
248
  status: OrderStatus | null;
211
249
  totalLabel: string;
@@ -223,6 +261,12 @@ export function PaymentStatus({
223
261
  * is the difference between an offer and an interruption.
224
262
  */
225
263
  paidExtra?: ReactNode;
264
+ /**
265
+ * The wait has been given up on — see {@link AWAITING_TIMED_OUT}. Only
266
+ * meaningful while AWAITING_PAYMENT; every other status has already resolved,
267
+ * so a stale flag cannot change what a settled screen says.
268
+ */
269
+ awaitingTimedOut?: boolean;
226
270
  }): JSX.Element {
227
271
  const { LoadingState } = useCheckoutComponents();
228
272
  const effective: OrderStatus = status ?? "AWAITING_PAYMENT";
@@ -231,9 +275,10 @@ export function PaymentStatus({
231
275
  <Box
232
276
  data-testid="payment-status"
233
277
  data-status={effective}
278
+ data-timed-out={awaitingTimedOut ? "true" : undefined}
234
279
  sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
235
280
  >
236
- <OutcomeHero status={effective} />
281
+ <OutcomeHero status={effective} timedOut={awaitingTimedOut} />
237
282
 
238
283
  {effective === "PAID" ? (
239
284
  <PaidFacts totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
@@ -241,7 +286,7 @@ export function PaymentStatus({
241
286
 
242
287
  {effective === "PAID" ? paidExtra : null}
243
288
 
244
- {effective === "AWAITING_PAYMENT" ? (
289
+ {effective === "AWAITING_PAYMENT" && !awaitingTimedOut ? (
245
290
  <LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
246
291
  ) : null}
247
292
 
@@ -174,6 +174,44 @@ function handOverToProvider(
174
174
  return true;
175
175
  }
176
176
 
177
+ /**
178
+ * How long the resumed screen keeps asking, and how often.
179
+ *
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.
201
+ *
202
+ * The card wait is bounded at 90 s (`CARD_AWAITING_POLL_CAP`) because a card
203
+ * authorises inline and a buyer is holding their phone. This leg is the other
204
+ * shape: the buyer has already been off to another site and back, and may
205
+ * legitimately still be finishing there.
206
+ */
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;
214
+
177
215
  /**
178
216
  * The leg of checkout that resumes after a hosted provider sent the buyer back
179
217
  * (FUT-556).
@@ -182,14 +220,29 @@ function handOverToProvider(
182
220
  * parked copy — once, on first render — and polled here rather than in a PIX or
183
221
  * card view, because a redirect provider produced neither. The webhook is still
184
222
  * what settles the order; this only tells the buyer that it did.
223
+ *
224
+ * And it stops telling them eventually. This poll was the one unbounded wait
225
+ * left in checkout — card and wallet both cap theirs — so a buyer who came back
226
+ * from a payment they never completed got "Confirmando seu pagamento… isso
227
+ * costuma levar alguns segundos" and a spinner, truthfully forever. The screen
228
+ * had no way to reach a terminal state, because the ORDER has none: expiry is
229
+ * PIX-only, and a redirect charge carries no QR window to lapse. `timedOut` is
230
+ * what the screen says instead of spinning.
185
231
  */
186
232
  function useHostedResume(tenantSlug?: string): {
187
233
  order: CheckoutOrder | null;
188
234
  status: OrderStatus | null;
235
+ timedOut: boolean;
189
236
  } {
190
237
  const [order] = useState(() => takeHostedOrder(tenantSlug));
191
- const { status } = usePaymentPolling(order?.orderId ?? null, { enabled: Boolean(order) });
192
- return { order, status };
238
+ const { status, timedOut } = usePaymentPolling(order?.orderId ?? null, {
239
+ enabled: Boolean(order),
240
+ intervalMs: HOSTED_RESUME_FAST_MS,
241
+ slowAfterPolls: HOSTED_RESUME_FAST_POLLS,
242
+ slowIntervalMs: HOSTED_RESUME_SLOW_MS,
243
+ maxHealthyPolls: HOSTED_RESUME_POLL_CAP,
244
+ });
245
+ return { order, status, timedOut };
193
246
  }
194
247
 
195
248
  /**
@@ -328,6 +381,10 @@ export function useCheckoutController(
328
381
  return {
329
382
  step, setStep, method, setMethod, buyer, setBuyer, saveProfile, setSaveProfile,
330
383
  order, finalStatus: finalStatus ?? resume.status, creating,
384
+ // Only ever true on the resumed leg: `useHostedResume` is the sole caller
385
+ // that caps its polls, and a buyer who never left has a card or PIX view
386
+ // reporting its own wait.
387
+ resumeTimedOut: resume.timedOut,
331
388
  createError: failure.message, errorField: failure.field, errorCode: failure.code,
332
389
  goToMenu: onExitToMenu, back, editBuyer,
333
390
  goToPayment, startPayment, payWithEmail, handleResolved, retry, completed,
@@ -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
  }
package/src/flows/copy.ts CHANGED
@@ -35,6 +35,18 @@ export interface CheckoutCopyFE {
35
35
  returnPending: string;
36
36
  /** The return leg when nothing was parked — the buyer came back to nothing. */
37
37
  returnUnknown: string;
38
+ /**
39
+ * The return leg once the bounded wait elapsed and the screen stopped asking
40
+ * (FUT-556) — say that nothing has arrived yet and, above all, not to pay
41
+ * again.
42
+ *
43
+ * OPTIONAL, so adopting a version with the bound is not a breaking change for
44
+ * a host that has not written the sentence yet. The BOUND itself applies
45
+ * either way: with no copy the screen still stops spinning and falls back to
46
+ * `returnPending` as a warning, which is the honest half. The words are the
47
+ * part only a host can own.
48
+ */
49
+ returnTimedOut?: string;
38
50
  /** Nothing to check out (cart mode only). */
39
51
  emptyCartTitle: string;
40
52
  emptyCartAction: string;
@@ -96,6 +96,26 @@ function buildHostedHandoff(runtime: FlowsRuntime): CheckoutScreens["HostedHando
96
96
  };
97
97
  }
98
98
 
99
+ /**
100
+ * How long this screen keeps asking, and how often — see the twin constants in
101
+ * `use-checkout-controller.ts`, which bounds the same wait for the components
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.
107
+ *
108
+ * Stated here rather than imported because the two waits are the same DECISION
109
+ * arrived at twice, not one shared implementation: this screen takes its FAST
110
+ * interval from the host's `polling` config when there is one, and a host that
111
+ * tunes that must not have this package's cap silently mean a different
112
+ * wall-clock window than the constant's comment claims.
113
+ */
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;
118
+
99
119
  function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn"] {
100
120
  function HostedReturnBody({
101
121
  onResolved,
@@ -106,9 +126,15 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
106
126
  // Read-and-clear, once, on first render: the resumed view belongs to
107
127
  // exactly one return trip.
108
128
  const [parked] = useState(takeHostedOrder);
109
- const { status } = usePaymentPolling(parked?.orderId ?? null, {
129
+ // Bounded, for the reason on RETURN_POLL_CAP: nothing here can ever reach a
130
+ // terminal state on its own, so an unbounded poll is a spinner the buyer
131
+ // watches until they close the tab.
132
+ const { status, timedOut } = usePaymentPolling(parked?.orderId ?? null, {
110
133
  enabled: Boolean(parked),
111
- intervalMs: runtime.config.polling?.intervalMs,
134
+ intervalMs: runtime.config.polling?.intervalMs ?? RETURN_FAST_MS,
135
+ slowAfterPolls: RETURN_FAST_POLLS,
136
+ slowIntervalMs: RETURN_SLOW_MS,
137
+ maxHealthyPolls: RETURN_POLL_CAP,
112
138
  });
113
139
 
114
140
  useEffect(() => {
@@ -126,6 +152,21 @@ function buildHostedReturn(runtime: FlowsRuntime): CheckoutScreens["HostedReturn
126
152
  />
127
153
  );
128
154
  }
155
+ if (timedOut) {
156
+ return (
157
+ <Box data-testid="checkout-hosted-return" sx={{ py: 4 }}>
158
+ <Alert
159
+ variant="warning"
160
+ title={runtime.copy.returnPending}
161
+ {...(runtime.copy.returnTimedOut === undefined
162
+ ? {}
163
+ : { description: runtime.copy.returnTimedOut })}
164
+ showIcon
165
+ data-testid="checkout-hosted-return-timeout"
166
+ />
167
+ </Box>
168
+ );
169
+ }
129
170
  return (
130
171
  <Box data-testid="checkout-hosted-return" sx={{ py: 4 }}>
131
172
  <LoadingState