@12-apps/payments-frontend 3.21.4 → 3.23.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.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/basket.ts +85 -0
  3. package/src/components/checkout/card-outcome.ts +81 -0
  4. package/src/components/checkout/card-view.tsx +62 -22
  5. package/src/components/checkout/checkout-actions.ts +387 -0
  6. package/src/components/checkout/checkout-flow.tsx +126 -24
  7. package/src/components/checkout/checkout-steps.tsx +149 -174
  8. package/src/components/checkout/checkout-totals.tsx +51 -0
  9. package/src/components/checkout/client-context.tsx +3 -0
  10. package/src/components/checkout/confirmation-wait.ts +97 -0
  11. package/src/components/checkout/dados-step.tsx +141 -0
  12. package/src/components/checkout/decline.ts +48 -0
  13. package/src/components/checkout/en-US.ts +41 -0
  14. package/src/components/checkout/failure-codes.ts +82 -0
  15. package/src/components/checkout/hosted-return.ts +190 -206
  16. package/src/components/checkout/hosted-store.ts +291 -0
  17. package/src/components/checkout/payment-error-panel.tsx +9 -3
  18. package/src/components/checkout/payment-status-parts.tsx +311 -0
  19. package/src/components/checkout/payment-status.tsx +69 -264
  20. package/src/components/checkout/pix-view.tsx +97 -8
  21. package/src/components/checkout/poll-loop.ts +5 -3
  22. package/src/components/checkout/providers/types.ts +20 -3
  23. package/src/components/checkout/pt-BR.ts +42 -0
  24. package/src/components/checkout/screens-copy.ts +14 -0
  25. package/src/components/checkout/screens-en-US.ts +1 -0
  26. package/src/components/checkout/screens-pt-BR.ts +3 -0
  27. package/src/components/checkout/transport.ts +21 -1
  28. package/src/components/checkout/types.ts +35 -0
  29. package/src/components/checkout/use-card-checkout.ts +34 -33
  30. package/src/components/checkout/use-checkout-controller.ts +68 -274
  31. package/src/components/checkout/use-hosted-resume.ts +326 -0
  32. package/src/components/checkout/use-payment-polling.ts +58 -7
  33. package/src/components/checkout/use-wallet-charge.ts +24 -1
  34. package/src/components/checkout/view-copy.ts +70 -0
  35. package/src/components/checkout/wallet-pane.tsx +9 -1
  36. package/src/flows/catalog-exit.ts +33 -0
  37. package/src/flows/create-payment-flows.tsx +19 -1
  38. package/src/flows/pipeline/actions.tsx +104 -0
  39. package/src/flows/pipeline/admission.ts +55 -0
  40. package/src/flows/pipeline/context.ts +140 -0
  41. package/src/flows/pipeline/derive-step.ts +234 -0
  42. package/src/flows/pipeline/engine-actions.ts +257 -0
  43. package/src/flows/pipeline/engine-chrome.tsx +123 -0
  44. package/src/flows/pipeline/engine-state.ts +107 -0
  45. package/src/flows/pipeline/engine.tsx +377 -0
  46. package/src/flows/pipeline/methods.ts +71 -0
  47. package/src/flows/pipeline/refusal-routing.ts +106 -0
  48. package/src/flows/pipeline/slices.ts +110 -0
  49. package/src/flows/pipeline/stable-plugins.ts +72 -0
  50. package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
  51. package/src/flows/pipeline/steps/index.ts +54 -0
  52. package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
  53. package/src/flows/pipeline/steps/status-step.tsx +41 -0
  54. package/src/flows/pipeline/types.ts +232 -0
  55. package/src/flows/public.ts +78 -0
  56. package/src/flows/screens-hosted.tsx +55 -5
  57. package/src/flows/screens-pay.tsx +6 -1
  58. package/src/flows/types.ts +25 -2
  59. package/src/index.ts +29 -19
@@ -217,4 +217,18 @@ export interface CheckoutScreensCopy {
217
217
  validation: CheckoutValidationCopy;
218
218
  /** The step wrapper's own wait, while the charge is being raised. */
219
219
  generatingPayment: string;
220
+ /**
221
+ * The pay bar's money caption — "Total · 2 itens".
222
+ *
223
+ * A FUNCTION because it inflects on the count, which is a property of the
224
+ * language rather than of the checkout: one item and two items are not the
225
+ * same word, and they are not the same word in different ways per language.
226
+ *
227
+ * It was a hard-coded Portuguese template inside the component until
228
+ * FUT-1179, which is the ticket about a checkout that never showed the
229
+ * amount — so the fix put that caption on a SECOND step and made a
230
+ * single-locale string render twice. Everything else on both bars had been a
231
+ * host's word since FUT-760; this one had simply been missed.
232
+ */
233
+ totalCaption(items: number): string;
220
234
  }
@@ -114,4 +114,5 @@ export const EN_US_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
114
114
  required: 'This field is required.',
115
115
  },
116
116
  generatingPayment: 'Generating payment…',
117
+ totalCaption: (items) => `Total · ${items} ${items === 1 ? 'item' : 'items'}`,
117
118
  };
@@ -100,4 +100,7 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
100
100
  required: 'Campo obrigatório.',
101
101
  },
102
102
  generatingPayment: 'Gerando pagamento…',
103
+ // Verbatim what the component rendered before the key existed, so nothing
104
+ // changes for a Brazilian shopper.
105
+ totalCaption: (items) => `Total · ${items} ${items === 1 ? 'item' : 'itens'}`,
103
106
  };
@@ -96,10 +96,20 @@ export interface CheckoutTransport {
96
96
  export type CheckoutTransportBinding = Omit<CheckoutTransport, "copy"> &
97
97
  Partial<Pick<CheckoutTransport, "copy">>;
98
98
 
99
- /** The eight calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
99
+ /** The nine calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
100
100
  export interface CheckoutClient {
101
101
  getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
102
102
  getStatus(ref: string): Promise<Result<OrderStatus>>;
103
+ /**
104
+ * `POST /release` (FUT-1146): the buyer says they did not pay, and the
105
+ * charge they were sent away for has no terminal state of its own.
106
+ *
107
+ * Answers the payable's status AFTER the server has re-asked the provider,
108
+ * so a payment that actually succeeded comes back `PAID` and nothing is
109
+ * released — the race against a late webhook resolves in the shopper's
110
+ * favour rather than against it.
111
+ */
112
+ releaseCheckout(input: { orderId: string }): Promise<Result<OrderStatus>>;
103
113
  charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
104
114
  /** A wallet instrument against the same `/charge` route (FUT-471/472). */
105
115
  chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>>;
@@ -266,6 +276,16 @@ export function createCheckoutClient(transport: CheckoutTransport): CheckoutClie
266
276
  { method: "GET" },
267
277
  ),
268
278
 
279
+ releaseCheckout: (input) =>
280
+ // The settlement hints ride in the QUERY, exactly as the poll's do: they
281
+ // are the only way a hosted provider can be asked anything at all, so a
282
+ // release must carry them or the server decides "not paid" from a
283
+ // question it was never able to put.
284
+ call<OrderStatus>(`/release?${new URLSearchParams(returnedSettlement()).toString()}`, {
285
+ method: "POST",
286
+ body: JSON.stringify({ orderId: input.orderId }),
287
+ }),
288
+
269
289
  charge: (input) =>
270
290
  call<ChargeOutcome>("/charge", { method: "POST", body: flatChargeBody(input) }),
271
291
 
@@ -9,6 +9,8 @@
9
9
  * the ports declared on `CheckoutFlowProps`.
10
10
  */
11
11
 
12
+ import type { CheckoutDecline, CheckoutDeclineReason } from "./decline";
13
+
12
14
  /** Payment methods offered at checkout. Mirrors `Payment.method` (FUT-42). */
13
15
  export type PaymentMethod = "PIX" | "CARD";
14
16
 
@@ -34,6 +36,16 @@ export type OrderStatus = "AWAITING_PAYMENT" | "PAID" | "FAILED" | "EXPIRED";
34
36
  /** Terminal states — polling stops once the order reaches one of these. */
35
37
  export const TERMINAL_STATUSES: readonly OrderStatus[] = ["PAID", "FAILED", "EXPIRED"];
36
38
 
39
+ /**
40
+ * What a payment pane calls once the charge has an answer.
41
+ *
42
+ * The refusal rides along OPTIONALLY (FUT-1145): a pane that has one hands it
43
+ * over so the confirmation screen can say which refusal this was and whether
44
+ * another instrument could work, and every caller that has nothing to add keeps
45
+ * calling this with one argument exactly as before.
46
+ */
47
+ export type OnCheckoutResolved = (status: OrderStatus, decline?: CheckoutDecline | null) => void;
48
+
37
49
  /** Buyer contact captured at checkout. */
38
50
  export interface BuyerInfo {
39
51
  name?: string;
@@ -173,6 +185,17 @@ export interface CreateOrderRequest {
173
185
  buyer: BuyerInfo;
174
186
  /** Opt-in: save buyer name/phone (not CPF — never persisted here) for next-checkout pre-fill. */
175
187
  saveProfile: boolean;
188
+ /**
189
+ * WHICH settlement the buyer chose, when it is not one of the two above
190
+ * (FUT-1240).
191
+ *
192
+ * `method` is what the CHAIN can be asked to charge, so a pipeline host's own
193
+ * registered settlement — "pay the courier", "pay the waiter" — cannot be
194
+ * said there, and without this every one of them arrives as the same request.
195
+ * ABSENT for `PIX` and `CARD`, which is what keeps it additive; otherwise the
196
+ * registered `SettlementMethodDescriptor.id` verbatim (`flows/pipeline`).
197
+ */
198
+ settlementMethod?: string;
176
199
  }
177
200
 
178
201
  /** What the flow hands the host's `saveBuyerContact` port on "Continuar". */
@@ -358,8 +381,20 @@ export interface CheckoutProviderConfig {
358
381
  * present when the provider demands the buyer finish the charge on ITS page —
359
382
  * Stripe's redirect-based 3-D Secure — and the client then hands the buyer
360
383
  * over exactly as it does for a redirect provider's link (FUT-556).
384
+ *
385
+ * `declineReason` / `retriable` ride along on a refusal (FUT-1145). The server
386
+ * has classified every acquirer decline since FUT-340 — 33 PagBank codes with
387
+ * issuer sub-reasons, each carrying the vendor's own retry verdict — and then
388
+ * answered `{ status }` and threw the classification away, so an expired card,
389
+ * a stolen card and "attempts exhausted, do not retry" all reached the buyer as
390
+ * "Pagamento não concluído. Você pode tentar novamente."
391
+ *
392
+ * BOTH OPTIONAL, and the degrade direction is today's behaviour: a server that
393
+ * sends neither renders exactly the generic refusal it always did.
361
394
  */
362
395
  export interface ChargeOutcome {
363
396
  status: OrderStatus;
364
397
  hostedCheckoutUrl?: string;
398
+ declineReason?: CheckoutDeclineReason;
399
+ retriable?: boolean;
365
400
  }
@@ -22,12 +22,12 @@ import {
22
22
  type CardInstruments,
23
23
  type RefreshBrowserKey,
24
24
  } from "./card-instruments";
25
+ import { handOverToChallenge, reportResolved, type ChallengeScope } from "./card-outcome";
25
26
  import { useCheckoutClientApi } from "./client-context";
26
- import { rememberHostedOrder } from "./hosted-return";
27
- import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
27
+ import { useCheckoutNavigate } from "./navigate-context";
28
28
  import type { CardChainLink } from "./method-capability";
29
29
  import { useOneClickArmed, useOneClickPay } from "./one-click";
30
- import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
30
+ import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved, OrderStatus } from "./types";
31
31
  import { usePaymentPolling } from "./use-payment-polling";
32
32
  import type { CardCopy } from "../../card/copy";
33
33
  import { useCheckoutCopy } from "./copy-context";
@@ -40,7 +40,17 @@ const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
40
40
  * actually charge are offered. The slug arrives as an argument (the host's
41
41
  * routing owns it); absent, the list is unscoped, exactly as before FUT-697.
42
42
  */
43
- function useSavedCards(tenantSlug: string | undefined): {
43
+ function useSavedCards(
44
+ tenantSlug: string | undefined,
45
+ /**
46
+ * The buyer is here because a card was REFUSED (FUT-1145), so the saved card
47
+ * this would otherwise preselect is the one that just failed. The list is
48
+ * still offered — another saved card may well work — but nothing is chosen
49
+ * for them, which puts the form in front of a buyer whose only untried
50
+ * instrument is a new one.
51
+ */
52
+ freshInstrument: boolean,
53
+ ): {
44
54
  savedCards: SavedCard[];
45
55
  selection: string;
46
56
  setSelection: (id: string) => void;
@@ -55,12 +65,12 @@ function useSavedCards(tenantSlug: string | undefined): {
55
65
  if (!active) return;
56
66
  setSavedCards(cards);
57
67
  const first = cards[0];
58
- if (first) setSelection(first.id);
68
+ if (first && !freshInstrument) setSelection(first.id);
59
69
  });
60
70
  return () => {
61
71
  active = false;
62
72
  };
63
- }, [tenantSlug, client]);
73
+ }, [tenantSlug, client, freshInstrument]);
64
74
 
65
75
  return { savedCards, selection, setSelection };
66
76
  }
@@ -159,29 +169,12 @@ type CardSubmit = Pick<
159
169
  * It was 36 healthy polls, which is the same 90 s at the default 2500 ms
160
170
  * interval and an unbounded wait at any other — including the one that
161
171
  * mattered, where every poll is FAILING and the healthy count never moves.
162
- * PIX passes no bound at all and keeps today's behavior: its charge expires
163
- * server-side and comes back as a terminal EXPIRED.
172
+ * PIX is bounded too since FUT-1170, but by the CHARGE's own expiry rather
173
+ * than by a constant: a code the buyer can still scan is still worth watching,
174
+ * and one that has died is not.
164
175
  */
165
176
  const CARD_AWAITING_WAIT_MS = 90_000;
166
177
 
167
- /**
168
- * Hand the buyer to the provider's authentication page (FUT-698) — Stripe's
169
- * redirect-based 3-D Secure. Park the order and navigate, the same trip a
170
- * redirect provider's link takes (FUT-556): the return lands back on this
171
- * checkout route, where the hosted-resume machinery polls the parked order.
172
- */
173
- function handOverToChallenge(
174
- order: CheckoutOrder,
175
- url: string,
176
- navigate: CheckoutNavigate,
177
- ): void {
178
- // PARK FIRST. The navigation may not come back to a live SPA at all, and a
179
- // return trip that finds nothing parked lands the buyer on a blank
180
- // confirmation after they have paid.
181
- rememberHostedOrder(order);
182
- navigate(url);
183
- }
184
-
185
178
  /** The buyer's card is invalid — block the submit and show which field. */
186
179
  function blockedByForm(form: CardFormState): boolean {
187
180
  if (!form.usingNewCard) return false;
@@ -257,10 +250,13 @@ function useCardSubmit(
257
250
  order: CheckoutOrder,
258
251
  buyer: BuyerInfo,
259
252
  providerConfig: CardTokenizationConfig,
260
- onResolved: (status: OrderStatus) => void,
253
+ onResolved: OnCheckoutResolved,
261
254
  pollIntervalMs: number,
262
255
  form: CardFormState,
263
256
  providerChain: readonly CardChainLink[],
257
+ /** WHOSE store and WHICH basket this charge is for — parked with a 3DS
258
+ * hand-off, which is otherwise resumable over any basket anywhere. */
259
+ scope: ChallengeScope,
264
260
  ): CardSubmit {
265
261
  const [submitting, setSubmitting] = useState(false);
266
262
  const [submitted, setSubmitted] = useState(false);
@@ -319,11 +315,11 @@ function useCardSubmit(
319
315
  // 3-D Secure (FUT-698): the buyer must finish on the provider's page.
320
316
  // `submitting` stays true on purpose — the tab is navigating away.
321
317
  if (charged.data.hostedCheckoutUrl) {
322
- return handOverToChallenge(order, charged.data.hostedCheckoutUrl, navigate);
318
+ return handOverToChallenge(order, charged.data.hostedCheckoutUrl, navigate, scope);
323
319
  }
324
- // A business outcome (e.g. declined FAILED) shows the status screen;
325
- // an accepted charge begins polling for the async confirmation.
326
- if (charged.data.status !== "AWAITING_PAYMENT") onResolved(charged.data.status);
320
+ // A business outcome shows the status screen, carrying the refusal the
321
+ // server classified (FUT-1145); an accepted charge begins polling.
322
+ if (charged.data.status !== "AWAITING_PAYMENT") reportResolved(charged.data, onResolved);
327
323
  else setSubmitted(true);
328
324
  };
329
325
 
@@ -338,7 +334,7 @@ export function useCardCheckout(
338
334
  order: CheckoutOrder,
339
335
  buyer: BuyerInfo,
340
336
  providerConfig: CardTokenizationConfig,
341
- onResolved: (status: OrderStatus) => void,
337
+ onResolved: OnCheckoutResolved,
342
338
  pollIntervalMs: number,
343
339
  /** The store whose saved cards may be offered (host routing owns the slug). */
344
340
  tenantSlug?: string,
@@ -347,8 +343,12 @@ export function useCardCheckout(
347
343
  * per entry so a card charge can fail over. Omitted ⇒ the head alone.
348
344
  */
349
345
  providerChain: readonly CardChainLink[] = [],
346
+ /** Do not preselect a saved card — the last one was refused (FUT-1145). */
347
+ freshInstrument = false,
348
+ /** WHOSE store and WHICH basket, for the 3-D Secure hand-off's parked order. */
349
+ scope: ChallengeScope = {},
350
350
  ): CardCheckout {
351
- const { savedCards, selection, setSelection } = useSavedCards(tenantSlug);
351
+ const { savedCards, selection, setSelection } = useSavedCards(tenantSlug, freshInstrument);
352
352
  const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
353
353
  const [fieldErrors, setFieldErrors] = useState<CardFieldErrors>({});
354
354
  const [saveCard, setSaveCard] = useState(false);
@@ -373,6 +373,7 @@ export function useCardCheckout(
373
373
  pollIntervalMs,
374
374
  { card, usingNewCard, selection, saveCard, validate, setFieldErrors },
375
375
  providerChain,
376
+ scope,
376
377
  );
377
378
  // The tap a one-click buyer already made (`./one-click.tsx`). Nothing about
378
379
  // the charge differs — this only presses the button, and only while a SAVED