@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.21.4",
3
+ "version": "3.23.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "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.",
@@ -22,7 +22,7 @@
22
22
  "storybook:build": "storybook build"
23
23
  },
24
24
  "dependencies": {
25
- "@12-apps/payments-backend": "^4.26.1",
25
+ "@12-apps/payments-backend": "^4.27.1",
26
26
  "react-qr-code": "^2.2.0"
27
27
  },
28
28
  "peerDependencies": {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * WHAT IS IN THE BASKET, as one comparable fact (FUT-1213).
3
+ *
4
+ * A parked checkout has to be able to ask "is this still the basket the order
5
+ * was raised from?" when the buyer comes back — and the answer cannot be the
6
+ * cart's ID. Emptying a cart ("Esvaziar carrinho") keeps the row: the same id
7
+ * comes back holding nothing, then holding something else entirely, and a
8
+ * comparison on the id says "same basket" for a shopper who has thrown the old
9
+ * one away and started again.
10
+ *
11
+ * So the identity is the LINES: which ones, and how many of each. Two baskets
12
+ * with the same lines in a different order are the same basket; one extra unit
13
+ * of one line is not.
14
+ *
15
+ * The HOST computes it, because the host owns the cart — this module only says
16
+ * what shape the answer takes and how to build one from lines, so that every
17
+ * adopter's signature is built the same way and a package-side comparison can
18
+ * mean something.
19
+ */
20
+
21
+ /** One line of the host's basket, reduced to what identity depends on. */
22
+ export interface CheckoutBasketLine {
23
+ /** The line's own stable handle — a cart-line id, never a product id alone. */
24
+ id: string;
25
+ quantity: number;
26
+ }
27
+
28
+ /**
29
+ * The lines' signature, or `null` for an empty basket.
30
+ *
31
+ * `null` rather than `""` because an EMPTY basket is a meaningful state on this
32
+ * path rather than a missing answer: the server closes a cart when its order is
33
+ * paid, so an empty basket is exactly what a buyer who paid comes back to.
34
+ *
35
+ * Sorted before joining, so the answer does not depend on the order the host
36
+ * happens to hold its lines in — a re-fetch that returns them differently
37
+ * sorted must not read as a different basket.
38
+ *
39
+ * The id is ENCODED before it is joined. `id + "x" + quantity` on a `|` join is
40
+ * ambiguous the moment an id can contain either character: `[a×1, b×2]` and the
41
+ * single line `["ax1|b" × 2]` produce the same string, and two different baskets
42
+ * that compare equal is a resume over the wrong one. Unreachable with the cuid
43
+ * and uuid ids every adopter has today — and this is exported for hosts whose
44
+ * ids nobody here has seen, so it is encoded rather than argued about.
45
+ */
46
+ export function basketSignature(lines: readonly CheckoutBasketLine[]): string | null {
47
+ if (lines.length === 0) return null;
48
+ return lines
49
+ .map((line) => `${encodeURIComponent(line.id)}x${line.quantity}`)
50
+ .sort()
51
+ .join("|");
52
+ }
53
+
54
+ /**
55
+ * The basket in front of the checkout right now, as the resume rule reads it.
56
+ *
57
+ * `ready` is not a nicety. The host's cart is fetched, so the first render of a
58
+ * checkout has an EMPTY cart that is merely unloaded — and "empty" is the one
59
+ * state the rule resumes on unconditionally (it is the paid buyer's normal
60
+ * state). Deciding against an unseeded cart would resume every abandoned
61
+ * hand-off on every mount, which is the bug this whole rule exists to remove.
62
+ * So the decision waits.
63
+ */
64
+ export interface CheckoutBasketIdentity {
65
+ /** The lines' signature; `null` ⇒ the basket is empty. */
66
+ signature: string | null;
67
+ /** False while the host's cart is still loading — decide nothing yet. */
68
+ ready: boolean;
69
+ }
70
+
71
+ /**
72
+ * The signature to PARK with an order, or nothing.
73
+ *
74
+ * `undefined` — meaning "no basket was recorded" — for a host that names none
75
+ * AND for a cart that has not answered yet. The second is the one worth
76
+ * stating: a loading cart reports `signature: null`, which is the value that
77
+ * means EMPTY, and an order parked as "raised from an empty basket" would later
78
+ * be compared against the real one and read as a different basket. Recording
79
+ * nothing is honest and degrades to the pre-1213 resume; recording `null` would
80
+ * be a fact we do not have.
81
+ */
82
+ export function parkedBasket(basket: CheckoutBasketIdentity | undefined): string | null | undefined {
83
+ if (!basket || !basket.ready) return undefined;
84
+ return basket.signature;
85
+ }
@@ -0,0 +1,81 @@
1
+ import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
2
+ import type { CheckoutDecline } from "./decline";
3
+ import { rememberHostedOrder } from "./hosted-return";
4
+ import type { CheckoutNavigate } from "./navigate-context";
5
+ import type { ChargeOutcome, CheckoutOrder, OnCheckoutResolved } from "./types";
6
+
7
+ /**
8
+ * WHAT HAPPENS TO A CHARGE'S ANSWER — the two endings a card submit can have
9
+ * that are not "keep polling".
10
+ *
11
+ * Split out of `./use-card-checkout.ts`, which is at its size ceiling. Both
12
+ * halves are about the same moment: the provider has answered, and the buyer
13
+ * is either being sent somewhere or being told something.
14
+ */
15
+
16
+ /** Whose store and which basket a challenge's parked order belongs to. */
17
+ export interface ChallengeScope {
18
+ tenantSlug?: string;
19
+ basket?: CheckoutBasketIdentity;
20
+ }
21
+
22
+ /**
23
+ * Hand the buyer to the provider's authentication page (FUT-698) — Stripe's
24
+ * redirect-based 3-D Secure. Park the order and navigate, the same trip a
25
+ * redirect provider's link takes (FUT-556): the return lands back on this
26
+ * checkout route, where the hosted-resume machinery polls the parked order.
27
+ *
28
+ * IT PARKS WITH THE SAME FACTS the other hand-off does, and that is a fix
29
+ * rather than a tidy-up: this call site named neither the store nor the basket,
30
+ * and both absences are read as "no opinion" by the resume — `belongsHere`
31
+ * passes an entry with no slug at ANY store, and the basket rule passes an
32
+ * entry with no basket against ANY basket. So a 3-D Secure challenge the buyer
33
+ * abandoned was exempt from both the multi-tenant scoping (FUT-556) and the
34
+ * basket binding (FUT-1213): it resumed over whatever checkout mounted next.
35
+ */
36
+ export function handOverToChallenge(
37
+ order: CheckoutOrder,
38
+ url: string,
39
+ navigate: CheckoutNavigate,
40
+ scope: ChallengeScope = {},
41
+ ): void {
42
+ // PARK FIRST. The navigation may not come back to a live SPA at all, and a
43
+ // return trip that finds nothing parked lands the buyer on a blank
44
+ // confirmation after they have paid.
45
+ const basket = parkedBasket(scope.basket);
46
+ rememberHostedOrder(order, {
47
+ ...(scope.tenantSlug ? { tenantSlug: scope.tenantSlug } : {}),
48
+ ...(basket === undefined ? {} : { basket }),
49
+ handoff: true,
50
+ });
51
+ navigate(url);
52
+ }
53
+
54
+ /**
55
+ * The refusal a charge answer carries, or nothing (FUT-1145).
56
+ *
57
+ * `undefined` for an outcome with neither field — an older server, a provider
58
+ * whose adapter classifies nothing — so the caller makes the same one-argument
59
+ * call it always did and every screen below behaves exactly as before.
60
+ */
61
+ function declineOf(outcome: ChargeOutcome): CheckoutDecline | undefined {
62
+ const reason = outcome.declineReason;
63
+ const retriable = outcome.retriable;
64
+ if (reason === undefined && retriable === undefined) return undefined;
65
+ return {
66
+ ...(reason === undefined ? {} : { reason }),
67
+ ...(retriable === undefined ? {} : { retriable }),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Bubble a terminal status up, carrying the refusal when there is one.
73
+ *
74
+ * Called with ONE argument when the server said nothing new, so a host on an
75
+ * older mount produces exactly the call it always did.
76
+ */
77
+ export function reportResolved(outcome: ChargeOutcome, onResolved: OnCheckoutResolved): void {
78
+ const refusal = declineOf(outcome);
79
+ if (refusal) onResolved(outcome.status, refusal);
80
+ else onResolved(outcome.status);
81
+ }
@@ -11,8 +11,9 @@ import {
11
11
  import { useCheckoutCopy } from "./copy-context";
12
12
  import { UNRESOLVED_CODE } from "./failure-codes";
13
13
  import { StalledWait } from "./stalled-wait";
14
+ import type { CheckoutBasketIdentity } from "./basket";
14
15
  import type { CardChainLink } from "./method-capability";
15
- import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
16
+ import type { BuyerInfo, CheckoutOrder, OnCheckoutResolved } from "./types";
16
17
  import { useCheckoutComponents } from "./ui";
17
18
  import { useCardCheckout, type CardCheckout } from "./use-card-checkout";
18
19
 
@@ -97,6 +98,40 @@ function ChargeFailure({
97
98
  );
98
99
  }
99
100
 
101
+ /**
102
+ * What the buyer pays WITH: the saved cards they may reuse, and the form for a
103
+ * new one.
104
+ *
105
+ * The picker is absent when there is nothing saved, and the form is absent
106
+ * while a saved card is selected — so a buyer retrying a refused card (FUT-1145)
107
+ * lands on the form, because nothing is preselected for them.
108
+ */
109
+ function CardInstrumentFields({ card }: { card: CardCheckout }): JSX.Element {
110
+ return (
111
+ <>
112
+ {card.savedCards.length > 0 ? (
113
+ <SavedCardsPicker
114
+ savedCards={card.savedCards}
115
+ selection={card.selection}
116
+ onSelect={card.setSelection}
117
+ />
118
+ ) : null}
119
+
120
+ {card.usingNewCard ? (
121
+ <NewCardForm
122
+ card={card.card}
123
+ fieldErrors={card.fieldErrors}
124
+ brand={card.brand}
125
+ saveCard={card.saveCard}
126
+ setCard={card.setCard}
127
+ setFieldErrors={card.setFieldErrors}
128
+ onSaveCardChange={card.setSaveCard}
129
+ />
130
+ ) : null}
131
+ </>
132
+ );
133
+ }
134
+
100
135
  /**
101
136
  * Card payment view (FUT-58). Card data is validated + formatted client-side, then
102
137
  * tokenized (mock PagBank JS SDK) so the PAN never reaches our server; only the
@@ -115,6 +150,8 @@ export function CardView({
115
150
  tenantSlug,
116
151
  onResolved,
117
152
  pollIntervalMs = 2500,
153
+ freshInstrument = false,
154
+ basket,
118
155
  }: {
119
156
  order: CheckoutOrder;
120
157
  buyer?: BuyerInfo;
@@ -128,12 +165,33 @@ export function CardView({
128
165
  providerChain?: CardChainLink[];
129
166
  /** Scopes the saved-card list to cards the store's provider can charge. */
130
167
  tenantSlug?: string;
131
- onResolved: (status: OrderStatus) => void;
168
+ onResolved: OnCheckoutResolved;
132
169
  pollIntervalMs?: number;
170
+ /**
171
+ * Preselect NOTHING from the saved list (FUT-1145): the buyer is back here
172
+ * because a card was refused, and the card this would otherwise choose for
173
+ * them is that one.
174
+ */
175
+ freshInstrument?: boolean;
176
+ /**
177
+ * WHICH basket this checkout is for (FUT-1213) — parked with the order when
178
+ * a 3-D Secure challenge sends the buyer to the provider's page.
179
+ */
180
+ basket?: CheckoutBasketIdentity;
133
181
  }): JSX.Element {
134
182
  const { Text } = useCheckoutComponents();
135
183
  const copy = useCheckoutCopy().screens.card;
136
- const cc = useCardCheckout(order, buyer, providerConfig, onResolved, pollIntervalMs, tenantSlug, providerChain);
184
+ const cc = useCardCheckout(
185
+ order,
186
+ buyer,
187
+ providerConfig,
188
+ onResolved,
189
+ pollIntervalMs,
190
+ tenantSlug,
191
+ providerChain,
192
+ freshInstrument,
193
+ { tenantSlug, basket },
194
+ );
137
195
  // A charge NOBODY can confirm yet is not a decline (FUT-563). Some provider
138
196
  // may be holding the buyer's money, so it gets its own presentation: the
139
197
  // danger heading "Não foi possível pagar" contradicts the body's "não pague
@@ -150,25 +208,7 @@ export function CardView({
150
208
  {copy.heading}
151
209
  </Text>
152
210
 
153
- {cc.savedCards.length > 0 ? (
154
- <SavedCardsPicker
155
- savedCards={cc.savedCards}
156
- selection={cc.selection}
157
- onSelect={cc.setSelection}
158
- />
159
- ) : null}
160
-
161
- {cc.usingNewCard ? (
162
- <NewCardForm
163
- card={cc.card}
164
- fieldErrors={cc.fieldErrors}
165
- brand={cc.brand}
166
- saveCard={cc.saveCard}
167
- setCard={cc.setCard}
168
- setFieldErrors={cc.setFieldErrors}
169
- onSaveCardChange={cc.setSaveCard}
170
- />
171
- ) : null}
211
+ <CardInstrumentFields card={cc} />
172
212
 
173
213
  {cc.error ? <ChargeFailure message={cc.error} unresolved={unresolved} /> : null}
174
214