@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 +1 -1
- package/src/card/types.ts +6 -0
- package/src/components/checkout/card-sources.ts +11 -2
- package/src/components/checkout/list/charge-runners.tsx +22 -3
- package/src/components/checkout/list/choice-list.tsx +4 -1
- package/src/components/checkout/list/choice.ts +23 -3
- package/src/components/checkout/list/en-US.ts +2 -0
- package/src/components/checkout/list/list-face.tsx +38 -15
- package/src/components/checkout/list/list-pix.tsx +2 -2
- package/src/components/checkout/list/online-content.tsx +11 -4
- package/src/components/checkout/list/option-row.tsx +4 -2
- package/src/components/checkout/list/payment-list-step.tsx +1 -1
- package/src/components/checkout/list/pt-BR.ts +2 -0
- package/src/components/checkout/list/types.ts +35 -4
- package/src/components/checkout/list/use-capability.ts +15 -10
- package/src/components/checkout/list/use-list-charge.ts +37 -12
- package/src/components/checkout/list/use-list-state.ts +5 -5
- package/src/components/checkout/transport.ts +3 -1
- package/src/components/checkout/types.ts +9 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
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((
|
|
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
|
|
113
|
-
*
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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 ??
|
|
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
|
|
180
|
+
total: door ? (door.totalLabel ?? props.total) : online ? onlineTotal(props) : props.total,
|
|
168
181
|
busy: inFlight(props),
|
|
169
182
|
};
|
|
170
183
|
}
|
|
171
184
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
266
|
-
const allowed = reason === null && (charge.pressedTotal === null || charge.pressedTotal ===
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
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
|
-
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (totalCents
|
|
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 && (
|
|
50
|
-
googlePay:
|
|
54
|
+
googlePayReady: usable !== null && (priced || repricing),
|
|
55
|
+
googlePay: priced ? usable : null,
|
|
51
56
|
};
|
|
52
|
-
}, [config, client,
|
|
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
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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 —
|
|
76
|
-
* a refused card (FUT-1145), no saved card at all
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
/**
|