@12-apps/payments-frontend 3.2.1 → 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.1",
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": {
@@ -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.
@@ -69,22 +69,55 @@ function isReturnTrip(): boolean {
69
69
  * Read WITHOUT consuming. The gate asks on every render; only the flow may
70
70
  * take the order.
71
71
  */
72
- export function hostedCheckoutReturnPending(): boolean {
72
+ export function hostedCheckoutReturnPending(tenantSlug?: string): boolean {
73
73
  if (isReturnTrip()) return true;
74
- try {
75
- return Boolean(
76
- window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ??
77
- window.sessionStorage?.getItem(LEGACY_KEY),
78
- );
79
- } catch {
80
- return false;
81
- }
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;
82
101
  }
83
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
+
84
112
  /** Park the raised order before handing the buyer to the provider's page. */
85
- export function rememberHostedOrder(order: CheckoutOrder): void {
113
+ export function rememberHostedOrder(order: CheckoutOrder, tenantSlug?: string): void {
86
114
  try {
87
- window.sessionStorage?.setItem(HOSTED_ORDER_STORAGE_KEY, JSON.stringify(order));
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));
88
121
  } catch {
89
122
  // Storage disabled or full. The redirect must still happen: the webhook
90
123
  // settles the order either way, and refusing to send the buyer to pay
@@ -156,15 +189,13 @@ const LEGACY_KEY = atob('ZnV0dXJlcGF5LmNoZWNrb3V0Lmhvc3RlZE9yZGVy');
156
189
  * legacy entry left behind would let a later return trip resume an order that
157
190
  * was already consumed.
158
191
  */
159
- function takeParkedPayload(): string | null {
192
+ function peekParkedPayload(): string | null {
160
193
  try {
161
- const raw =
194
+ return (
162
195
  window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ??
163
196
  window.sessionStorage?.getItem(LEGACY_KEY) ??
164
- null;
165
- window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
166
- window.sessionStorage?.removeItem(LEGACY_KEY);
167
- return raw;
197
+ null
198
+ );
168
199
  } catch {
169
200
  // Storage disabled or unavailable — the same "no parked order" as an empty
170
201
  // slot, and the webhook still settles the order regardless.
@@ -172,12 +203,51 @@ function takeParkedPayload(): string | null {
172
203
  }
173
204
  }
174
205
 
175
- export function takeHostedOrder(): CheckoutOrder | null {
176
- const raw = takeParkedPayload();
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();
177
239
  if (!raw) return null;
178
240
  try {
179
241
  const parsed: unknown = JSON.parse(raw);
180
- return isCheckoutOrder(parsed) ? parsed : null;
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
+ };
181
251
  } catch {
182
252
  return null;
183
253
  }
@@ -193,3 +263,20 @@ function isCheckoutOrder(value: unknown): value is CheckoutOrder {
193
263
  const candidate = value as Partial<CheckoutOrder>;
194
264
  return typeof candidate.orderId === "string" && typeof candidate.totalLabel === "string";
195
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(order: CheckoutOrder, navigate: CheckoutNavigate): boolean {
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
- rememberHostedOrder(order);
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(): { order: CheckoutOrder | null; status: OrderStatus | null } {
179
- const [order] = useState(takeHostedOrder);
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 };