@flopay/js 1.1.0 → 1.1.4

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
@@ -154,6 +154,27 @@ const result = await createCheckoutSession({
154
154
 
155
155
  Use `createCheckoutSessionWithRetries` for automatic retry with exponential backoff on timeout errors.
156
156
 
157
+ Coupon validation errors are surfaced as `FloPayError` with structured `code`:
158
+
159
+ - `CouponLimitExceeded` — more than 5 coupon codes supplied (also enforced client-side before the request leaves the browser).
160
+ - `CouponCurrencyUnsupported` — an amount-based coupon has no price for the cart currency.
161
+
162
+ ```ts
163
+ import { FloPayError } from '@flopay/shared';
164
+
165
+ try {
166
+ await createCheckoutSession({ /* … */ couponCodes });
167
+ } catch (err) {
168
+ if (err instanceof FloPayError) {
169
+ if (err.code === 'CouponLimitExceeded') {
170
+ // show "Too many coupons" toast
171
+ } else if (err.code === 'CouponCurrencyUnsupported') {
172
+ // show "Coupon is not valid for this currency" toast
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
157
178
  ## API Reference
158
179
 
159
180
  ### Exports
package/dist/index.cjs CHANGED
@@ -744,19 +744,28 @@ var PaymentAPI = class {
744
744
  * Falls back to create + GET if the backend doesn't support `expand`.
745
745
  */
746
746
  async createAndFetchSession(params) {
747
+ const wireProducts = params.products ?? (0, import_shared3.foldIntoProducts)(params.items, params.subscriptions);
747
748
  const sessionCurrency = (0, import_shared3.resolveSessionCurrency)(
748
749
  params.currency,
749
750
  params.items,
750
- params.subscriptions
751
+ params.subscriptions,
752
+ wireProducts
751
753
  );
754
+ if (!sessionCurrency) {
755
+ throw new import_shared3.FloPayError(
756
+ "currency is required: pass `currency` on the session, or include a `currency` on the first item/subscription/product.",
757
+ "validation_error",
758
+ { code: "CurrencyRequired", param: "currency" }
759
+ );
760
+ }
752
761
  const payload = {
753
762
  clientId: params.clientId,
763
+ checkoutVersion: import_shared3.SDK_VERSION,
754
764
  successUrl: params.successUrl,
755
765
  cancelUrl: params.cancelUrl,
756
766
  currency: sessionCurrency,
757
767
  checkoutMode: params.checkoutMode ?? "full",
758
- items: (params.items ?? []).map((item) => (0, import_shared3.buildItemPayload)(item, sessionCurrency)),
759
- subscriptions: (params.subscriptions ?? []).map((sub) => (0, import_shared3.buildSubscriptionPayload)(sub, sessionCurrency)),
768
+ products: wireProducts.map((product) => (0, import_shared3.buildProductPayload)(product, sessionCurrency)),
760
769
  accountData: {
761
770
  userId: params.account.userId,
762
771
  firstName: params.account.firstName ?? null,
@@ -888,17 +897,20 @@ var PaymentAPI = class {
888
897
  }
889
898
  /** Convert raw session to the SDK CheckoutSession shape. */
890
899
  toCheckoutSession(raw) {
891
- const totalAmount = [
892
- ...raw.subscriptions.map((s) => s.overrideAmount ?? s.totalAmount ?? 0),
893
- ...raw.items.map((i) => i.overrideAmount ?? i.totalAmount ?? 0)
894
- ].reduce((sum, val) => sum + val, 0);
900
+ const rawProducts = raw.products ?? [];
901
+ const hasBackendTotal = typeof raw.totalAmount === "number" && Number.isFinite(raw.totalAmount);
902
+ const computedTotal = rawProducts.reduce(
903
+ (sum, p) => sum + (p.overrideAmount ?? p.totalAmount ?? 0),
904
+ 0
905
+ );
906
+ const totalAmount = hasBackendTotal ? raw.totalAmount : computedTotal;
895
907
  const amountInCents = Math.round(totalAmount * 100);
896
- const currency = raw.currency ?? raw.subscriptions[0]?.currency ?? raw.items[0]?.currency ?? "USD";
908
+ const currency = raw.currency ?? rawProducts[0]?.currency ?? "USD";
909
+ const mode = rawProducts.some((p) => p.type === "subscription") ? "subscription" : "payment";
897
910
  return {
898
- // Core fields (backward compat)
899
911
  id: raw.uuid,
900
912
  clientSecret: raw.nonce,
901
- mode: raw.subscriptions.length > 0 ? "subscription" : "payment",
913
+ mode,
902
914
  status: this.toCheckoutSessionStatus(raw.status),
903
915
  amount: amountInCents,
904
916
  currency,
@@ -916,13 +928,20 @@ var PaymentAPI = class {
916
928
  line2: raw.accountData.addressLine2 ?? void 0
917
929
  },
918
930
  metadata: {},
919
- // Full session data from billing API
920
931
  checkoutMode: raw.checkoutMode,
921
- items: raw.items,
922
- subscriptions: raw.subscriptions,
932
+ products: rawProducts.map((p) => ({
933
+ ...p,
934
+ totalAmount: typeof p.totalAmount === "number" ? p.totalAmount : void 0,
935
+ overrideAmount: typeof p.overrideAmount === "number" ? p.overrideAmount : null,
936
+ currency: typeof p.currency === "string" ? p.currency : void 0,
937
+ metadata: p.metadata ?? null
938
+ })),
923
939
  successUrl: raw.successUrl,
924
940
  cancelUrl: raw.cancelUrl,
925
941
  coupons: raw.coupons,
942
+ subtotalAmount: raw.subtotalAmount,
943
+ discountAmount: raw.discountAmount,
944
+ totalAmount: raw.totalAmount,
926
945
  createdAt: raw.createdAt,
927
946
  gateways: raw.gateways,
928
947
  accountData: raw.accountData,
@@ -981,70 +1000,63 @@ var PaymentAPI = class {
981
1000
  * Stash the display-only fields the consumer passed into a create-session
982
1001
  * call. Runs after the backend assigns a UUID so a later GET on the same
983
1002
  * session (typically after a redirect) can fill in fields the backend no
984
- * longer persists — `overrideAmount`, `totalAmount`, `itemName`, etc.
1003
+ * longer persists — `overrideAmount`, `totalAmount`, `name`, etc.
985
1004
  *
986
1005
  * No-op when no UUID is available.
987
1006
  */
988
1007
  autoCacheDisplayData(sessionId, params) {
989
1008
  if (!sessionId) return;
990
- if (!params.items?.length && !params.subscriptions?.length && !params.currency) {
1009
+ const products = params.products ?? (0, import_shared3.foldIntoProducts)(params.items, params.subscriptions);
1010
+ if (products.length === 0 && !params.currency) {
991
1011
  return;
992
1012
  }
1013
+ const usingUnifiedProducts = params.products !== void 0;
1014
+ const sessionCurrency = (0, import_shared3.resolveSessionCurrency)(
1015
+ params.currency,
1016
+ usingUnifiedProducts ? void 0 : params.items,
1017
+ usingUnifiedProducts ? void 0 : params.subscriptions,
1018
+ products
1019
+ );
993
1020
  cacheSessionDisplayData(sessionId, {
994
- currency: params.currency,
995
- items: params.items,
996
- subscriptions: params.subscriptions
1021
+ currency: sessionCurrency ?? void 0,
1022
+ products: products.map((p) => ({
1023
+ code: p.code ?? p.providerItemId ?? p.providerPlanId,
1024
+ type: p.type,
1025
+ name: p.name ?? p.itemName ?? p.providerItemName ?? p.subscriptionName ?? p.providerPlanName ?? null,
1026
+ totalAmount: p.totalAmount,
1027
+ overrideAmount: p.overrideAmount,
1028
+ currency: p.currency ?? sessionCurrency ?? void 0
1029
+ }))
997
1030
  });
998
1031
  }
999
1032
  /**
1000
1033
  * Merge cached display-only fields (set by {@link cacheSessionDisplayData})
1001
- * into a raw session response and mirror the new/legacy name aliases so
1002
- * readers using either field always get a value when one exists.
1003
- *
1004
- * Server values always win — cache fills in only where the server returned
1005
- * `null` / `undefined`.
1034
+ * into a raw session response. Server values always win cache fills in
1035
+ * only where the server returned `null` / `undefined`.
1006
1036
  */
1007
1037
  mergeCachedDisplayData(raw) {
1008
1038
  const cached = getSessionDisplayData(raw.uuid);
1009
- const cachedItems = /* @__PURE__ */ new Map();
1010
- for (const item of cached?.items ?? []) {
1011
- const key = item.code ?? item.providerItemId;
1012
- if (key) cachedItems.set(key, item);
1013
- }
1014
- const cachedSubs = /* @__PURE__ */ new Map();
1015
- for (const sub of cached?.subscriptions ?? []) {
1016
- const key = sub.code ?? sub.providerPlanId;
1017
- if (key) cachedSubs.set(key, sub);
1018
- }
1039
+ const cachedProducts = /* @__PURE__ */ new Map();
1040
+ const productKey = (type, code) => code && type ? `${type}:${code}` : void 0;
1041
+ for (const p of cached?.products ?? []) {
1042
+ const key = productKey(p.type, p.code);
1043
+ if (key) cachedProducts.set(key, p);
1044
+ }
1045
+ const mergedProducts = (raw.products ?? []).map((p) => {
1046
+ const key = productKey(p.type, p.code);
1047
+ const fallback = key ? cachedProducts.get(key) : void 0;
1048
+ return {
1049
+ ...p,
1050
+ name: p.name ?? fallback?.name ?? null,
1051
+ totalAmount: p.totalAmount ?? fallback?.totalAmount,
1052
+ overrideAmount: p.overrideAmount ?? fallback?.overrideAmount,
1053
+ currency: p.currency ?? fallback?.currency
1054
+ };
1055
+ });
1019
1056
  return {
1020
1057
  ...raw,
1021
1058
  currency: raw.currency ?? cached?.currency,
1022
- items: raw.items.map((item) => {
1023
- const key = item.code ?? item.providerItemId;
1024
- const fallback = key ? cachedItems.get(key) : void 0;
1025
- const resolvedName = item.itemName ?? item.providerItemName ?? fallback?.itemName ?? fallback?.providerItemName;
1026
- return {
1027
- ...item,
1028
- itemName: resolvedName,
1029
- providerItemName: resolvedName,
1030
- totalAmount: item.totalAmount ?? fallback?.totalAmount,
1031
- overrideAmount: item.overrideAmount ?? fallback?.overrideAmount,
1032
- currency: item.currency ?? fallback?.currency
1033
- };
1034
- }),
1035
- subscriptions: raw.subscriptions.map((sub) => {
1036
- const key = sub.code ?? sub.providerPlanId;
1037
- const fallback = key ? cachedSubs.get(key) : void 0;
1038
- const resolvedName = sub.subscriptionName ?? sub.providerPlanName ?? fallback?.subscriptionName ?? fallback?.providerPlanName;
1039
- return {
1040
- ...sub,
1041
- subscriptionName: resolvedName,
1042
- providerPlanName: resolvedName,
1043
- totalAmount: sub.totalAmount ?? fallback?.totalAmount,
1044
- overrideAmount: sub.overrideAmount ?? fallback?.overrideAmount,
1045
- currency: sub.currency ?? fallback?.currency
1046
- };
1047
- })
1059
+ products: mergedProducts
1048
1060
  };
1049
1061
  }
1050
1062
  };
@@ -1181,12 +1193,33 @@ async function loadFloPay(publishableKey, options) {
1181
1193
 
1182
1194
  // src/create-checkout-session.ts
1183
1195
  var import_shared6 = require("@flopay/shared");
1196
+ var MAX_COUPON_CODES = 5;
1197
+ function readString2(value) {
1198
+ return typeof value === "string" && value.trim() ? value : void 0;
1199
+ }
1200
+ function buildCheckoutSessionError(status, payload) {
1201
+ const nested = payload?.error;
1202
+ const code = readString2(payload?.code) ?? readString2(nested?.code) ?? `http_${status}`;
1203
+ const message = readString2(payload?.message) ?? readString2(nested?.message) ?? defaultMessageForCode(code, status);
1204
+ return new import_shared6.FloPayError(message, "api_error", { code, statusCode: status });
1205
+ }
1206
+ function defaultMessageForCode(code, status) {
1207
+ switch (code) {
1208
+ case "CouponLimitExceeded":
1209
+ return `Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`;
1210
+ case "CouponCurrencyUnsupported":
1211
+ return "One of the applied coupons has no price configured for the cart currency.";
1212
+ default:
1213
+ return `Failed to create checkout session (HTTP ${status}).`;
1214
+ }
1215
+ }
1184
1216
  async function createCheckoutSession(options) {
1185
1217
  const {
1186
1218
  billingApiUrl,
1187
1219
  checkoutBaseUrl,
1188
1220
  items = [],
1189
1221
  subscriptions = [],
1222
+ products,
1190
1223
  account,
1191
1224
  successUrl,
1192
1225
  cancelUrl,
@@ -1200,15 +1233,30 @@ async function createCheckoutSession(options) {
1200
1233
  currency,
1201
1234
  utmMetadata
1202
1235
  } = options;
1203
- const sessionCurrency = (0, import_shared6.resolveSessionCurrency)(currency, items, subscriptions);
1236
+ if (couponCodes.length > MAX_COUPON_CODES) {
1237
+ throw new import_shared6.FloPayError(
1238
+ `Too many coupon codes \u2014 a checkout session accepts at most ${MAX_COUPON_CODES}.`,
1239
+ "validation_error",
1240
+ { code: "CouponLimitExceeded", param: "couponCodes" }
1241
+ );
1242
+ }
1243
+ const wireProducts = products ?? (0, import_shared6.foldIntoProducts)(items, subscriptions);
1244
+ const sessionCurrency = (0, import_shared6.resolveSessionCurrency)(currency, items, subscriptions, wireProducts);
1245
+ if (!sessionCurrency) {
1246
+ throw new import_shared6.FloPayError(
1247
+ "currency is required: pass `currency` on the session, or include a `currency` on the first item/subscription/product.",
1248
+ "validation_error",
1249
+ { code: "CurrencyRequired", param: "currency" }
1250
+ );
1251
+ }
1204
1252
  const payload = {
1205
1253
  clientId,
1254
+ checkoutVersion: import_shared6.SDK_VERSION,
1206
1255
  successUrl,
1207
1256
  cancelUrl,
1208
1257
  currency: sessionCurrency,
1209
1258
  checkoutMode,
1210
- items: items.map((item) => (0, import_shared6.buildItemPayload)(item, sessionCurrency)),
1211
- subscriptions: subscriptions.map((sub) => (0, import_shared6.buildSubscriptionPayload)(sub, sessionCurrency)),
1259
+ products: wireProducts.map((product) => (0, import_shared6.buildProductPayload)(product, sessionCurrency)),
1212
1260
  accountData: {
1213
1261
  userId: account.userId,
1214
1262
  firstName: account.firstName ?? null,
@@ -1250,16 +1298,25 @@ async function createCheckoutSession(options) {
1250
1298
  } finally {
1251
1299
  clearTimeout(timer);
1252
1300
  }
1301
+ if (status >= 400) {
1302
+ throw buildCheckoutSessionError(status, body);
1303
+ }
1253
1304
  if (status === 201) {
1254
1305
  const uuid = body?.data?.uuid;
1255
1306
  if (!uuid) {
1256
1307
  throw new Error("Checkout session created but no UUID was returned by the billing API");
1257
1308
  }
1258
- if (items.length || subscriptions.length || currency) {
1309
+ if (wireProducts.length || sessionCurrency) {
1259
1310
  cacheSessionDisplayData(uuid, {
1260
- currency,
1261
- items,
1262
- subscriptions
1311
+ currency: sessionCurrency,
1312
+ products: wireProducts.map((p) => ({
1313
+ code: p.code ?? p.providerItemId ?? p.providerPlanId,
1314
+ type: p.type,
1315
+ name: p.name ?? p.itemName ?? p.providerItemName ?? p.subscriptionName ?? p.providerPlanName ?? null,
1316
+ totalAmount: p.totalAmount,
1317
+ overrideAmount: p.overrideAmount,
1318
+ currency: p.currency ?? sessionCurrency
1319
+ }))
1263
1320
  });
1264
1321
  }
1265
1322
  const redirectUrl = new URL(`${checkoutBaseUrl.replace(/\/+$/, "")}/secure`);