@flopay/shared 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
@@ -178,25 +178,40 @@ throw validationError('Email is required', 'email');
178
178
 
179
179
  ### Postal Code Helpers
180
180
 
181
- Country-aware postcode format validation, backed by the [`validator`](https://www.npmjs.com/package/validator) package's `isPostalCode` (the same library the billing API uses, so client and server rules agree). `@flopay/react`'s AVS block uses these to block a malformed postcode before the card is captured and to show the expected format inline.
181
+ Country-aware postcode format validation, a **faithful mirror of the billing API's own validator** — `is-valid-postal-code.validator.ts` (TeamFloPay/backend#895), same pinned [`validator`](https://www.npmjs.com/package/validator)`@13.15.35` `isPostalCode`, same country→ISO-2 normalization, same fail-open rules — so the SDK never blocks a postcode the server accepts nor opens its submit gate on one the server's `PATCH …/account` will reject with a 400. `@flopay/react`'s AVS block uses these to block a malformed postcode before the card is captured and to show the expected format inline.
182
182
 
183
183
  | Export | Description |
184
184
  |--------|-------------|
185
185
  | `getPostalCodeLabel(countryCode)` | Country-appropriate field label (`'ZIP Code'`, `'Postcode'`, `'Eircode'`, …); defaults to `'Postal Code'` |
186
- | `isPostalCodeSupported(country)` | `true` when `validator` has a postcode pattern for the (ISO 3166-1 alpha-2, case-insensitive; `UK`→`GB`) country. Unsupported / no-postcode countries return `false` |
187
- | `isValidPostalCode(country, postalCode)` | `true` when the (trimmed) postcode matches the country's format. **Fails open** unsupported / no-postcode countries always return `true`, so callers never block them. A supported country with an empty value returns `false`; check emptiness first to distinguish "required" from "malformed" |
188
- | `getPostalCodeExample(country)` | A curated example postcode for the field hint (US `12345 or 12345-6789`, GB `SW1A 1AA`, CA `A1A 1A1`, …), or `undefined` when there is no curated example |
186
+ | `isPostalCodeSupported(country)` | `true` when `validator` has a postcode pattern for the country. Normalization matches the backend: a **2-letter code is passed straight through** (so `'UK'`, not a `validator` locale, → `false`); non-2-letter names resolve via the backend's `COUNTRY_ALIASES` (`'United Kingdom'` → `GB`). Unresolvable / no-postcode countries return `false` |
187
+ | `isValidPostalCode(country, postalCode)` | `true` when the (trimmed) postcode matches the country's format. **Fails open** in exactly the backend's three cases: blank/whitespace postcode, unresolvable country, or an unsupported `validator` locale. Callers distinguish "required" (empty) from "malformed" by checking emptiness + `isPostalCodeSupported` themselves — an empty value fails open here, as it does server-side |
188
+ | `getPostalCodeExample(country)` | The example postcode the backend embeds in its 400 message (US `12345 or 12345-6789`, GB `SW1A 1AA`, CA `A1A 1A1`, …), or `undefined` when there is none. Kept verbatim in sync with the backend's `POSTAL_CODE_EXAMPLES` so the SDK's inline hint and the server's rejection message never contradict |
189
189
  | `getStateFromPostalCode(country, postalCode)` | Derives a US state / CA province code from a postcode for AVS enrichment; `null` for other countries or a malformed code |
190
190
 
191
+ ### Idempotency Helpers
192
+
193
+ Primitives for the stable checkout-create `Idempotency-Key` (TeamFloPay/backend#972). `@flopay/js` and `@flopay/react` use these automatically; import them only if you build your own create flow.
194
+
195
+ | Export | Description |
196
+ |--------|-------------|
197
+ | `IDEMPOTENCY_KEY_HEADER` | The header name (`'Idempotency-Key'`) the SDK sends on `POST /v1/checkouts/sessions`. Optional on the backend — absent means the legacy, non-idempotent path. |
198
+ | `MAX_IDEMPOTENCY_KEY_LENGTH` | `255` — the documented max length a supplied key may have. |
199
+ | `IDEMPOTENCY_IN_PROGRESS_CODE` | Backend `code` (`'IdempotencyKeyInProgress'`) marking a **retryable** in-progress replay: retry with the *same* key. Distinct from a payload-conflict (`409`), which is non-retryable. |
200
+ | `generateIdempotencyKey()` | Returns a cryptographically random, high-entropy key (`crypto.randomUUID()`, else 16 random bytes). Returns `undefined` when no secure RNG exists, so callers omit the header rather than emit a weak key. Never derived from request data. |
201
+ | `resolveIdempotencyKey(supplied?)` | Returns a supplied key unchanged after validating it is non-empty and ≤ `MAX_IDEMPOTENCY_KEY_LENGTH` (throws `FloPayError('validation_error')` otherwise, never echoing the value), or a freshly generated key when none is supplied. |
202
+
191
203
  ```ts
192
204
  import { isPostalCodeSupported, isValidPostalCode, getPostalCodeExample } from '@flopay/shared';
193
205
 
194
- isPostalCodeSupported('US'); // true
195
- isPostalCodeSupported('AE'); // false — UAE has no postcodes
196
- isValidPostalCode('GB', 'SW1A 1AA'); // true
197
- isValidPostalCode('GB', '12345'); // false — US shape in GB
198
- isValidPostalCode('AE', ''); // true — fail open, never blocks
199
- getPostalCodeExample('CA'); // 'A1A 1A1'
206
+ isPostalCodeSupported('US'); // true
207
+ isPostalCodeSupported('AE'); // false — UAE has no postcodes
208
+ isPostalCodeSupported('United Kingdom'); // true — full name → GB
209
+ isPostalCodeSupported('UK'); // false — 2-letter passthrough (not a validator locale)
210
+ isValidPostalCode('GB', 'SW1A 1AA'); // true
211
+ isValidPostalCode('GB', '12345'); // false — US shape in GB
212
+ isValidPostalCode('AE', ''); // true — fail open, never blocks
213
+ isValidPostalCode('US', ''); // true — blank fails open (enforce "required" separately)
214
+ getPostalCodeExample('CA'); // 'A1A 1A1'
200
215
  ```
201
216
 
202
217
  > **Runtime dependency.** Unlike the rest of `@flopay/shared`, these helpers pull in `validator` (imported via the `validator/lib/isPostalCode.js` submodule so bundlers only include the one check). It installs transitively when you depend on `@flopay/shared`.
package/dist/index.cjs CHANGED
@@ -58,6 +58,9 @@ __export(index_exports, {
58
58
  FloPayError: () => FloPayError,
59
59
  GLASS_DARK_APPEARANCE: () => GLASS_DARK_APPEARANCE,
60
60
  GLASS_LIGHT_APPEARANCE: () => GLASS_LIGHT_APPEARANCE,
61
+ IDEMPOTENCY_IN_PROGRESS_CODE: () => IDEMPOTENCY_IN_PROGRESS_CODE,
62
+ IDEMPOTENCY_KEY_HEADER: () => IDEMPOTENCY_KEY_HEADER,
63
+ MAX_IDEMPOTENCY_KEY_LENGTH: () => MAX_IDEMPOTENCY_KEY_LENGTH,
61
64
  MODERN_DARK_APPEARANCE: () => MODERN_DARK_APPEARANCE,
62
65
  MODERN_LIGHT_APPEARANCE: () => MODERN_LIGHT_APPEARANCE,
63
66
  NIGHT_APPEARANCE: () => NIGHT_APPEARANCE,
@@ -81,6 +84,7 @@ __export(index_exports, {
81
84
  filterStripeMethodsByCountry: () => filterStripeMethodsByCountry,
82
85
  filterStripeMethodsByCurrency: () => filterStripeMethodsByCurrency,
83
86
  foldIntoProducts: () => foldIntoProducts,
87
+ generateIdempotencyKey: () => generateIdempotencyKey,
84
88
  getConfiguredBillingApiUrl: () => getConfiguredBillingApiUrl,
85
89
  getCountryByCode: () => getCountryByCode,
86
90
  getCurrencyByCountry: () => getCurrencyByCountry,
@@ -107,6 +111,7 @@ __export(index_exports, {
107
111
  resolveAVSConfig: () => resolveAVSConfig,
108
112
  resolveBillingApiUrl: () => resolveBillingApiUrl,
109
113
  resolveButtonsLayoutTheme: () => resolveButtonsLayoutTheme,
114
+ resolveIdempotencyKey: () => resolveIdempotencyKey,
110
115
  resolveSessionCurrency: () => resolveSessionCurrency,
111
116
  resolveStripeMethodBrandVariant: () => resolveStripeMethodBrandVariant,
112
117
  resolveTheme: () => resolveTheme,
@@ -196,7 +201,7 @@ var PAYMENT_METHOD_LOGOS = {
196
201
  };
197
202
 
198
203
  // src/constants.ts
199
- var SDK_VERSION = "1.3.1";
204
+ var SDK_VERSION = "1.3.3";
200
205
  var FLO_SDK_VERSION_HEADER = "x-flo-sdk-version";
201
206
  var BILLING_API_URL_STAGING = "https://api.stage.flopay.com";
202
207
  var BILLING_API_URL_PRODUCTION = "https://api.flopay.com";
@@ -1985,53 +1990,93 @@ if (typeof isPostalCode !== "function") {
1985
1990
  var SUPPORTED_LOCALES = new Set(
1986
1991
  mod.locales.map((code) => code.toUpperCase())
1987
1992
  );
1988
- var LOCALE_ALIASES = {
1989
- UK: "GB"
1993
+ var COUNTRY_ALIASES = {
1994
+ AUSTRALIA: "AU",
1995
+ BRAZIL: "BR",
1996
+ CANADA: "CA",
1997
+ CH: "CH",
1998
+ DENMARK: "DK",
1999
+ DE: "DE",
2000
+ DEUTSCHLAND: "DE",
2001
+ FRANCE: "FR",
2002
+ GB: "GB",
2003
+ GERMANY: "DE",
2004
+ GREATBRITAIN: "GB",
2005
+ GREAT_BRITAIN: "GB",
2006
+ INDIA: "IN",
2007
+ IRELAND: "IE",
2008
+ ITALY: "IT",
2009
+ JAPAN: "JP",
2010
+ MEXICO: "MX",
2011
+ NETHERLANDS: "NL",
2012
+ NEWZEALAND: "NZ",
2013
+ NORWAY: "NO",
2014
+ POLAND: "PL",
2015
+ PORTUGAL: "PT",
2016
+ SINGAPORE: "SG",
2017
+ SOUTHAFRICA: "ZA",
2018
+ SPAIN: "ES",
2019
+ SWEDEN: "SE",
2020
+ SWITZERLAND: "CH",
2021
+ UK: "GB",
2022
+ UNITEDKINGDOM: "GB",
2023
+ UNITEDSTATES: "US",
2024
+ US: "US",
2025
+ USA: "US",
2026
+ "UNITED STATES": "US",
2027
+ "UNITED KINGDOM": "GB",
2028
+ "GREAT BRITAIN": "GB",
2029
+ ENGLAND: "GB"
1990
2030
  };
1991
- function normalizeCountry(country) {
1992
- const upper = country.trim().toUpperCase();
1993
- return LOCALE_ALIASES[upper] ?? upper;
2031
+ function normalizeCountryToIso2(country) {
2032
+ const upper = (country ?? "").trim().toUpperCase();
2033
+ if (!upper) return null;
2034
+ if (upper.length === 2) return upper;
2035
+ return COUNTRY_ALIASES[upper] ?? null;
1994
2036
  }
1995
2037
  var POSTAL_CODE_EXAMPLES = {
1996
- US: "12345 or 12345-6789",
1997
- GB: "SW1A 1AA",
1998
- CA: "A1A 1A1",
1999
2038
  AU: "2000",
2000
- NZ: "6011",
2001
- IE: "D02 AF30",
2039
+ BR: "01000-000",
2040
+ CA: "A1A 1A1",
2041
+ CH: "8001",
2002
2042
  DE: "10115",
2003
- FR: "75008",
2004
- NL: "1011 AB",
2043
+ DK: "1050",
2005
2044
  ES: "28001",
2006
- IT: "00100",
2045
+ FR: "75008",
2046
+ GB: "SW1A 1AA",
2047
+ IE: "D02 X285",
2007
2048
  IN: "110001",
2049
+ IT: "00118",
2008
2050
  JP: "100-0001",
2009
- BR: "01310-100",
2010
- MX: "01000",
2011
- SE: "114 55",
2012
- CH: "8001",
2051
+ MX: "06500",
2052
+ NL: "1012 JS",
2053
+ NO: "0150",
2054
+ NZ: "6011",
2013
2055
  PL: "00-001",
2014
- PT: "1000-001",
2015
- SG: "570150",
2016
- ZA: "0001",
2017
- NO: "0001",
2018
- DK: "1050",
2019
- FI: "00100",
2020
- AT: "1010",
2021
- BE: "1000",
2022
- CZ: "100 00"
2056
+ PT: "1100-148",
2057
+ SE: "111 22",
2058
+ SG: "018989",
2059
+ US: "12345 or 12345-6789",
2060
+ ZA: "8001"
2023
2061
  };
2024
2062
  function isPostalCodeSupported(country) {
2025
- if (!country) return false;
2026
- return SUPPORTED_LOCALES.has(normalizeCountry(country));
2063
+ const iso2 = normalizeCountryToIso2(country);
2064
+ return iso2 !== null && SUPPORTED_LOCALES.has(iso2);
2027
2065
  }
2028
2066
  function isValidPostalCode(country, postalCode) {
2029
- if (!isPostalCodeSupported(country)) return true;
2030
- return isPostalCode(postalCode.trim(), normalizeCountry(country));
2067
+ const trimmed = (postalCode ?? "").trim();
2068
+ if (!trimmed) return true;
2069
+ const iso2 = normalizeCountryToIso2(country);
2070
+ if (iso2 === null || !SUPPORTED_LOCALES.has(iso2)) return true;
2071
+ try {
2072
+ return isPostalCode(trimmed, iso2);
2073
+ } catch {
2074
+ return true;
2075
+ }
2031
2076
  }
2032
2077
  function getPostalCodeExample(country) {
2033
- if (!country) return void 0;
2034
- return POSTAL_CODE_EXAMPLES[normalizeCountry(country)];
2078
+ const iso2 = normalizeCountryToIso2(country);
2079
+ return iso2 ? POSTAL_CODE_EXAMPLES[iso2] : void 0;
2035
2080
  }
2036
2081
 
2037
2082
  // src/checkout-payload.ts
@@ -2234,6 +2279,49 @@ function isValidSecretKey(key) {
2234
2279
  function isSetupIntentClientSecret(clientSecret) {
2235
2280
  return typeof clientSecret === "string" && clientSecret.startsWith("seti_");
2236
2281
  }
2282
+
2283
+ // src/idempotency.ts
2284
+ var IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
2285
+ var MAX_IDEMPOTENCY_KEY_LENGTH = 255;
2286
+ var IDEMPOTENCY_IN_PROGRESS_CODE = "IdempotencyKeyInProgress";
2287
+ function getSecureRandomSource() {
2288
+ const source = globalThis.crypto;
2289
+ if (!source) return void 0;
2290
+ if (typeof source.randomUUID === "function" || typeof source.getRandomValues === "function") {
2291
+ return source;
2292
+ }
2293
+ return void 0;
2294
+ }
2295
+ function generateIdempotencyKey() {
2296
+ const source = getSecureRandomSource();
2297
+ if (!source) return void 0;
2298
+ if (typeof source.randomUUID === "function") {
2299
+ return source.randomUUID();
2300
+ }
2301
+ const bytes = new Uint8Array(16);
2302
+ source.getRandomValues(bytes);
2303
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
2304
+ }
2305
+ function resolveIdempotencyKey(supplied) {
2306
+ if (supplied === void 0) {
2307
+ return generateIdempotencyKey();
2308
+ }
2309
+ if (typeof supplied !== "string" || supplied.trim() === "") {
2310
+ throw new FloPayError(
2311
+ "idempotencyKey must be a non-empty string identifying exactly one checkout creation.",
2312
+ "validation_error",
2313
+ { code: "InvalidIdempotencyKey", param: "idempotencyKey" }
2314
+ );
2315
+ }
2316
+ if (supplied.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
2317
+ throw new FloPayError(
2318
+ `idempotencyKey must be at most ${MAX_IDEMPOTENCY_KEY_LENGTH} characters.`,
2319
+ "validation_error",
2320
+ { code: "InvalidIdempotencyKey", param: "idempotencyKey" }
2321
+ );
2322
+ }
2323
+ return supplied;
2324
+ }
2237
2325
  // Annotate the CommonJS export names for ESM import in node:
2238
2326
  0 && (module.exports = {
2239
2327
  BILLING_API_URL,
@@ -2264,6 +2352,9 @@ function isSetupIntentClientSecret(clientSecret) {
2264
2352
  FloPayError,
2265
2353
  GLASS_DARK_APPEARANCE,
2266
2354
  GLASS_LIGHT_APPEARANCE,
2355
+ IDEMPOTENCY_IN_PROGRESS_CODE,
2356
+ IDEMPOTENCY_KEY_HEADER,
2357
+ MAX_IDEMPOTENCY_KEY_LENGTH,
2267
2358
  MODERN_DARK_APPEARANCE,
2268
2359
  MODERN_LIGHT_APPEARANCE,
2269
2360
  NIGHT_APPEARANCE,
@@ -2287,6 +2378,7 @@ function isSetupIntentClientSecret(clientSecret) {
2287
2378
  filterStripeMethodsByCountry,
2288
2379
  filterStripeMethodsByCurrency,
2289
2380
  foldIntoProducts,
2381
+ generateIdempotencyKey,
2290
2382
  getConfiguredBillingApiUrl,
2291
2383
  getCountryByCode,
2292
2384
  getCurrencyByCountry,
@@ -2313,6 +2405,7 @@ function isSetupIntentClientSecret(clientSecret) {
2313
2405
  resolveAVSConfig,
2314
2406
  resolveBillingApiUrl,
2315
2407
  resolveButtonsLayoutTheme,
2408
+ resolveIdempotencyKey,
2316
2409
  resolveSessionCurrency,
2317
2410
  resolveStripeMethodBrandVariant,
2318
2411
  resolveTheme,