@12-apps/payments-frontend 3.21.4 → 3.22.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 (31) 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 +341 -0
  6. package/src/components/checkout/checkout-flow.tsx +112 -18
  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/dados-step.tsx +141 -0
  11. package/src/components/checkout/decline.ts +48 -0
  12. package/src/components/checkout/en-US.ts +33 -0
  13. package/src/components/checkout/hosted-return.ts +190 -206
  14. package/src/components/checkout/hosted-store.ts +269 -0
  15. package/src/components/checkout/payment-status-parts.tsx +311 -0
  16. package/src/components/checkout/payment-status.tsx +69 -264
  17. package/src/components/checkout/providers/types.ts +20 -3
  18. package/src/components/checkout/pt-BR.ts +33 -0
  19. package/src/components/checkout/screens-copy.ts +14 -0
  20. package/src/components/checkout/screens-en-US.ts +1 -0
  21. package/src/components/checkout/screens-pt-BR.ts +3 -0
  22. package/src/components/checkout/transport.ts +21 -1
  23. package/src/components/checkout/types.ts +24 -0
  24. package/src/components/checkout/use-card-checkout.ts +31 -31
  25. package/src/components/checkout/use-checkout-controller.ts +51 -271
  26. package/src/components/checkout/use-hosted-resume.ts +326 -0
  27. package/src/components/checkout/view-copy.ts +32 -0
  28. package/src/components/checkout/wallet-pane.tsx +3 -0
  29. package/src/flows/create-payment-flows.tsx +7 -0
  30. package/src/flows/screens-hosted.tsx +19 -2
  31. package/src/index.ts +22 -0
@@ -1,10 +1,11 @@
1
1
  import { Box } from "@mui/material";
2
2
  import type { JSX, ReactNode } from "react";
3
3
 
4
- import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
4
+ import type { CheckoutDecline } from "./decline";
5
+ import { OutcomeHero, PaidFacts, StatusActions, type WaitState } from "./payment-status-parts";
5
6
  import type { OrderStatus } from "./types";
6
7
  import { useCheckoutComponents } from "./ui";
7
- import type { PaymentStatusCopy, StatusOutcomeCopy } from "./view-copy";
8
+ import type { PaymentStatusCopy } from "./view-copy";
8
9
 
9
10
  /**
10
11
  * The last screen of checkout.
@@ -29,241 +30,6 @@ import type { PaymentStatusCopy, StatusOutcomeCopy } from "./view-copy";
29
30
  * screen says and how it is arranged has changed.
30
31
  */
31
32
 
32
- /**
33
- * The per-outcome VISUAL grammar — icon and semantic tone. The heading and
34
- * supporting line beside them come from {@link PaymentStatusCopy}: an icon is
35
- * the component's own vocabulary, a sentence never is. (The FAILED support
36
- * line's job — say "nothing was charged" plainly and first — and the
37
- * timed-out wait's "do not pay again" now live with the host's words, where
38
- * FUT-556's reasoning is documented on the copy port.)
39
- */
40
- interface OutcomeVisual {
41
- icon: JSX.Element;
42
- /** Semantic theme token — never a raw colour. */
43
- tone: "success" | "danger" | "warning" | "neutral";
44
- }
45
-
46
- const OUTCOME_VISUAL: Record<OrderStatus, OutcomeVisual> = {
47
- PAID: { icon: <CheckCircleOutlineIcon fontSize="large" />, tone: "success" },
48
- AWAITING_PAYMENT: { icon: <ScheduleIcon fontSize="large" />, tone: "neutral" },
49
- FAILED: { icon: <ErrorOutlineIcon fontSize="large" />, tone: "danger" },
50
- EXPIRED: { icon: <ScheduleIcon fontSize="large" />, tone: "warning" },
51
- };
52
-
53
- const OUTCOME_COPY_KEY: Record<OrderStatus, keyof Pick<
54
- PaymentStatusCopy,
55
- "paid" | "awaiting" | "failed" | "expired"
56
- >> = {
57
- PAID: "paid",
58
- AWAITING_PAYMENT: "awaiting",
59
- FAILED: "failed",
60
- EXPIRED: "expired",
61
- };
62
-
63
- const TONE_COLOR: Record<OutcomeVisual["tone"], string> = {
64
- success: "success.main",
65
- danger: "error.main",
66
- warning: "warning.main",
67
- neutral: "text.secondary",
68
- };
69
-
70
- /**
71
- * The buyer's quotable reference.
72
- *
73
- * The order id is a uuid — unreadable over a phone call and impossible to copy
74
- * by eye — so the screen shows its first block, uppercased. It is the real id's
75
- * own prefix rather than a second number, so support can still find the order
76
- * from what the buyer reads out.
77
- */
78
- function orderReference(orderId: string): string {
79
- return orderId.replace(/-/g, "").slice(0, 8).toUpperCase();
80
- }
81
-
82
- /** How the wait itself is going, when it has not resolved into an outcome. */
83
- interface WaitState {
84
- /** The bounded wall-clock wait elapsed — nothing further is scheduled. */
85
- timedOut: boolean;
86
- /** The last poll failed, and the wait is still running (FUT-1144). */
87
- unreachable: boolean;
88
- }
89
-
90
- /**
91
- * Which of AWAITING's three faces this is.
92
- *
93
- * STOPPED beats STILL TRYING, and the order is the whole honesty of the screen.
94
- * A wait that ran its clock out while failing carries BOTH flags — the last
95
- * poll's error is still the last thing that happened — and saying "we keep
96
- * trying" over a wait nothing is scheduled for is precisely the lie this ticket
97
- * exists to remove. The elapsed state is also the one carrying "não pague de
98
- * novo", which is the sentence that matters most when we have stopped looking.
99
- *
100
- * Both keep AWAITING's neutral clock icon and take WARNING's tone: the order is
101
- * not resolved, and calm-but-alert is the visual for that.
102
- */
103
- function awaitingFace(
104
- copy: PaymentStatusCopy,
105
- wait: WaitState,
106
- ): { outcome: StatusOutcomeCopy; tone: OutcomeVisual["tone"]; testId: string } | null {
107
- if (wait.timedOut) {
108
- return { outcome: copy.awaitingTimedOut, tone: "warning", testId: "payment-awaiting-timeout" };
109
- }
110
- if (wait.unreachable) {
111
- return { outcome: copy.awaitingUnreachable, tone: "warning", testId: "payment-awaiting-unreachable" };
112
- }
113
- return null;
114
- }
115
-
116
- /** The headline block: icon, outcome, and one supporting line. */
117
- function OutcomeHero({
118
- copy,
119
- status,
120
- wait,
121
- }: {
122
- copy: PaymentStatusCopy;
123
- status: OrderStatus;
124
- wait: WaitState;
125
- }): JSX.Element {
126
- const { Text } = useCheckoutComponents();
127
- const face = status === "AWAITING_PAYMENT" ? awaitingFace(copy, wait) : null;
128
- const visual = face
129
- ? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: face.tone }
130
- : OUTCOME_VISUAL[status];
131
- const outcome = face ? face.outcome : copy[OUTCOME_COPY_KEY[status]];
132
- return (
133
- <Box
134
- // `payment-paid` is load-bearing for the storefront journeys — it is how
135
- // they assert the buyer actually got there. Each unsettled wait gets its
136
- // OWN id rather than reusing `payment-awaiting_payment`: a test that
137
- // cannot tell "still asking" from "stopped asking" from "cannot reach the
138
- // payment" is a test that would pass against the spinner this replaced.
139
- data-testid={
140
- face ? face.testId : status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`
141
- }
142
- sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
143
- >
144
- <Box sx={{ color: TONE_COLOR[visual.tone], display: "flex" }}>{visual.icon}</Box>
145
- <Text variant="heading" size="md" weight="bold" as="h2">
146
- {outcome.heading}
147
- </Text>
148
- <Text variant="body" size="sm" as="p" style={{ opacity: 0.75 }}>
149
- {outcome.support}
150
- </Text>
151
- </Box>
152
- );
153
- }
154
-
155
- /** One label/value row of the paid receipt block. */
156
- function Fact({ label, value, testId }: { label: string; value: string; testId?: string }): JSX.Element {
157
- const { Text } = useCheckoutComponents();
158
- return (
159
- <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2 }}>
160
- <Text variant="body" size="sm" as="span" style={{ opacity: 0.75 }}>
161
- {label}
162
- </Text>
163
- <Text variant="body" size="sm" weight="bold" as="span" data-testid={testId}>
164
- {value}
165
- </Text>
166
- </Box>
167
- );
168
- }
169
-
170
- /**
171
- * What a paid buyer will want later: how much left their account, which order
172
- * it was, and where the receipt went. Rendered only for PAID — on any other
173
- * outcome these facts are either untrue or not yet knowable.
174
- */
175
- function PaidFacts({
176
- copy,
177
- totalLabel,
178
- orderId,
179
- buyerEmail,
180
- }: {
181
- copy: PaymentStatusCopy;
182
- totalLabel: string;
183
- orderId?: string;
184
- buyerEmail?: string;
185
- }): JSX.Element {
186
- return (
187
- <Box
188
- data-testid="payment-receipt"
189
- sx={{
190
- display: "flex",
191
- flexDirection: "column",
192
- gap: 1,
193
- p: 2,
194
- borderRadius: 2,
195
- bgcolor: "action.hover",
196
- }}
197
- >
198
- <Fact label={copy.amountLabel} value={totalLabel} testId="payment-amount" />
199
- {orderId ? (
200
- <Fact label={copy.referenceLabel} value={`#${orderReference(orderId)}`} testId="payment-reference" />
201
- ) : null}
202
- {buyerEmail ? <Fact label={copy.receiptEmailLabel} value={buyerEmail} /> : null}
203
- </Box>
204
- );
205
- }
206
-
207
- /** The next-action row: retry / regenerate / check-again, always back-to-menu. */
208
- function StatusActions({
209
- copy,
210
- status,
211
- onRetry,
212
- onRegenerate,
213
- onCheckAgain,
214
- onBackToMenu,
215
- }: {
216
- copy: PaymentStatusCopy;
217
- status: OrderStatus;
218
- onRetry?: () => void;
219
- onRegenerate?: () => void;
220
- /**
221
- * Offered only while the wait is unsettled AND not visibly working — the
222
- * caller decides that; here it is simply present or absent. A button under a
223
- * healthy spinner would invite a tap that changes nothing.
224
- */
225
- onCheckAgain?: () => void;
226
- onBackToMenu: () => void;
227
- }): JSX.Element {
228
- const { Button } = useCheckoutComponents();
229
- return (
230
- <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
231
- {onCheckAgain ? (
232
- <Button
233
- variant="solid"
234
- color="primary"
235
- size="lg"
236
- onClick={onCheckAgain}
237
- dataTestId="payment-check-again"
238
- >
239
- {copy.checkAgainAction}
240
- </Button>
241
- ) : null}
242
- {status === "FAILED" && onRetry ? (
243
- <Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
244
- {copy.retryAction}
245
- </Button>
246
- ) : null}
247
- {status === "EXPIRED" && onRegenerate ? (
248
- <Button variant="solid" color="primary" size="lg" onClick={onRegenerate} dataTestId="payment-regenerate">
249
- {copy.regenerateAction}
250
- </Button>
251
- ) : null}
252
- <Button
253
- // Full width and last, so the thumb lands on the same place in every
254
- // outcome instead of hunting a button that moves with the state.
255
- variant={status === "PAID" ? "solid" : "outline"}
256
- color={status === "PAID" ? "primary" : "neutral"}
257
- size="lg"
258
- onClick={onBackToMenu}
259
- dataTestId="payment-back-to-menu"
260
- >
261
- {copy.backAction}
262
- </Button>
263
- </Box>
264
- );
265
- }
266
-
267
33
  /** What the last screen of checkout is handed. */
268
34
  interface PaymentStatusProps {
269
35
  /** Every sentence and label this screen renders — the HOST's words. */
@@ -304,6 +70,20 @@ interface PaymentStatusProps {
304
70
  * about when it can.
305
71
  */
306
72
  onCheckAgain?: () => void;
73
+ /**
74
+ * The buyer says they did not pay (FUT-1146). Present only while the wait is
75
+ * genuinely unsettled and the caller has something to release; rendered on
76
+ * AWAITING and nowhere else, because every other status has an answer already
77
+ * and this action is the one that manufactures one.
78
+ */
79
+ onNotPaid?: () => void;
80
+ /** A release is in flight — the action stands down rather than repeating. */
81
+ releasing?: boolean;
82
+ /**
83
+ * WHY the charge was refused (FUT-1145). Read only on FAILED: it chooses the
84
+ * sentence, and it decides whether a retry is offered at all.
85
+ */
86
+ decline?: CheckoutDecline | null;
307
87
  }
308
88
 
309
89
  /**
@@ -328,52 +108,77 @@ function offeredCheckAgain(
328
108
  return stalled ? onCheckAgain : undefined;
329
109
  }
330
110
 
331
- export function PaymentStatus({
332
- copy,
333
- status,
334
- totalLabel,
335
- orderId,
336
- buyerEmail,
337
- onRetry,
338
- onRegenerate,
339
- onBackToMenu,
340
- paidExtra,
341
- awaitingTimedOut = false,
342
- awaitingError = null,
343
- onCheckAgain,
344
- }: PaymentStatusProps): JSX.Element {
345
- const { LoadingState } = useCheckoutComponents();
346
- const effective: OrderStatus = status ?? "AWAITING_PAYMENT";
347
- const wait: WaitState = { timedOut: awaitingTimedOut, unreachable: awaitingError !== null };
111
+ /** What the props ADD UP TO — every branch this screen makes, made once. */
112
+ interface StatusView {
113
+ effective: OrderStatus;
114
+ wait: WaitState;
115
+ paid: boolean;
116
+ spinning: boolean;
117
+ decline: CheckoutDecline | null;
118
+ checkAgain: (() => void) | undefined;
119
+ notPaid: (() => void) | undefined;
120
+ }
121
+
122
+ /**
123
+ * Resolve the props into that view.
124
+ *
125
+ * A pure function rather than a block inside the component, because the
126
+ * decisions and the markup are two different things to read — and because the
127
+ * component was over the complexity gate with all of them inlined.
128
+ */
129
+ function statusView(props: PaymentStatusProps): StatusView {
130
+ const effective: OrderStatus = props.status ?? "AWAITING_PAYMENT";
131
+ const wait: WaitState = {
132
+ timedOut: props.awaitingTimedOut === true,
133
+ unreachable: (props.awaitingError ?? null) !== null,
134
+ };
348
135
  const stalled = isStalled(effective, wait);
349
- const paid = effective === "PAID";
350
- const spinning = effective === "AWAITING_PAYMENT" && !stalled;
136
+ const awaiting = effective === "AWAITING_PAYMENT";
137
+ return {
138
+ effective,
139
+ wait,
140
+ paid: effective === "PAID",
141
+ spinning: awaiting && !stalled,
142
+ decline: props.decline ?? null,
143
+ checkAgain: offeredCheckAgain(stalled, props.onCheckAgain),
144
+ // Scoped to the unsettled wait, and stood down while its own request is
145
+ // out. A settled screen has its answer; a second tap would only ask again.
146
+ notPaid: awaiting && props.releasing !== true ? props.onNotPaid : undefined,
147
+ };
148
+ }
149
+
150
+ export function PaymentStatus(props: PaymentStatusProps): JSX.Element {
151
+ const { copy, totalLabel, orderId, buyerEmail, onRetry, onRegenerate, onBackToMenu } = props;
152
+ const { LoadingState } = useCheckoutComponents();
153
+ const view = statusView(props);
351
154
 
352
155
  return (
353
156
  <Box
354
157
  data-testid="payment-status"
355
- data-status={effective}
356
- data-timed-out={awaitingTimedOut ? "true" : undefined}
158
+ data-status={view.effective}
159
+ data-timed-out={view.wait.timedOut ? "true" : undefined}
357
160
  sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
358
161
  >
359
- <OutcomeHero copy={copy} status={effective} wait={wait} />
162
+ <OutcomeHero copy={copy} status={view.effective} wait={view.wait} decline={view.decline} />
360
163
 
361
- {paid ? (
164
+ {view.paid ? (
362
165
  <PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
363
166
  ) : null}
364
167
 
365
- {paid ? paidExtra : null}
168
+ {view.paid ? props.paidExtra : null}
366
169
 
367
- {spinning ? (
170
+ {view.spinning ? (
368
171
  <LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
369
172
  ) : null}
370
173
 
371
174
  <StatusActions
372
175
  copy={copy}
373
- status={effective}
176
+ status={view.effective}
177
+ decline={view.decline}
374
178
  onRetry={onRetry}
375
179
  onRegenerate={onRegenerate}
376
- onCheckAgain={offeredCheckAgain(stalled, onCheckAgain)}
180
+ onCheckAgain={view.checkAgain}
181
+ onNotPaid={view.notPaid}
377
182
  onBackToMenu={onBackToMenu}
378
183
  />
379
184
  </Box>
@@ -15,11 +15,12 @@
15
15
  */
16
16
  import type { JSX } from "react";
17
17
 
18
+ import type { CheckoutBasketIdentity } from "../basket";
18
19
  import type {
19
20
  BuyerInfo,
20
21
  CheckoutOrder,
21
22
  CheckoutProviderConfig,
22
- OrderStatus,
23
+ OnCheckoutResolved,
23
24
  PaymentMethod,
24
25
  } from "../types";
25
26
 
@@ -70,6 +71,19 @@ export interface ProviderCheckoutScreenProps {
70
71
  tenantSlug?: string;
71
72
  /** The shell's polling cadence, passed through so tests can shorten it. */
72
73
  pollIntervalMs?: number;
74
+ /**
75
+ * The buyer is retrying a REFUSED card (FUT-1145), so no saved instrument is
76
+ * preselected: the one this would otherwise choose is the one that failed,
77
+ * and a retry that re-charges it is a second identical decline.
78
+ */
79
+ freshInstrument?: boolean;
80
+ /**
81
+ * WHICH basket this checkout is for (FUT-1213). A card screen needs it
82
+ * because it can PARK an order of its own: a redirect-based 3-D Secure
83
+ * challenge is a hand-off like any other, and an entry parked without a
84
+ * basket (or without a store) resumes over any basket at any store.
85
+ */
86
+ basket?: CheckoutBasketIdentity;
73
87
  /**
74
88
  * The host's Apple Pay merchant-validation port (FUT-472): exchange the
75
89
  * session's `validationURL` for an Apple merchant session, SERVER-SIDE —
@@ -78,8 +92,11 @@ export interface ProviderCheckoutScreenProps {
78
92
  * card form stays the way to pay.
79
93
  */
80
94
  validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
81
- /** A terminal status — the shell moves to Confirmação. */
82
- onResolved: (status: OrderStatus) => void;
95
+ /**
96
+ * A terminal status the shell moves to Confirmação, carrying the refusal
97
+ * when the charge produced one (FUT-1145).
98
+ */
99
+ onResolved: OnCheckoutResolved;
83
100
  }
84
101
 
85
102
  /**
@@ -40,9 +40,42 @@ export const PT_BR_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
40
40
  "Continuamos tentando por aqui. Se você já pagou, não pague de novo — " +
41
41
  "o pedido é confirmado assim que a operadora avisar.",
42
42
  },
43
+ /**
44
+ * One refusal at a time, in the cardholder's own terms (FUT-1145).
45
+ *
46
+ * `UNKNOWN` is deliberately absent: with no recognised reason there is
47
+ * nothing specific to say, and `failed` above is already that sentence.
48
+ */
49
+ declined: {
50
+ INSUFFICIENT_FUNDS: {
51
+ heading: "Não havia saldo ou limite",
52
+ support: "Nenhum valor foi cobrado. Tente outro cartão.",
53
+ },
54
+ CARD_DECLINED: {
55
+ heading: "Seu banco não autorizou o pagamento",
56
+ support: "Nenhum valor foi cobrado. Tente outro cartão ou fale com o seu banco.",
57
+ },
58
+ INVALID_CARD: {
59
+ heading: "Os dados do cartão não foram aceitos",
60
+ support: "Nenhum valor foi cobrado. Confira o número, a validade e o CVV, ou use outro cartão.",
61
+ },
62
+ EXPIRED_CARD: {
63
+ heading: "O cartão está vencido",
64
+ support: "Nenhum valor foi cobrado. Use um cartão com a validade em dia.",
65
+ },
66
+ FRAUD_SUSPECTED: {
67
+ heading: "O banco bloqueou esta compra por segurança",
68
+ support: "Nenhum valor foi cobrado. Fale com o seu banco ou use outro cartão.",
69
+ },
70
+ PROVIDER_ERROR: {
71
+ heading: "Não foi possível processar o pagamento agora",
72
+ support: "Nenhum valor foi cobrado. Tente de novo em alguns instantes.",
73
+ },
74
+ },
43
75
  retryAction: "Tentar novamente",
44
76
  regenerateAction: "Gerar novo código",
45
77
  checkAgainAction: "Verificar de novo",
78
+ notPaidAction: "Não consegui pagar",
46
79
  backAction: "Voltar ao cardápio",
47
80
  amountLabel: "Valor pago",
48
81
  referenceLabel: "Pedido",
@@ -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;
@@ -358,8 +370,20 @@ export interface CheckoutProviderConfig {
358
370
  * present when the provider demands the buyer finish the charge on ITS page —
359
371
  * Stripe's redirect-based 3-D Secure — and the client then hands the buyer
360
372
  * over exactly as it does for a redirect provider's link (FUT-556).
373
+ *
374
+ * `declineReason` / `retriable` ride along on a refusal (FUT-1145). The server
375
+ * has classified every acquirer decline since FUT-340 — 33 PagBank codes with
376
+ * issuer sub-reasons, each carrying the vendor's own retry verdict — and then
377
+ * answered `{ status }` and threw the classification away, so an expired card,
378
+ * a stolen card and "attempts exhausted, do not retry" all reached the buyer as
379
+ * "Pagamento não concluído. Você pode tentar novamente."
380
+ *
381
+ * BOTH OPTIONAL, and the degrade direction is today's behaviour: a server that
382
+ * sends neither renders exactly the generic refusal it always did.
361
383
  */
362
384
  export interface ChargeOutcome {
363
385
  status: OrderStatus;
364
386
  hostedCheckoutUrl?: string;
387
+ declineReason?: CheckoutDeclineReason;
388
+ retriable?: boolean;
365
389
  }