@flopay/react 1.3.1 → 1.3.3

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/README.md CHANGED
@@ -329,6 +329,33 @@ Skip the backend API route — create the session directly in the component:
329
329
 
330
330
  The component POSTs to the billing API, gets the full session back, and renders the form — zero backend code needed.
331
331
 
332
+ That create POST sends a stable `Idempotency-Key` header automatically whenever a
333
+ secure RNG is available, so a timeout or lost response never mints a second
334
+ session. `FloPayCheckout` already coalesces concurrent mounts (StrictMode
335
+ double-mount, Suspense remount, navigation flicker) into a single create, and
336
+ rerenders during an in-flight create do **not** replace the key. You normally
337
+ don't need to think about it.
338
+
339
+ To control the key yourself — for example to keep it stable across your own
340
+ server retries — pass `idempotencyKey` on `createSession`:
341
+
342
+ ```tsx
343
+ <FloPayCheckout
344
+ createSession={{
345
+ /* … */
346
+ idempotencyKey: `checkout:${orderId}`, // one logical checkout; never reuse for a new purchase
347
+ }}
348
+ />
349
+ ```
350
+
351
+ The key must be non-empty and at most 255 characters; an invalid value throws a
352
+ `FloPayError('validation_error')` before any request. Omit it and the SDK
353
+ generates a fresh cryptographically random key per logical create when a secure
354
+ RNG is available; without one it omits the header, and a merchant-supplied
355
+ `idempotencyKey` stays the way to remain idempotent. See the
356
+ [`@flopay/js` idempotency docs](../js/README.md#idempotent-checkout-creation)
357
+ for the full contract.
358
+
332
359
  Session-level `currency` is now **required**. The backend (#760) enforces `@IsNotEmpty` on the field; the SDK pre-validates and throws `FloPayError({ type: 'validation_error', code: 'CurrencyRequired' })` before issuing the request when neither the session nor any item/subscription/product carries a currency. Pass `currency` at the top of `createSession` (preferred), or rely on the legacy fallback to the first item/subscription/product `currency`.
333
360
 
334
361
  When you use `onBeforeButtonClick` with `createSession`, any returned `InlineSessionPatch` is merged into the draft session params before the selected buttons-layout flow continues. That lets you add tracking data or update account fields just in time without pre-creating a separate backend session.
@@ -487,6 +514,30 @@ It reuses `@flopay/shared`'s country-aware
487
514
  → the postcode field is shown but **optional**: empty and format checks are
488
515
  both skipped, so these buyers are never blocked.
489
516
 
517
+ On the **vault card path** the widget's submit button is gated **synchronously**
518
+ on the client-side AVS check above. Because the client postcode rules mirror the
519
+ backend validator (`validator@13.15.35`) verbatim, a postcode the server's
520
+ `PATCH /v1/checkouts/sessions/{id}/account` would reject is already blocked here —
521
+ before the button ever enables — with no network round-trip in the critical path.
522
+ The gate is **never** held pending an async request: the hosted widget silently
523
+ swallows a click that lands while the button is disabled and offers no way to
524
+ auto-resubmit on release, so an async gate would strand any buyer (or single-click
525
+ preview) who clicked before it resolved — the checkout would write no attempt and
526
+ time out (sdk#124).
527
+
528
+ When the buyer submits, `SplitCardForm` persists the billing snapshot
529
+ best-effort **in parallel** with the widget's charge:
530
+
531
+ - **Backend rejects the address (a `4xx`)** — a residual, non-postcode drift, since
532
+ the postcode is already client-validated — the specific message (e.g. *"Postal/ZIP
533
+ code is not valid for the selected country…"*) shows inline **and** fires `onError`
534
+ with the parsed `FloPayError`, and the processing overlay clears so the buyer can
535
+ correct and retry. The charge cannot be cancelled once submitted, so this surfaces
536
+ for the buyer's *next* attempt rather than stopping the in-flight one.
537
+ - **Transient failure (`5xx` / network)** → best-effort and silent: the backend
538
+ listener falls back to the session's baseline address while the widget completes
539
+ the charge.
540
+
490
541
  ### Vault PCI card form
491
542
 
492
543
  When the billing API returns a hosted vault card form on the session
package/dist/index.cjs CHANGED
@@ -1365,6 +1365,9 @@ var import_jsx_runtime7 = require("react/jsx-runtime");
1365
1365
  var STRIPE_RESUME_KEY = "flopay_stripe_resume";
1366
1366
  var LEGACY_WALLET_RESUME_KEY = "flopay_wallet_resume";
1367
1367
  var PAYPAL_RESUME_KEY = "flopay_paypal_resume";
1368
+ function isAccountValidationError(err) {
1369
+ return err instanceof import_shared6.FloPayError && typeof err.statusCode === "number" && err.statusCode >= 400 && err.statusCode < 500;
1370
+ }
1368
1371
  function toVaultMount(block) {
1369
1372
  if (!block?.html) return null;
1370
1373
  return {
@@ -2866,10 +2869,6 @@ function SplitCardFormInner({
2866
2869
  },
2867
2870
  [onErrorChange]
2868
2871
  );
2869
- (0, import_react9.useEffect)(() => {
2870
- if (!vaultActive || !cardCapture) return;
2871
- cardCapture.setSubmitGate?.(avsInvalid);
2872
- }, [vaultActive, cardCapture, avsInvalid]);
2873
2872
  (0, import_react9.useEffect)(() => {
2874
2873
  if (!vaultActive || !cardCapture) return;
2875
2874
  cardCapture.setCardFieldOrder?.(cardFieldOrder ?? null, avsConfig == null);
@@ -2950,6 +2949,46 @@ function SplitCardFormInner({
2950
2949
  baseUrl
2951
2950
  };
2952
2951
  const vaultCompletedRef = (0, import_react9.useRef)(false);
2952
+ const buildVaultAccountSnapshot = (0, import_react9.useCallback)(() => {
2953
+ const { resolvedAccount: resolvedAccount2, avsConfig: avsConfig2, fullName: fullName2, avsCheckProp: avsCheckProp2 } = vaultOutcomeRef.current;
2954
+ const cc = selectedCountryRef.current || resolvedAccount2.country || "US";
2955
+ const stateVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.state, cc) : false;
2956
+ const line1Visible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.address_line_1, cc) : false;
2957
+ const zipVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.postal_code, cc) : false;
2958
+ const cityVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.city, cc) : false;
2959
+ const line2Visible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.address_line_2, cc) : false;
2960
+ const derivedState = line1Visible && !stateVisible && zipVisible ? (0, import_shared5.getStateFromPostalCode)(cc, (zipCodeRef.current ?? "").trim()) : null;
2961
+ const stateValue2 = stateVisible ? stateRef.current : derivedState;
2962
+ return {
2963
+ accountData: {
2964
+ userId: resolvedAccount2.userId ?? "",
2965
+ email: resolvedAccount2.email ?? "",
2966
+ firstName: resolvedAccount2.firstName ?? fullName2.trim().split(/\s+/)[0] ?? "",
2967
+ lastName: resolvedAccount2.lastName ?? fullName2.trim().split(/\s+/).slice(1).join(" ") ?? "",
2968
+ ...zipVisible && zipCodeRef.current ? { zip: zipCodeRef.current } : {},
2969
+ ...cityVisible && cityRef.current ? { city: cityRef.current } : {},
2970
+ ...stateValue2 ? { state: stateValue2 } : {},
2971
+ ...line1Visible && addressLine1Ref.current ? { addressLine1: addressLine1Ref.current } : {},
2972
+ ...line2Visible && addressLine2Ref.current ? { addressLine2: addressLine2Ref.current } : {},
2973
+ country: cc
2974
+ },
2975
+ ...avsCheckProp2 !== void 0 ? { avsCheck: avsCheckProp2 } : {},
2976
+ ...avsConfig2 ? {
2977
+ avsConfig: {
2978
+ country: (0, import_shared5.isAVSFieldVisible)(avsConfig2.country, cc),
2979
+ postal_code: zipVisible,
2980
+ address_line_1: line1Visible,
2981
+ address_line_2: line2Visible,
2982
+ city: cityVisible,
2983
+ state: stateVisible
2984
+ }
2985
+ } : {}
2986
+ };
2987
+ }, []);
2988
+ (0, import_react9.useEffect)(() => {
2989
+ if (!vaultActive || !cardCapture) return;
2990
+ cardCapture.setSubmitGate?.(avsInvalid);
2991
+ }, [vaultActive, cardCapture, avsInvalid]);
2953
2992
  (0, import_react9.useEffect)(() => {
2954
2993
  if (!vaultActive || !cardCapture) return;
2955
2994
  vaultCompletedRef.current = false;
@@ -2957,41 +2996,14 @@ function SplitCardFormInner({
2957
2996
  const offSubmitting = cardCapture.on("submitting", () => {
2958
2997
  setHasAttemptedSubmit(true);
2959
2998
  setOverlayStatus("processing");
2960
- const { resolvedAccount: resolvedAccount2, avsConfig: avsConfig2, fullName: fullName2, avsCheckProp: avsCheckProp2, baseUrl: baseUrl2, sessionId: sessionId2, nonce: nonce2 } = vaultOutcomeRef.current;
2999
+ const { baseUrl: baseUrl2, sessionId: sessionId2, nonce: nonce2, updateError: updateError2, onError: onError2 } = vaultOutcomeRef.current;
2961
3000
  if (!sessionId2 || !nonce2) return;
2962
- const cc = selectedCountryRef.current || resolvedAccount2.country || "US";
2963
- const stateVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.state, cc) : false;
2964
- const line1Visible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.address_line_1, cc) : false;
2965
- const zipVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.postal_code, cc) : false;
2966
- const cityVisible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.city, cc) : false;
2967
- const line2Visible = avsConfig2 ? (0, import_shared5.isAVSFieldVisible)(avsConfig2.address_line_2, cc) : false;
2968
- const derivedState = line1Visible && !stateVisible && zipVisible ? (0, import_shared5.getStateFromPostalCode)(cc, zipCodeRef.current ?? "") : null;
2969
- const stateValue2 = stateVisible ? stateRef.current : derivedState;
2970
- void new import_js2.PaymentAPI(baseUrl2).patchAccountSnapshot(sessionId2, nonce2, {
2971
- accountData: {
2972
- userId: resolvedAccount2.userId ?? "",
2973
- email: resolvedAccount2.email ?? "",
2974
- firstName: resolvedAccount2.firstName ?? fullName2.trim().split(/\s+/)[0] ?? "",
2975
- lastName: resolvedAccount2.lastName ?? fullName2.trim().split(/\s+/).slice(1).join(" ") ?? "",
2976
- ...zipVisible && zipCodeRef.current ? { zip: zipCodeRef.current } : {},
2977
- ...cityVisible && cityRef.current ? { city: cityRef.current } : {},
2978
- ...stateValue2 ? { state: stateValue2 } : {},
2979
- ...line1Visible && addressLine1Ref.current ? { addressLine1: addressLine1Ref.current } : {},
2980
- ...line2Visible && addressLine2Ref.current ? { addressLine2: addressLine2Ref.current } : {},
2981
- country: cc
2982
- },
2983
- ...avsCheckProp2 !== void 0 ? { avsCheck: avsCheckProp2 } : {},
2984
- ...avsConfig2 ? {
2985
- avsConfig: {
2986
- country: (0, import_shared5.isAVSFieldVisible)(avsConfig2.country, cc),
2987
- postal_code: zipVisible,
2988
- address_line_1: line1Visible,
2989
- address_line_2: line2Visible,
2990
- city: cityVisible,
2991
- state: stateVisible
2992
- }
2993
- } : {}
2994
- }).catch(() => {
3001
+ void new import_js2.PaymentAPI(baseUrl2).patchAccountSnapshot(sessionId2, nonce2, buildVaultAccountSnapshot()).catch((err) => {
3002
+ if (!isAccountValidationError(err)) return;
3003
+ const message = err instanceof Error ? err.message : "Please check your billing address and try again.";
3004
+ updateError2(message);
3005
+ setOverlayStatus(null);
3006
+ onError2?.(err instanceof import_shared6.FloPayError ? err : new import_shared6.FloPayError(message, "api_error"));
2995
3007
  });
2996
3008
  });
2997
3009
  const offComplete = cardCapture.on("complete", async (event) => {
@@ -3221,7 +3233,7 @@ function SplitCardFormInner({
3221
3233
  const stateVisible = (0, import_shared5.isAVSFieldVisible)(avsConfig.state, c);
3222
3234
  const line1Visible = (0, import_shared5.isAVSFieldVisible)(avsConfig.address_line_1, c);
3223
3235
  const zipVisible = (0, import_shared5.isAVSFieldVisible)(avsConfig.postal_code, c);
3224
- const derivedState = line1Visible && !stateVisible && zipVisible ? (0, import_shared5.getStateFromPostalCode)(c, zipCodeRef.current ?? "") : null;
3236
+ const derivedState = line1Visible && !stateVisible && zipVisible ? (0, import_shared5.getStateFromPostalCode)(c, (zipCodeRef.current ?? "").trim()) : null;
3225
3237
  const stateValue2 = stateVisible ? stateRef.current : derivedState;
3226
3238
  return {
3227
3239
  country: c,