@12-apps/payments-frontend 3.2.0 → 3.2.2
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.2.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"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.",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"storybook:build": "storybook build"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@12-apps/payments-backend": "^4.
|
|
20
|
+
"@12-apps/payments-backend": "^4.5.0",
|
|
21
21
|
"react-qr-code": "^2.2.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
@@ -131,7 +131,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
|
|
|
131
131
|
// any chain member may need rather than re-opening after the choice. A chain
|
|
132
132
|
// that declares nothing degrades to CPF-required, never to "ask nothing".
|
|
133
133
|
const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
|
|
134
|
-
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields);
|
|
134
|
+
const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug);
|
|
135
135
|
|
|
136
136
|
// A settlement settlement pays already-sent kitchen items — the cart is
|
|
137
137
|
// legitimately empty here, so the empty-cart guard only applies to cart mode.
|
|
@@ -54,10 +54,70 @@ function isReturnTrip(): boolean {
|
|
|
54
54
|
return RETURN_MARKERS.some((marker) => params.has(marker));
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Whether a hand-off from THIS tab is still waiting to be resolved.
|
|
59
|
+
*
|
|
60
|
+
* Exported because a host needs it and was otherwise forced to reimplement it.
|
|
61
|
+
* `/menu/checkout` is a URL like any other, so a host may put a gate in front
|
|
62
|
+
* of it — a closed-shop curtain, a plan check — and every such gate has to
|
|
63
|
+
* stand aside for a buyer coming back from a payment, because that route is
|
|
64
|
+
* where the money is confirmed. Deciding that from the outside meant copying
|
|
65
|
+
* this module's marker list and its storage key into the host, which is
|
|
66
|
+
* precisely the drift this package exists to stop: the copy went stale the
|
|
67
|
+
* moment Stripe's 3-D Secure markers were added here.
|
|
68
|
+
*
|
|
69
|
+
* Read WITHOUT consuming. The gate asks on every render; only the flow may
|
|
70
|
+
* take the order.
|
|
71
|
+
*/
|
|
72
|
+
export function hostedCheckoutReturnPending(tenantSlug?: string): boolean {
|
|
73
|
+
if (isReturnTrip()) return true;
|
|
74
|
+
const parked = readParked();
|
|
75
|
+
if (!parked) return false;
|
|
76
|
+
// Same two questions the resume asks, so a gate and the flow behind it can
|
|
77
|
+
// never disagree: another store's hand-off is not this route's business, and
|
|
78
|
+
// a stale one is nobody's.
|
|
79
|
+
return belongsHere(parked, tenantSlug) && !isStale(parked);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What is actually parked: the order, WHOSE STORE it belongs to, and when.
|
|
84
|
+
*
|
|
85
|
+
* `CheckoutOrder` carries no tenant, and on a multi-tenant storefront every
|
|
86
|
+
* store shares one origin — so one tab holds one slot for all of them. Without
|
|
87
|
+
* the slug, a buyer who abandoned store A's hand-off and opened store B's
|
|
88
|
+
* checkout resumed A's order on B's screen: a confirmation for an unrelated
|
|
89
|
+
* order, and B's own checkout skipped.
|
|
90
|
+
*
|
|
91
|
+
* `parkedAt` bounds the other axis. A hand-off is a round trip of minutes; an
|
|
92
|
+
* entry older than {@link MAX_PARKED_AGE_MS} belongs to a session the buyer has
|
|
93
|
+
* long since abandoned, and resuming it tells them about an order they are no
|
|
94
|
+
* longer trying to place.
|
|
95
|
+
*/
|
|
96
|
+
interface ParkedHostedOrder {
|
|
97
|
+
order: CheckoutOrder;
|
|
98
|
+
/** The store this hand-off belongs to; absent for an unscoped host. */
|
|
99
|
+
tenantSlug?: string;
|
|
100
|
+
parkedAt: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How long a parked hand-off stays resumable.
|
|
105
|
+
*
|
|
106
|
+
* Thirty minutes: a hosted payment takes minutes, and the window has to cover a
|
|
107
|
+
* buyer who fetches their card, not one who comes back tomorrow. Beyond it the
|
|
108
|
+
* entry is dropped on read rather than resumed.
|
|
109
|
+
*/
|
|
110
|
+
const MAX_PARKED_AGE_MS = 30 * 60_000;
|
|
111
|
+
|
|
57
112
|
/** Park the raised order before handing the buyer to the provider's page. */
|
|
58
|
-
export function rememberHostedOrder(order: CheckoutOrder): void {
|
|
113
|
+
export function rememberHostedOrder(order: CheckoutOrder, tenantSlug?: string): void {
|
|
59
114
|
try {
|
|
60
|
-
|
|
115
|
+
const parked: ParkedHostedOrder = {
|
|
116
|
+
order,
|
|
117
|
+
...(tenantSlug ? { tenantSlug } : {}),
|
|
118
|
+
parkedAt: Date.now(),
|
|
119
|
+
};
|
|
120
|
+
window.sessionStorage?.setItem(HOSTED_ORDER_STORAGE_KEY, JSON.stringify(parked));
|
|
61
121
|
} catch {
|
|
62
122
|
// Storage disabled or full. The redirect must still happen: the webhook
|
|
63
123
|
// settles the order either way, and refusing to send the buyer to pay
|
|
@@ -66,12 +126,30 @@ export function rememberHostedOrder(order: CheckoutOrder): void {
|
|
|
66
126
|
}
|
|
67
127
|
|
|
68
128
|
/**
|
|
69
|
-
* The parked order,
|
|
129
|
+
* The parked order, cleared as it is read.
|
|
130
|
+
*
|
|
131
|
+
* This USED to require a marker on the URL, so that a buyer who abandoned the
|
|
132
|
+
* provider's page and reopened checkout got a fresh order rather than resuming
|
|
133
|
+
* one they never paid. That reasoning is inverted for the provider it matters
|
|
134
|
+
* most for, and the inversion is a money bug rather than a UX preference.
|
|
135
|
+
*
|
|
136
|
+
* Pressing the provider's "Continuar" is the ONLY thing that marks the URL.
|
|
137
|
+
* Closing the tab, hitting back, or retyping the store's address are all
|
|
138
|
+
* commoner, and all of them landed the buyer on a live payment step for an
|
|
139
|
+
* order that may already be paid — an invitation to pay twice. It cannot be
|
|
140
|
+
* decided by asking first, either: InfinitePay's `payment_check` refuses to
|
|
141
|
+
* answer without a `transaction_nsu` that only that same redirect carries, so
|
|
142
|
+
* "poll before resuming" reads PAID as PENDING and drops them on the pay
|
|
143
|
+
* button anyway.
|
|
70
144
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
145
|
+
* So a parked order is itself the signal. The cost is that a buyer who truly
|
|
146
|
+
* abandoned sees one confirmation screen reporting what the store actually
|
|
147
|
+
* knows — which is the truth — with the way back on it. The read-and-clear
|
|
148
|
+
* bounds it: the resume happens once per hand-off, and leaving and reopening
|
|
149
|
+
* checkout gives a fresh one.
|
|
150
|
+
*
|
|
151
|
+
* `sessionStorage` already scopes this to one tab's round trip, so nothing
|
|
152
|
+
* here can resurface in a later, unrelated session.
|
|
75
153
|
*/
|
|
76
154
|
/**
|
|
77
155
|
* The key before the 2.0.0 rename, READ ONLY — decoded from base64 so no
|
|
@@ -111,15 +189,13 @@ const LEGACY_KEY = atob('ZnV0dXJlcGF5LmNoZWNrb3V0Lmhvc3RlZE9yZGVy');
|
|
|
111
189
|
* legacy entry left behind would let a later return trip resume an order that
|
|
112
190
|
* was already consumed.
|
|
113
191
|
*/
|
|
114
|
-
function
|
|
192
|
+
function peekParkedPayload(): string | null {
|
|
115
193
|
try {
|
|
116
|
-
|
|
194
|
+
return (
|
|
117
195
|
window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ??
|
|
118
196
|
window.sessionStorage?.getItem(LEGACY_KEY) ??
|
|
119
|
-
null
|
|
120
|
-
|
|
121
|
-
window.sessionStorage?.removeItem(LEGACY_KEY);
|
|
122
|
-
return raw;
|
|
197
|
+
null
|
|
198
|
+
);
|
|
123
199
|
} catch {
|
|
124
200
|
// Storage disabled or unavailable — the same "no parked order" as an empty
|
|
125
201
|
// slot, and the webhook still settles the order regardless.
|
|
@@ -127,13 +203,51 @@ function takeParkedPayload(): string | null {
|
|
|
127
203
|
}
|
|
128
204
|
}
|
|
129
205
|
|
|
130
|
-
export function takeHostedOrder(): CheckoutOrder | null {
|
|
131
|
-
|
|
132
|
-
|
|
206
|
+
export function takeHostedOrder(tenantSlug?: string): CheckoutOrder | null {
|
|
207
|
+
const parked = readParked();
|
|
208
|
+
if (!parked) return null;
|
|
209
|
+
// A hand-off from ANOTHER store is left where it is rather than consumed: it
|
|
210
|
+
// is that store's to resume, and this buyer may well go back to it.
|
|
211
|
+
if (!belongsHere(parked, tenantSlug)) return null;
|
|
212
|
+
clearParked();
|
|
213
|
+
if (isStale(parked)) return null;
|
|
214
|
+
return parked.order;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Whether a parked hand-off is this store's. */
|
|
218
|
+
function belongsHere(parked: ParkedHostedOrder, tenantSlug?: string): boolean {
|
|
219
|
+
// An unscoped entry (a host that passes no slug, or one parked by an older
|
|
220
|
+
// bundle) stays readable by anyone — the single-tenant case, where there is
|
|
221
|
+
// no other store to confuse it with.
|
|
222
|
+
if (!parked.tenantSlug || !tenantSlug) return true;
|
|
223
|
+
return parked.tenantSlug === tenantSlug;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Whether it has been sitting long enough to no longer be this trip's. */
|
|
227
|
+
function isStale(parked: ParkedHostedOrder): boolean {
|
|
228
|
+
if (typeof parked.parkedAt !== "number") return false;
|
|
229
|
+
return Date.now() - parked.parkedAt > MAX_PARKED_AGE_MS;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The parked entry, parsed, or null. Tolerates the PRE-SCOPE shape — a bare
|
|
234
|
+
* `CheckoutOrder` — so a buyer mid-hand-off across the deploy still comes back
|
|
235
|
+
* to their confirmation.
|
|
236
|
+
*/
|
|
237
|
+
function readParked(): ParkedHostedOrder | null {
|
|
238
|
+
const raw = peekParkedPayload();
|
|
133
239
|
if (!raw) return null;
|
|
134
240
|
try {
|
|
135
241
|
const parsed: unknown = JSON.parse(raw);
|
|
136
|
-
|
|
242
|
+
if (isCheckoutOrder(parsed)) return { order: parsed, parkedAt: Date.now() };
|
|
243
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
244
|
+
const candidate = parsed as Partial<ParkedHostedOrder>;
|
|
245
|
+
if (!isCheckoutOrder(candidate.order)) return null;
|
|
246
|
+
return {
|
|
247
|
+
order: candidate.order,
|
|
248
|
+
...(candidate.tenantSlug ? { tenantSlug: candidate.tenantSlug } : {}),
|
|
249
|
+
parkedAt: typeof candidate.parkedAt === "number" ? candidate.parkedAt : Date.now(),
|
|
250
|
+
};
|
|
137
251
|
} catch {
|
|
138
252
|
return null;
|
|
139
253
|
}
|
|
@@ -149,3 +263,20 @@ function isCheckoutOrder(value: unknown): value is CheckoutOrder {
|
|
|
149
263
|
const candidate = value as Partial<CheckoutOrder>;
|
|
150
264
|
return typeof candidate.orderId === "string" && typeof candidate.totalLabel === "string";
|
|
151
265
|
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Drop the parked entry. Split from the read because the READ now has to
|
|
269
|
+
* decide whose it is first — consuming another store's hand-off was the bug
|
|
270
|
+
* this scoping exists to stop.
|
|
271
|
+
*
|
|
272
|
+
* BOTH keys, whichever answered: a legacy entry left behind would let a later
|
|
273
|
+
* return trip resume an order that was already consumed.
|
|
274
|
+
*/
|
|
275
|
+
function clearParked(): void {
|
|
276
|
+
try {
|
|
277
|
+
window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
|
|
278
|
+
window.sessionStorage?.removeItem(LEGACY_KEY);
|
|
279
|
+
} catch {
|
|
280
|
+
// Storage disabled — there was nothing to clear.
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -156,12 +156,20 @@ function initialStep(resuming: boolean, taxIdOnFile: boolean): Step {
|
|
|
156
156
|
*
|
|
157
157
|
* @returns true when the buyer is on their way and the caller must stop.
|
|
158
158
|
*/
|
|
159
|
-
function handOverToProvider(
|
|
159
|
+
function handOverToProvider(
|
|
160
|
+
order: CheckoutOrder,
|
|
161
|
+
navigate: CheckoutNavigate,
|
|
162
|
+
tenantSlug?: string,
|
|
163
|
+
): boolean {
|
|
160
164
|
if (!order.hostedCheckoutUrl) return false;
|
|
161
165
|
// PARK FIRST, navigate second. The order is the only thing the return trip
|
|
162
166
|
// has to rehydrate from, and the navigation may tear this SPA down before
|
|
163
167
|
// any later write lands.
|
|
164
|
-
|
|
168
|
+
//
|
|
169
|
+
// The STORE goes with it: one tab holds one slot, and on a multi-tenant
|
|
170
|
+
// storefront every store shares an origin. Without the slug, abandoning this
|
|
171
|
+
// hand-off and opening another store's checkout resumed THIS order there.
|
|
172
|
+
rememberHostedOrder(order, tenantSlug);
|
|
165
173
|
navigate(order.hostedCheckoutUrl);
|
|
166
174
|
return true;
|
|
167
175
|
}
|
|
@@ -175,8 +183,11 @@ function handOverToProvider(order: CheckoutOrder, navigate: CheckoutNavigate): b
|
|
|
175
183
|
* card view, because a redirect provider produced neither. The webhook is still
|
|
176
184
|
* what settles the order; this only tells the buyer that it did.
|
|
177
185
|
*/
|
|
178
|
-
function useHostedResume(): {
|
|
179
|
-
|
|
186
|
+
function useHostedResume(tenantSlug?: string): {
|
|
187
|
+
order: CheckoutOrder | null;
|
|
188
|
+
status: OrderStatus | null;
|
|
189
|
+
} {
|
|
190
|
+
const [order] = useState(() => takeHostedOrder(tenantSlug));
|
|
180
191
|
const { status } = usePaymentPolling(order?.orderId ?? null, { enabled: Boolean(order) });
|
|
181
192
|
return { order, status };
|
|
182
193
|
}
|
|
@@ -252,10 +263,11 @@ export function useCheckoutController(
|
|
|
252
263
|
defaultBuyer?: BuyerInfo,
|
|
253
264
|
taxIdOnFile = false,
|
|
254
265
|
buyerFields: readonly CheckoutCustomerField[] = CPF_ONLY,
|
|
266
|
+
tenantSlug?: string,
|
|
255
267
|
) {
|
|
256
268
|
const { createOrder, saveBuyerContact, onExitToMenu, onPaid } = ports;
|
|
257
269
|
const navigate = useCheckoutNavigate();
|
|
258
|
-
const resume = useHostedResume();
|
|
270
|
+
const resume = useHostedResume(tenantSlug);
|
|
259
271
|
const [step, setStep] = useState<Step>(initialStep(Boolean(resume.order), taxIdOnFile));
|
|
260
272
|
// No method pre-selected: the Pagamento step shows just the picker until the
|
|
261
273
|
// buyer chooses PIX or card, then that method's order is raised and its UI
|
|
@@ -296,10 +308,10 @@ export function useCheckoutController(
|
|
|
296
308
|
const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
|
|
297
309
|
setCreating(false);
|
|
298
310
|
if (!result.ok) { failure.fail(result.error); return; }
|
|
299
|
-
if (handOverToProvider(result.data, navigate)) return;
|
|
311
|
+
if (handOverToProvider(result.data, navigate, tenantSlug)) return;
|
|
300
312
|
setOrder(result.data);
|
|
301
313
|
setFinalStatus(null);
|
|
302
|
-
}, [buyer, saveProfile, createOrder, clearError, navigate]);
|
|
314
|
+
}, [buyer, saveProfile, createOrder, clearError, navigate, tenantSlug]);
|
|
303
315
|
const payWithEmail = useCallback((email: string) => {
|
|
304
316
|
if (!method) return;
|
|
305
317
|
const next = { ...buyer, email };
|
package/src/index.ts
CHANGED
|
@@ -93,6 +93,17 @@ export { fetchCheckoutConfig } from './components/checkout/client';
|
|
|
93
93
|
* literal and silently drifts when it changes.
|
|
94
94
|
*/
|
|
95
95
|
export { HOSTED_ORDER_STORAGE_KEY } from './components/checkout/hosted-return';
|
|
96
|
+
/**
|
|
97
|
+
* Whether a hand-off from this tab is still waiting to be resolved.
|
|
98
|
+
*
|
|
99
|
+
* For a HOST GATE in front of the checkout route — a closed-shop curtain, a
|
|
100
|
+
* plan check. Every such gate has to stand aside for a buyer coming back from
|
|
101
|
+
* a payment, because that route is where the money gets confirmed, and a host
|
|
102
|
+
* deciding it alone had to copy this package's marker list and storage key.
|
|
103
|
+
* That copy goes stale: it did, the moment Stripe's 3-D Secure markers were
|
|
104
|
+
* added here. Reads without consuming.
|
|
105
|
+
*/
|
|
106
|
+
export { hostedCheckoutReturnPending } from './components/checkout/hosted-return';
|
|
96
107
|
// ---------------------------------------------------------------------------
|
|
97
108
|
// Digital wallets (FUT-471/472) — the Google-branded button and the capability
|
|
98
109
|
// read it is gated on. `CheckoutFlow` wires these automatically; they are
|