@12-apps/payments-frontend 3.38.0 → 3.39.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.38.0",
3
+ "version": "3.39.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.",
package/src/card/types.ts CHANGED
@@ -40,6 +40,12 @@ export interface SavedCard {
40
40
  expMonth: number;
41
41
  expYear: number;
42
42
  holder: string;
43
+ /**
44
+ * When the buyer last paid with it (ISO-8601), or `null`/absent when the
45
+ * host does not say. The one list preselects the card used LAST (FUT-3591);
46
+ * every other screen keeps the host's own order.
47
+ */
48
+ lastUsedAt?: string | null;
43
49
  }
44
50
 
45
51
  /** Per-field validation messages; `undefined` means the field is fine. */
@@ -13,6 +13,14 @@ import { useCheckoutClientApi } from "./client-context";
13
13
  * rather than after the buyer has spent a minute typing.
14
14
  */
15
15
 
16
+ /**
17
+ * A list answer as a list: a host whose card route answers `null` (or
18
+ * nothing) has no saved card, and must not take the checkout down with it.
19
+ */
20
+ function asList(cards: SavedCard[] | null | undefined): SavedCard[] {
21
+ return Array.isArray(cards) ? cards : [];
22
+ }
23
+
16
24
  /**
17
25
  * The buyer's saved cards at this store (FUT-697), loaded once per store, and
18
26
  * whether the list has answered — "no saved card" and "not asked yet" are
@@ -29,7 +37,7 @@ export function useSavedCardList(tenantSlug: string | undefined): { cards: Saved
29
37
  .listInstruments(tenantSlug)
30
38
  .catch((): SavedCard[] => [])
31
39
  .then((cards) => {
32
- if (active) setState({ cards, loaded: true });
40
+ if (active) setState({ cards: asList(cards), loaded: true });
33
41
  });
34
42
  return () => {
35
43
  active = false;
@@ -68,8 +76,9 @@ export function useSavedCards(
68
76
  void client
69
77
  .listInstruments(tenantSlug)
70
78
  .catch((): SavedCard[] => [])
71
- .then((cards) => {
79
+ .then((answer) => {
72
80
  if (!active) return;
81
+ const cards = asList(answer);
73
82
  const first = cards[0];
74
83
  setState((prev) => ({ savedCards: cards, selection: first && !freshInstrument ? first.id : prev.selection }));
75
84
  });
@@ -106,11 +106,27 @@ export function CardRun({
106
106
  return submitted ? <SubmittedState card={submit} /> : null;
107
107
  }
108
108
 
109
+ /**
110
+ * A refusal a host can fix: its order answers no `amountDueCents`, and its
111
+ * total is not what the sheet showed — the shape of a balance taken off the
112
+ * charge that the order view does not report. Said once in development; the
113
+ * buyer still gets the refusal.
114
+ */
115
+ function warnMissingAmountDue(order: CheckoutOrder, sheetCents: number): void {
116
+ const development = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
117
+ if (order.amountDueCents !== undefined || !development) return;
118
+ console.warn(
119
+ `[payments-frontend] Google Pay refused: the order costs ${order.totalCents} cents and the sheet showed ${sheetCents}. ` +
120
+ "A host that takes a balance off the charge must answer CheckoutOrder.amountDueCents.",
121
+ );
122
+ }
123
+
109
124
  /**
110
125
  * Google Pay: the sheet was opened INSIDE the buyer's tap (`sheet`, priced at
111
126
  * `sheetCents`) while the order was raised; its key is charged against that
112
- * order only if the order costs what the sheet showed. A closed sheet sends
113
- * nothing more.
127
+ * order only if the order asks the provider for what the sheet showed — its
128
+ * amount due, which a balance the host holds may have made less than its
129
+ * total. A closed sheet sends nothing more.
114
130
  */
115
131
  export function WalletRun({
116
132
  order,
@@ -140,7 +156,10 @@ export function WalletRun({
140
156
  .then((key) => {
141
157
  if (key === null) return onFailed(null);
142
158
  // The sheet showed one amount; never charge another under it.
143
- if (order.totalCents !== sheetCents) return onFailed(refused);
159
+ if ((order.amountDueCents ?? order.totalCents) !== sheetCents) {
160
+ warnMissingAmountDue(order, sheetCents);
161
+ return onFailed(refused);
162
+ }
144
163
  return void wallet.payWithKey("GOOGLE_PAY", key);
145
164
  })
146
165
  .catch(() => onFailed(refused));
@@ -43,6 +43,8 @@ interface RowProps {
43
43
  onBuyerChange: (buyer: BuyerInfo) => void;
44
44
  provider: string | null;
45
45
  failure: ListChargeFailure | null;
46
+ /** The host's balance pays the whole amount: the card asks for no card at all. */
47
+ covered?: boolean;
46
48
  }
47
49
 
48
50
  /** One of the store's online options, with what opens inside it. */
@@ -62,7 +64,7 @@ function OnlineOptionRow({ row, props }: { row: OnlineRow; props: RowProps }): J
62
64
  onChoose={() => state.choose({ kind: "online", method: row.method })}
63
65
  testId={`list-option-${row.method}`}
64
66
  >
65
- {opensContent(row.method, state.asked, props.failure) ? (
67
+ {opensContent(row.method, state.asked, props.failure, props.covered) ? (
66
68
  <OnlineContent
67
69
  method={row.method}
68
70
  asked={state.asked}
@@ -72,6 +74,7 @@ function OnlineOptionRow({ row, props }: { row: OnlineRow; props: RowProps }): J
72
74
  copy={copy}
73
75
  provider={provider}
74
76
  failure={props.failure}
77
+ covered={props.covered}
75
78
  />
76
79
  ) : null}
77
80
  </OptionRow>
@@ -63,10 +63,28 @@ export function raisedMethod(choice: CheckoutOnlineChoice, offered: PaymentMetho
63
63
  return "CARD";
64
64
  }
65
65
 
66
+ /**
67
+ * The saved card to choose for the buyer: the one used LAST (owner,
68
+ * 2026-10-15), or the list's head — the host's own order, newest first — when
69
+ * no card says when it was used. A card never used loses to any used one.
70
+ */
71
+ export function lastUsedCard(saved: readonly SavedCard[]): SavedCard | undefined {
72
+ let pick: SavedCard | undefined;
73
+ let pickAt = -Infinity;
74
+ for (const card of saved) {
75
+ const at = card.lastUsedAt ? Date.parse(card.lastUsedAt) : Number.NaN;
76
+ if (!Number.isNaN(at) && at > pickAt) {
77
+ pick = card;
78
+ pickAt = at;
79
+ }
80
+ }
81
+ return pick ?? saved[0];
82
+ }
83
+
66
84
  /**
67
85
  * What is chosen before the buyer touches anything (M7, owner 2026-10-15): a
68
86
  * saved card makes "Cartão de crédito" the choice with that card selected —
69
- * the list's head, which the host orders last-used first. Nothing else is ever
87
+ * the one used last ({@link lastUsedCard}). Nothing else is ever
70
88
  * preselected: never Pix, never a door tender (FUT-1478), and not a store's
71
89
  * only method either (that pick used to raise an order with no tap at all).
72
90
  *
@@ -82,7 +100,7 @@ export function preselection(
82
100
  const card = rows.find((row) => row.method === "CARD" && !row.disabled);
83
101
  if (!card) return null;
84
102
  if (fresh) return { choice: { kind: "online", method: "CARD" }, selection: saved.length > 0 ? null : NEW_CARD };
85
- const head = saved[0];
103
+ const head = lastUsedCard(saved);
86
104
  if (!head) return null;
87
105
  return { choice: { kind: "online", method: "CARD" }, selection: head.id };
88
106
  }
@@ -157,6 +175,8 @@ export function blockedReason(input: {
157
175
  buyer: BuyerInfo;
158
176
  selection: string | null;
159
177
  cardIsComplete: boolean;
178
+ /** The host's balance pays the whole amount: no card needs choosing. */
179
+ covered?: boolean;
160
180
  }): string | null {
161
181
  const { copy, choice } = input;
162
182
  if (input.hold) return input.hold;
@@ -164,7 +184,7 @@ export function blockedReason(input: {
164
184
  if (choice.kind === "door") return input.door ? input.door.blocked : copy.reasons.choose;
165
185
  const buyer = buyerReason(input.asked, input.buyer, copy.reasons);
166
186
  if (buyer) return buyer;
167
- if (choice.method !== "CARD") return null;
187
+ if (choice.method !== "CARD" || input.covered) return null;
168
188
  return cardReason(input.selection, input.cardIsComplete, copy.reasons);
169
189
  }
170
190
 
@@ -35,6 +35,7 @@ export const EN_US_PAYMENT_LIST_COPY: PaymentListCopy = {
35
35
  details: "Fill in your details to pay in the app.",
36
36
  chooseCard: "Choose the card.",
37
37
  cardDetails: "Fill in the card details.",
38
+ repricing: "Updating the amount…",
38
39
  },
39
40
  actions: {
40
41
  choose: "Choose how to pay",
@@ -46,6 +47,7 @@ export const EN_US_PAYMENT_LIST_COPY: PaymentListCopy = {
46
47
  payer: (name) => `Paying as ${name} · CPF ✓`,
47
48
  payerChange: "Change",
48
49
  charging: (total, card) => `Paying ${total} with ${card ?? "the card entered"}…`,
50
+ chargingCovered: "Confirming the order, paid with your balance…",
49
51
  cancel: "Cancel",
50
52
  pixFace: {
51
53
  title: (total) => `Pay ${total} with Pix`,
@@ -66,11 +66,19 @@ function saveAsked(props: ListFaceProps): void {
66
66
  saveBuyerContact?.({ name: c.buyer.name, phone: c.buyer.phone, taxId: c.buyer.taxId });
67
67
  }
68
68
 
69
+ /**
70
+ * What an online payment asks for: the host's amount due when a balance it
71
+ * holds pays part of the cart, else the cart's total (P3).
72
+ */
73
+ function onlineTotal(props: ListFaceProps): string {
74
+ return props.list.amountDueLabel ?? props.total;
75
+ }
76
+
69
77
  /** An online choice's press: the card goes through the bar, Pix and the hand-off raise at once. */
70
78
  function actOnline(props: ListFaceProps, method: CheckoutOnlineChoice): void {
71
79
  const { c, charge, cap } = props;
72
80
  saveAsked(props);
73
- if (method === "CARD") return charge.start("CARD", props.total);
81
+ if (method === "CARD") return charge.start("CARD", onlineTotal(props));
74
82
  const raised = method === "PIX" ? "PIX" : handOffMethod(cap.offered);
75
83
  if (method === "HAND_OFF") props.onHandOff();
76
84
  c.setMethod(raised);
@@ -94,7 +102,7 @@ function useAct(props: ListFaceProps, clearFailure: () => void): () => void {
94
102
 
95
103
  /**
96
104
  * Google Pay's press, from Google's own button: the sheet opens HERE, inside
97
- * the tap, priced with the host's total — then the order is raised behind it.
105
+ * the tap, priced with the host's amount due — then the order is raised behind it.
98
106
  */
99
107
  function useGooglePayPress(props: ListFaceProps, clearFailure: () => void, sheet: { current: OpenSheet | null }): () => void {
100
108
  return () => {
@@ -113,7 +121,7 @@ function useGooglePayPress(props: ListFaceProps, clearFailure: () => void, sheet
113
121
  },
114
122
  );
115
123
  sheet.current = { answer, cents };
116
- charge.start("GOOGLE_PAY", props.total);
124
+ charge.start("GOOGLE_PAY", onlineTotal(props));
117
125
  };
118
126
  }
119
127
 
@@ -151,26 +159,35 @@ function cardCharging(charge: ListCharge): boolean {
151
159
  return charge.phase === "counting" || (charge.phase === "sending" && charge.kind === "CARD");
152
160
  }
153
161
 
154
- /** The bar's sentence: the amount frozen at the press, and the card it goes on. */
162
+ /**
163
+ * The bar's sentence: the amount frozen at the press, and the card it goes on
164
+ * — or, when the host's balance pays everything, no card at all.
165
+ */
155
166
  function chargingText(props: ListFaceProps): string {
156
- const { charge, list, state, total } = props;
167
+ const { charge, list, state } = props;
168
+ if (coveredByHost(list) && list.copy.chargingCovered) return list.copy.chargingCovered;
157
169
  const selected = state.card.saved.find((card) => card.id === state.card.selection);
158
- return list.copy.charging(charge.pressedTotal ?? total, selected ? cardName(list.copy, selected) : null);
170
+ return list.copy.charging(charge.pressedTotal ?? onlineTotal(props), selected ? cardName(list.copy, selected) : null);
159
171
  }
160
172
 
161
173
  /** The verb, the amount and the busy state of the button, for what is chosen. */
162
174
  function buttonFacts(props: ListFaceProps): { label: string; total: string; busy: boolean } {
163
175
  const { list, state } = props;
164
176
  const door = chosenDoor(props);
177
+ const online = state.choice?.kind === "online";
165
178
  return {
166
179
  label: list.hold ? list.hold.action : actionLabel(list.copy, state.choice, door, props.provider),
167
- total: door?.totalLabel ?? props.total,
180
+ total: door ? (door.totalLabel ?? props.total) : online ? onlineTotal(props) : props.total,
168
181
  busy: inFlight(props),
169
182
  };
170
183
  }
171
184
 
172
- /** Google Pay chosen while the host re-prices: the row stays, its button waits for the price. */
173
- function googlePayRepricing(props: ListFaceProps): boolean {
185
+ /**
186
+ * Google Pay chosen while the host re-prices (or for the one render before the
187
+ * list drops a choice nobody prices any more): a plain disabled button, never
188
+ * a spinner, with `copy.reasons.repricing` under it.
189
+ */
190
+ function googlePayUnpriced(props: ListFaceProps): boolean {
174
191
  const { choice } = props.state;
175
192
  return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && !props.cap.googlePay;
176
193
  }
@@ -199,7 +216,7 @@ function BarAction({ props, failure, act, onGooglePay, onCancel, reason, returnF
199
216
  // all under "não pague de novo" (FUT-563), as the card pane does today.
200
217
  if (failure?.code === UNRESOLVED_CODE) return null;
201
218
  const { label, total, busy } = buttonFacts(props);
202
- if (googlePayRepricing(props)) return <ActionButton label={label} total={total} disabled loading onClick={act} />;
219
+ if (googlePayUnpriced(props)) return <ActionButton label={label} total={total} disabled loading={false} onClick={act} />;
203
220
  if (googlePayLive(props, reason, busy) && cap.googlePay) return <GooglePayAction client={cap.googlePay.client} onPress={onGooglePay} />;
204
221
  return <ActionButton label={label} total={total} disabled={reason !== null || busy} loading={busy} onClick={act} focusOnMount={returnFocus} />;
205
222
  }
@@ -230,13 +247,19 @@ function RaiseError({ c, onRetry }: { c: Controller; onRetry: () => void }): JSX
230
247
  return <PaymentErrorPanel message={c.createError} emailFlagged={c.errorField === "email"} code={c.errorCode} onUseEmail={c.payWithEmail} onRetry={onRetry} />;
231
248
  }
232
249
 
250
+ /** The host's balance pays the whole amount (`totalCents: 0`): no card is asked for or charged. */
251
+ function coveredByHost(list: CheckoutPaymentList): boolean {
252
+ return list.totalCents === 0;
253
+ }
254
+
233
255
  /** Why the button is held: the host's hold, then the list's own reasons, in the prototype's order. */
234
256
  function reasonOf(props: ListFaceProps, door: CheckoutDoorOption | undefined): string | null {
235
257
  const { c, list, state } = props;
236
- return blockedReason({
258
+ const reason = blockedReason({
237
259
  copy: list.copy, hold: list.hold?.reason ?? null, choice: state.choice, door, asked: state.asked,
238
- buyer: c.buyer, selection: state.card.selection, cardIsComplete: state.card.complete,
260
+ buyer: c.buyer, selection: state.card.selection, cardIsComplete: state.card.complete, covered: coveredByHost(list),
239
261
  });
262
+ return reason ?? (googlePayUnpriced(props) ? (list.copy.reasons.repricing ?? null) : null);
240
263
  }
241
264
 
242
265
  /**
@@ -262,8 +285,8 @@ function barFacts(props: ListFaceProps, door: CheckoutDoorOption | undefined, re
262
285
  * appears or a total that moves mid-bar stops it, and `send` checks again.
263
286
  */
264
287
  function useChargeGuard(props: ListFaceProps, reason: string | null): void {
265
- const { charge, total } = props;
266
- const allowed = reason === null && (charge.pressedTotal === null || charge.pressedTotal === total);
288
+ const { charge } = props;
289
+ const allowed = reason === null && (charge.pressedTotal === null || charge.pressedTotal === onlineTotal(props));
267
290
  const { setAllowed } = charge;
268
291
  useEffect(() => setAllowed(allowed), [allowed, setAllowed]);
269
292
  }
@@ -301,7 +324,7 @@ export function ListFace(props: ListFaceProps): JSX.Element {
301
324
  return (
302
325
  <Box data-testid="payment-list-step" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
303
326
  {shown ? (
304
- <ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} />
327
+ <ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} covered={coveredByHost(list)} />
305
328
  ) : null}
306
329
  <Runners props={props} sheet={sheet.current} onFailed={onFailed} />
307
330
  <RaiseError c={c} onRetry={act} />
@@ -180,13 +180,13 @@ export function ListPixFace(props: ListPixFaceProps & { mode: PaymentListMode })
180
180
  return (
181
181
  <Box data-testid="list-pix" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
182
182
  <Text variant="body" size="md" weight="bold" as="p" style={{ margin: 0 }} data-testid="list-pix-title">
183
- {copy.title(order.totalLabel)}
183
+ {copy.title(mode.list.amountDueLabel ?? order.totalLabel)}
184
184
  </Text>
185
185
  {afterCopy ? (
186
186
  <PixAfterCopy
187
187
  pix={pix}
188
188
  words={props.words}
189
- totalLabel={order.totalLabel}
189
+ totalLabel={mode.list.amountDueLabel ?? order.totalLabel}
190
190
  deadline={props.time}
191
191
  justCopied={props.justCopied}
192
192
  onCopyAgain={props.onCopyAgain}
@@ -72,9 +72,13 @@ function SavedChips({ card, copy }: { card: ListCard; copy: PaymentListCopy }):
72
72
  );
73
73
  }
74
74
 
75
- /** Whether a chosen online row has anything to open — a row with nothing opens no box (L5). */
76
- export function opensContent(method: string, asked: readonly CheckoutCustomerField[], failure: ListChargeFailure | null): boolean {
77
- return method === "CARD" || asked.length > 0 || failure !== null;
75
+ /**
76
+ * Whether a chosen online row has anything to open — a row with nothing opens
77
+ * no box (L5). The card opens nothing of its own when the host's balance pays
78
+ * the whole amount: no card is charged, so none is asked for.
79
+ */
80
+ export function opensContent(method: string, asked: readonly CheckoutCustomerField[], failure: ListChargeFailure | null, covered = false): boolean {
81
+ return (method === "CARD" && !covered) || asked.length > 0 || failure !== null;
78
82
  }
79
83
 
80
84
  /** Move the focus to the card number — the next thing to type once the CPF is complete. */
@@ -97,6 +101,7 @@ export function OnlineContent({
97
101
  copy,
98
102
  provider,
99
103
  failure,
104
+ covered = false,
100
105
  }: {
101
106
  method: "PIX" | "CARD" | "GOOGLE_PAY" | "HAND_OFF";
102
107
  asked: readonly CheckoutCustomerField[];
@@ -106,9 +111,11 @@ export function OnlineContent({
106
111
  copy: PaymentListCopy;
107
112
  provider: string | null;
108
113
  failure: ListChargeFailure | null;
114
+ /** The host's balance pays the whole amount — no chips, no card fields. */
115
+ covered?: boolean;
109
116
  }): JSX.Element {
110
117
  const scope = useRef<HTMLDivElement | null>(null);
111
- const isCard = method === "CARD";
118
+ const isCard = method === "CARD" && !covered;
112
119
  return (
113
120
  <Box ref={scope} sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
114
121
  <BuyerInline
@@ -70,8 +70,10 @@ export function OptionRow({
70
70
  borderRadius: 2.5,
71
71
  px: 1.5,
72
72
  opacity: disabled ? 0.6 : 1,
73
- // The keyboard's place in the group, drawn on the whole row (5.1 item 6).
74
- "&:has(input:focus-visible)": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
73
+ // The keyboard's place in the group, drawn on the whole row (5.1 item 6)
74
+ // — for the row's own radio only: a field opened inside draws its own
75
+ // ring, and a second one around the row doubled it.
76
+ "&:has(> label input[type='radio']:focus-visible)": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
75
77
  }}
76
78
  >
77
79
  <Box
@@ -132,7 +132,7 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
132
132
  taxIdOnFile: props.taxIdOnFile,
133
133
  onChoiceChange: list.onChoiceChange,
134
134
  });
135
- const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label });
135
+ const charge = useListCharge({ ...c, cartTotal: displayTotals(props.totalOverride, props.cart).label, dueCents: list.totalCents });
136
136
  const [handingOff, startHandOff] = useHandingOff(c.createError);
137
137
  const pixStage = usePaymentPixStage(cap, usePreferMethod(c, state.choose));
138
138
  const pix = c.order?.method === "PIX" ? c.order : null;
@@ -51,6 +51,7 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
51
51
  details: "Preencha seus dados para pagar pelo app.",
52
52
  chooseCard: "Escolha o cartão.",
53
53
  cardDetails: "Preencha os dados do cartão.",
54
+ repricing: "Atualizando o valor…",
54
55
  },
55
56
  actions: {
56
57
  choose: "Escolha como pagar",
@@ -62,6 +63,7 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
62
63
  payer: (name) => `Pagando como ${name} · CPF ✓`,
63
64
  payerChange: "Alterar",
64
65
  charging: (total, card) => `Pagando ${total} no ${card ?? "cartão informado"}…`,
66
+ chargingCovered: "Confirmando o pedido, pago com o seu saldo…",
65
67
  cancel: "Cancelar",
66
68
  pixFace: {
67
69
  title: (total) => `Pague ${total} com Pix`,
@@ -102,12 +102,35 @@ export interface CheckoutPaymentList {
102
102
  /** Told whenever the chosen option changes, including to nothing. */
103
103
  onChoiceChange?: (choice: CheckoutListChoice | null) => void;
104
104
  /**
105
- * The amount the buyer is about to pay, in integer cents — what Google Pay's
106
- * sheet is priced with, since that sheet must open INSIDE the tap, before any
107
- * order exists to read a price from. Absent ⇒ no Google Pay row. The charge
108
- * is refused if the order raised after the sheet does not cost the same.
105
+ * The amount an ONLINE payment will ask the provider for, in integer cents —
106
+ * the cart's total, or what is left of it after a balance the host holds
107
+ * (store credit). It is what Google Pay's sheet is priced with, since that
108
+ * sheet must open INSIDE the tap, before any order exists to read a price
109
+ * from. Never given, or zero (a balance covers everything) ⇒ no Google Pay
110
+ * row, and a Google Pay choice is dropped. Once given, a moment without it
111
+ * (the host re-pricing) keeps a chosen Google Pay with its button held —
112
+ * plainly, with `copy.reasons.repricing` — until it returns. The key is
113
+ * refused if the order raised behind the sheet asks the provider for another
114
+ * amount (`CheckoutOrder.amountDueCents`, else its total) — so a host that
115
+ * takes a balance off the charge MUST answer `amountDueCents`.
109
116
  */
110
117
  totalCents?: number;
118
+ /**
119
+ * The same amount, pre-formatted by the host ("R$ 22,00"), when it is not
120
+ * the cart's total — a balance the host holds pays part of it. The card's
121
+ * button, the 5-second bar and Google Pay's press name THIS while an online
122
+ * option is chosen; a door option and an empty choice keep the cart's total,
123
+ * since no balance pays at the door. Absent ⇒ the cart's total. It is also
124
+ * the amount the Pix face asks for.
125
+ *
126
+ * **A balance that covers everything** (`totalCents: 0`): the card option
127
+ * asks for no card, the bar says `copy.chargingCovered`, and the order the
128
+ * button raises must come back SETTLED by the host — paid from the balance,
129
+ * with no provider call — so the list goes to its paid screen. An order that
130
+ * comes back `AWAITING_PAYMENT` with `amountDueCents: 0` is never charged:
131
+ * the list returns to its rows.
132
+ */
133
+ amountDueLabel?: string;
111
134
  }
112
135
 
113
136
  /** The list's words — a pack per locale ships at `@12-apps/payments-frontend/locales`. */
@@ -155,6 +178,8 @@ export interface PaymentListCopy {
155
178
  details: string;
156
179
  chooseCard: string;
157
180
  cardDetails: string;
181
+ /** Google Pay is chosen while the host re-prices (`totalCents` briefly absent). Absent ⇒ no sentence. */
182
+ repricing?: string;
158
183
  };
159
184
  /** The button's verb, beside the total. */
160
185
  actions: {
@@ -169,6 +194,12 @@ export interface PaymentListCopy {
169
194
  payerChange: string;
170
195
  /** The 5-second bar: "Pagando R$ 27,00 no Visa final 4242…"; `card` is `null` for a card typed now. */
171
196
  charging: (total: string, card: string | null) => string;
197
+ /**
198
+ * The 5-second bar when the host's balance pays the WHOLE amount
199
+ * (`totalCents` 0), so no card is charged and none is named — "Confirmando
200
+ * o pedido, pago com o seu saldo…". Absent ⇒ `charging` as usual.
201
+ */
202
+ chargingCovered?: string;
172
203
  cancel: string;
173
204
  pixFace: {
174
205
  /** "Pague R$ 22,00 com Pix". */
@@ -21,24 +21,29 @@ export interface ListCapability extends OnlineCapability {
21
21
  * always had (FUT-471), so the list never offers a wallet that cannot work.
22
22
  *
23
23
  * The sheet must open inside the tap, before any order exists, so it is priced
24
- * with the host's `totalCents`; without one there is no row.
24
+ * with the host's `totalCents` — the amount DUE. A host that never priced it,
25
+ * or prices it at zero (a balance it holds covers everything), offers no row.
26
+ * Once priced, a moment without a price (the host re-pricing — the credit was
27
+ * toggled) keeps the row, and the list holds a chosen Google Pay's button,
28
+ * plainly, until the price returns.
25
29
  *
26
30
  * Apple Pay is not a row here: its sheet must open synchronously inside the
27
31
  * tap, before any order could be raised for it. The tabs layout keeps it.
28
32
  */
29
33
  export function useListCapability(
30
34
  config: CheckoutProviderConfig | null,
31
- /** The host's price for the sheet — no price, no Google Pay row. */
35
+ /** The host's price for the sheet — no price (or zero), no Google Pay row. */
32
36
  totalCents: number | undefined,
33
37
  ): ListCapability {
34
38
  const declared = googlePayConfig(config) !== null;
35
39
  // `null` tells the client hook not to load pay.js at all.
36
40
  const client = useGooglePayClient(declared ? undefined : null, "TEST");
37
- // Once the host has priced the sheet, a moment without a price (it is
38
- // re-pricing — the credit was toggled) keeps the row and holds its button;
39
- // only a host that never priced it offers no row.
40
- const [priced, setPriced] = useState(false);
41
- if (totalCents !== undefined && !priced) setPriced(true);
41
+ const priced = totalCents !== undefined && totalCents > 0;
42
+ // Priced once, and not since withdrawn by an explicit zero.
43
+ const [wasPriced, setWasPriced] = useState(false);
44
+ if (priced && !wasPriced) setWasPriced(true);
45
+ if (totalCents === 0 && wasPriced) setWasPriced(false);
46
+ const repricing = totalCents === undefined && wasPriced;
42
47
  return useMemo(() => {
43
48
  const params = googlePayConfig(config);
44
49
  const usable = params && client ? { client, params } : null;
@@ -46,8 +51,8 @@ export function useListCapability(
46
51
  handOff: methodChosenAtProvider(config?.chain?.[0]?.checkoutScreen, config),
47
52
  offered: offeredMethods(config),
48
53
  cardUnavailable: !cardPathAvailable(config),
49
- googlePayReady: usable !== null && (totalCents !== undefined || priced),
50
- googlePay: totalCents === undefined ? null : usable,
54
+ googlePayReady: usable !== null && (priced || repricing),
55
+ googlePay: priced ? usable : null,
51
56
  };
52
- }, [config, client, totalCents, priced]);
57
+ }, [config, client, priced, repricing]);
53
58
  }
@@ -32,6 +32,8 @@ interface RaisePorts {
32
32
  startPayment: (method: PaymentMethod) => Promise<void>;
33
33
  /** The amount the buyer is shown now (the cart's, or the balance's). */
34
34
  cartTotal: string;
35
+ /** What an online payment asks the provider for now, when the host prices it (`paymentList.totalCents`). */
36
+ dueCents?: number;
35
37
  }
36
38
 
37
39
  export interface ListCharge {
@@ -57,9 +59,23 @@ export interface ListCharge {
57
59
  chargeable: CheckoutOrder | null;
58
60
  }
59
61
 
60
- /** A held CARD order still awaiting payment AND priced as the buyer now sees — reusable for the charge. */
61
- function reusable(order: CheckoutOrder | null, cartTotal: string): boolean {
62
- return order?.method === "CARD" && order.status === "AWAITING_PAYMENT" && order.totalLabel === cartTotal;
62
+ /**
63
+ * A held CARD order still awaiting payment AND priced as the buyer now sees —
64
+ * reusable for the charge. A balance ticked or unticked moves what the charge
65
+ * asks for and not the order's total, so the amount due is compared too.
66
+ */
67
+ function reusable(order: CheckoutOrder | null, cartTotal: string, dueCents: number | undefined): boolean {
68
+ if (order?.method !== "CARD" || order.status !== "AWAITING_PAYMENT" || order.totalLabel !== cartTotal) return false;
69
+ return dueCents === undefined || (order.amountDueCents ?? order.totalCents) === dueCents;
70
+ }
71
+
72
+ /**
73
+ * An awaiting CARD order that asks the provider for nothing: a balance the host
74
+ * holds covers it, and the host should have settled it (see `amountDueLabel`).
75
+ * It is never charged.
76
+ */
77
+ function owesNothing(order: CheckoutOrder | null): boolean {
78
+ return order?.method === "CARD" && order.status === "AWAITING_PAYMENT" && order.amountDueCents === 0;
63
79
  }
64
80
 
65
81
  /** The order `send` meant: the one it reused, or the CARD order raised to replace a stale one. */
@@ -67,11 +83,24 @@ function chargeTarget(
67
83
  order: CheckoutOrder | null,
68
84
  target: { reuse: CheckoutOrder } | { replace: CheckoutOrder | null } | null,
69
85
  ): CheckoutOrder | null {
70
- if (!order || !target || order.method !== "CARD" || order.status !== "AWAITING_PAYMENT") return null;
86
+ if (!order || !target || order.method !== "CARD" || order.status !== "AWAITING_PAYMENT" || owesNothing(order)) return null;
71
87
  if ("reuse" in target) return target.reuse === order ? order : null;
72
88
  return order !== target.replace ? order : null;
73
89
  }
74
90
 
91
+ /**
92
+ * Back to the list when the press left nothing to charge: a raise that failed
93
+ * (the step's error panel says why), or a held order that owes the provider
94
+ * nothing — never charged, rather than waited on forever.
95
+ */
96
+ function useSettleWhenNothingToCharge(phase: ListChargePhase, kind: ListChargeKind | null, ports: RaisePorts, settle: () => void): void {
97
+ const { order, creating, createError } = ports;
98
+ useEffect(() => {
99
+ if (phase !== "sending" || creating) return;
100
+ if ((!order && createError) || (kind === "CARD" && owesNothing(order))) settle();
101
+ }, [phase, kind, creating, order, createError, settle]);
102
+ }
103
+
75
104
  /**
76
105
  * The bar's charge, after the button (FUT-3592): count down for a card, then
77
106
  * raise the order — or reuse the CARD order already held, after a refusal the
@@ -108,8 +137,8 @@ export function useListCharge(ports: RaisePorts): ListCharge {
108
137
  // must not let the bar's own timer charge past it.
109
138
  if (!allowed.current) return cancel();
110
139
  setPhase("sending");
111
- const { order, cartTotal } = latest.current;
112
- if (reusable(order, cartTotal) && order) {
140
+ const { order, cartTotal, dueCents } = latest.current;
141
+ if (reusable(order, cartTotal, dueCents) && order) {
113
142
  target.current = { reuse: order };
114
143
  return;
115
144
  }
@@ -148,12 +177,8 @@ export function useListCharge(ports: RaisePorts): ListCharge {
148
177
  if (timer.current) clearTimeout(timer.current);
149
178
  }, []);
150
179
 
151
- // A raise that failed leaves nothing to charge: back to the list, where the
152
- // step's error panel says why.
153
- const { order, creating, createError } = ports;
154
- useEffect(() => {
155
- if (phase === "sending" && !creating && !order && createError) settle();
156
- }, [phase, creating, order, createError, settle]);
180
+ const { order, creating } = ports;
181
+ useSettleWhenNothingToCharge(phase, kind, ports, settle);
157
182
 
158
183
  const sending = phase === "sending" || phase === "waiting";
159
184
  const chargeable = sending && !creating ? chargeTarget(order, target.current) : null;
@@ -5,7 +5,7 @@ import { useSavedCardList } from "../card-sources";
5
5
  import { useCheckoutCopy } from "../copy-context";
6
6
  import type { BuyerInfo, CheckoutCustomerField, CheckoutProviderConfig } from "../types";
7
7
 
8
- import { askedFields, cardComplete, onlineRows, preselection, raisedMethod, type OnlineCapability, type OnlineRow } from "./choice";
8
+ import { askedFields, cardComplete, lastUsedCard, onlineRows, preselection, raisedMethod, type OnlineCapability, type OnlineRow } from "./choice";
9
9
  import type { CheckoutListChoice } from "./types";
10
10
 
11
11
  const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
@@ -72,12 +72,12 @@ function usePreselect(
72
72
  }
73
73
 
74
74
  /**
75
- * The card chosen again: the head of the saved list — or, for a buyer back from
76
- * a refused card (FUT-1145), no saved card at all (the head may be the one that
77
- * failed), or the fields when nothing is saved.
75
+ * The card chosen again: the one used last (else the head of the saved list) —
76
+ * or, for a buyer back from a refused card (FUT-1145), no saved card at all
77
+ * (that one may be the card that failed), or the fields when nothing is saved.
78
78
  */
79
79
  function reopenedSelection(cards: readonly SavedCard[], fresh: boolean): string | null {
80
- const head = cards[0];
80
+ const head = lastUsedCard(cards);
81
81
  if (!head) return NEW_CARD;
82
82
  return fresh ? null : head.id;
83
83
  }
@@ -308,7 +308,9 @@ export function createCheckoutClient(transport: CheckoutTransport): CheckoutClie
308
308
  // offered for a Stone charge it would fail (or misroute).
309
309
  const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
310
310
  const result = await call<SavedCard[]>(`/cards${query}`, { method: "GET" });
311
- return result.ok ? result.data : [];
311
+ // A route that answers `null` (or no list at all) has no saved card —
312
+ // every screen that reads this maps over it.
313
+ return result.ok && Array.isArray(result.data) ? result.data : [];
312
314
  },
313
315
 
314
316
  beginVault: () => call<BuyerVaultSession>("/cards/begin", { method: "POST" }),
@@ -2,11 +2,10 @@
2
2
  * The buyer checkout's payment vocabulary (FUT-7 / FUT-44 / FUT-58, moved into
3
3
  * the library by FUT-564).
4
4
  *
5
- * Pure types with no runtime imports. Everything here is PAYMENT: what a
6
- * charge, an order-in-payment, a buyer identity or a provider protocol looks
7
- * like to the screens in this folder. The host-side halves — the cart, the
8
- * catalog, order CREATION — stay in the host app and reach the flow through
9
- * the ports declared on `CheckoutFlowProps`.
5
+ * Pure types with no runtime imports. Everything here is PAYMENT: what a charge, an
6
+ * order-in-payment, a buyer identity or a provider protocol looks like to the screens in
7
+ * this folder. The host-side halves — the cart, the catalog, order CREATION — stay in the
8
+ * host app and reach the flow through the ports declared on `CheckoutFlowProps`.
10
9
  */
11
10
 
12
11
  import type { CheckoutDecline, CheckoutDeclineReason } from "./decline";
@@ -15,10 +14,9 @@ import type { CheckoutDecline, CheckoutDeclineReason } from "./decline";
15
14
  export type PaymentMethod = "PIX" | "CARD";
16
15
 
17
16
  /**
18
- * The digital wallets a chain entry can charge (FUT-471/472). Mirrors the
19
- * backend's `WalletType` — a wallet is not a fourth method: it is another way
20
- * of producing the CARD instrument, so it rides the CARD tile and the CARD
21
- * charge.
17
+ * The digital wallets a chain entry can charge (FUT-471/472). Mirrors the backend's
18
+ * `WalletType` — a wallet is not a fourth method: it is another way of producing the
19
+ * CARD instrument, so it rides the CARD tile and the CARD charge.
22
20
  */
23
21
  export type CheckoutWalletType = "GOOGLE_PAY" | "APPLE_PAY";
24
22
 
@@ -114,6 +112,8 @@ export interface CheckoutOrder {
114
112
  appliedDiscounts: CheckoutDiscount[];
115
113
  /** Pre-formatted BRL grand total (e.g. `R$ 42,90`). */
116
114
  totalLabel: string;
115
+ /** Cents the provider is asked for when a balance the host holds pays part of {@link totalCents}; absent ⇒ the total. */
116
+ amountDueCents?: number;
117
117
  /** Present only when {@link method} is `"PIX"`. */
118
118
  pix?: PixCharge;
119
119
  /**