@flopay/js 1.0.3 → 1.1.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
@@ -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
@@ -643,9 +643,9 @@ var PaymentAPI = class {
643
643
  /**
644
644
  * Fetch and normalize a checkout session.
645
645
  *
646
- * Reads the backend's `gateway` field to determine the provider,
647
- * then wraps the session in a `NormalizedCheckoutSession` for
648
- * provider-agnostic consumption.
646
+ * Reads the backend's `gateways` map to enumerate provider-specific data,
647
+ * then wraps the session in a `NormalizedCheckoutSession` for provider-
648
+ * agnostic consumption.
649
649
  */
650
650
  async getUnifiedCheckoutSession(checkoutSessionId) {
651
651
  const res = await this.getCheckoutSession(checkoutSessionId);
@@ -656,16 +656,18 @@ var PaymentAPI = class {
656
656
  *
657
657
  * The backend will either succeed, return `type: '3ds_required'`
658
658
  * (with a `threeDSecureToken`), or return `type: 'paypal_redirect_required'`.
659
+ *
660
+ * @param userId Vestigial — backend's GatewayInterceptor routes via session,
661
+ * not headers, so this value is no longer sent on the wire. Kept in the
662
+ * signature for back-compat with existing callers; will be removed in a
663
+ * future major version.
659
664
  */
660
- async processPayment(userId, data, options) {
665
+ async processPayment(_userId, data, options) {
661
666
  const response = await fetch(
662
667
  `${this.baseUrl}/v1/checkouts/sessions/process`,
663
668
  {
664
669
  method: "POST",
665
- headers: {
666
- "Content-Type": "application/json",
667
- "x-user-id": userId
668
- },
670
+ headers: { "Content-Type": "application/json" },
669
671
  body: JSON.stringify(data)
670
672
  }
671
673
  );
@@ -742,19 +744,28 @@ var PaymentAPI = class {
742
744
  * Falls back to create + GET if the backend doesn't support `expand`.
743
745
  */
744
746
  async createAndFetchSession(params) {
747
+ const wireProducts = params.products ?? (0, import_shared3.foldIntoProducts)(params.items, params.subscriptions);
745
748
  const sessionCurrency = (0, import_shared3.resolveSessionCurrency)(
746
749
  params.currency,
747
750
  params.items,
748
- params.subscriptions
751
+ params.subscriptions,
752
+ wireProducts
749
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
+ }
750
761
  const payload = {
751
762
  clientId: params.clientId,
763
+ checkoutVersion: import_shared3.SDK_VERSION,
752
764
  successUrl: params.successUrl,
753
765
  cancelUrl: params.cancelUrl,
754
766
  currency: sessionCurrency,
755
767
  checkoutMode: params.checkoutMode ?? "full",
756
- items: (params.items ?? []).map((item) => (0, import_shared3.buildItemPayload)(item, sessionCurrency)),
757
- subscriptions: (params.subscriptions ?? []).map((sub) => (0, import_shared3.buildSubscriptionPayload)(sub, sessionCurrency)),
768
+ products: wireProducts.map((product) => (0, import_shared3.buildProductPayload)(product, sessionCurrency)),
758
769
  accountData: {
759
770
  userId: params.account.userId,
760
771
  firstName: params.account.firstName ?? null,
@@ -796,7 +807,7 @@ var PaymentAPI = class {
796
807
  throw await buildApiErrorFromResponse(response, "Failed to create checkout session");
797
808
  }
798
809
  const body = await response.json();
799
- if (body.data && "gateway" in body.data) {
810
+ if (body.data && "gateways" in body.data) {
800
811
  this.autoCacheDisplayData(body.data.uuid, params);
801
812
  const merged = this.mergeCachedDisplayData(body.data);
802
813
  return {
@@ -849,57 +860,57 @@ var PaymentAPI = class {
849
860
  }
850
861
  /** Normalize a raw session into a provider-agnostic shape. */
851
862
  normalizeRawSession(session) {
852
- const gateway = session.gateway;
853
- if (gateway === "chargebee") {
854
- return {
855
- provider: "chargebee",
856
- mode: "tokenize",
857
- data: { session: this.toCheckoutSession(session) },
858
- raw: { data: session }
859
- };
860
- }
861
- if (gateway === "stripe") {
863
+ const gateways = session.gateways ?? {};
864
+ const providers = [];
865
+ const data = {
866
+ session: this.toCheckoutSession(session)
867
+ };
868
+ const stripeGateway = gateways.stripe;
869
+ if (stripeGateway?.publishableKey) {
870
+ providers.push("stripe");
862
871
  const rawSession = session;
863
- const gatewayDataRecord = session.gatewayData ?? {};
864
872
  const stripeClientSecret = [
865
873
  rawSession["stripeClientSecret"],
866
- gatewayDataRecord["stripeClientSecret"],
867
- gatewayDataRecord["clientSecret"]
874
+ stripeGateway.stripeClientSecret
868
875
  ].find((value) => typeof value === "string" && value.length > 0);
869
- return {
870
- provider: "stripe",
871
- mode: "tokenize",
872
- data: {
873
- session: this.toCheckoutSession(session),
874
- stripe: {
875
- clientSecret: stripeClientSecret ?? "",
876
- publishableKey: session.gatewayData?.publishableKey ?? void 0,
877
- paypalPublishableKey: session.gatewayData?.paypalPublishableKey ?? void 0
878
- }
879
- },
880
- raw: { data: session }
876
+ data.stripe = {
877
+ clientSecret: stripeClientSecret ?? "",
878
+ publishableKey: stripeGateway.publishableKey ?? void 0,
879
+ paypalPublishableKey: stripeGateway.paypalPublishableKey ?? void 0,
880
+ environment: stripeGateway.environment
881
+ };
882
+ }
883
+ const paypalGateway = gateways.paypal;
884
+ if (paypalGateway?.publishableKey) {
885
+ providers.push("paypal");
886
+ data.paypal = {
887
+ publishableKey: paypalGateway.publishableKey,
888
+ environment: paypalGateway.environment
881
889
  };
882
890
  }
883
891
  return {
884
- provider: "recurly",
892
+ providers,
885
893
  mode: "tokenize",
886
- data: { session: this.toCheckoutSession(session) },
894
+ data,
887
895
  raw: { data: session }
888
896
  };
889
897
  }
890
898
  /** Convert raw session to the SDK CheckoutSession shape. */
891
899
  toCheckoutSession(raw) {
892
- const totalAmount = [
893
- ...raw.subscriptions.map((s) => s.overrideAmount ?? s.totalAmount ?? 0),
894
- ...raw.items.map((i) => i.overrideAmount ?? i.totalAmount ?? 0)
895
- ].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;
896
907
  const amountInCents = Math.round(totalAmount * 100);
897
- 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";
898
910
  return {
899
- // Core fields (backward compat)
900
911
  id: raw.uuid,
901
912
  clientSecret: raw.nonce,
902
- mode: raw.subscriptions.length > 0 ? "subscription" : "payment",
913
+ mode,
903
914
  status: this.toCheckoutSessionStatus(raw.status),
904
915
  amount: amountInCents,
905
916
  currency,
@@ -917,16 +928,22 @@ var PaymentAPI = class {
917
928
  line2: raw.accountData.addressLine2 ?? void 0
918
929
  },
919
930
  metadata: {},
920
- // Full session data from billing API
921
931
  checkoutMode: raw.checkoutMode,
922
- items: raw.items,
923
- 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
+ })),
924
939
  successUrl: raw.successUrl,
925
940
  cancelUrl: raw.cancelUrl,
926
941
  coupons: raw.coupons,
942
+ subtotalAmount: raw.subtotalAmount,
943
+ discountAmount: raw.discountAmount,
944
+ totalAmount: raw.totalAmount,
927
945
  createdAt: raw.createdAt,
928
- gateway: raw.gateway,
929
- gatewayData: raw.gatewayData,
946
+ gateways: raw.gateways,
930
947
  accountData: raw.accountData,
931
948
  tagsData: raw.tagsData
932
949
  };
@@ -983,70 +1000,63 @@ var PaymentAPI = class {
983
1000
  * Stash the display-only fields the consumer passed into a create-session
984
1001
  * call. Runs after the backend assigns a UUID so a later GET on the same
985
1002
  * session (typically after a redirect) can fill in fields the backend no
986
- * longer persists — `overrideAmount`, `totalAmount`, `itemName`, etc.
1003
+ * longer persists — `overrideAmount`, `totalAmount`, `name`, etc.
987
1004
  *
988
1005
  * No-op when no UUID is available.
989
1006
  */
990
1007
  autoCacheDisplayData(sessionId, params) {
991
1008
  if (!sessionId) return;
992
- 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) {
993
1011
  return;
994
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
+ );
995
1020
  cacheSessionDisplayData(sessionId, {
996
- currency: params.currency,
997
- items: params.items,
998
- 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
+ }))
999
1030
  });
1000
1031
  }
1001
1032
  /**
1002
1033
  * Merge cached display-only fields (set by {@link cacheSessionDisplayData})
1003
- * into a raw session response and mirror the new/legacy name aliases so
1004
- * readers using either field always get a value when one exists.
1005
- *
1006
- * Server values always win — cache fills in only where the server returned
1007
- * `null` / `undefined`.
1034
+ * into a raw session response. Server values always win cache fills in
1035
+ * only where the server returned `null` / `undefined`.
1008
1036
  */
1009
1037
  mergeCachedDisplayData(raw) {
1010
1038
  const cached = getSessionDisplayData(raw.uuid);
1011
- const cachedItems = /* @__PURE__ */ new Map();
1012
- for (const item of cached?.items ?? []) {
1013
- const key = item.code ?? item.providerItemId;
1014
- if (key) cachedItems.set(key, item);
1015
- }
1016
- const cachedSubs = /* @__PURE__ */ new Map();
1017
- for (const sub of cached?.subscriptions ?? []) {
1018
- const key = sub.code ?? sub.providerPlanId;
1019
- if (key) cachedSubs.set(key, sub);
1020
- }
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
+ });
1021
1056
  return {
1022
1057
  ...raw,
1023
1058
  currency: raw.currency ?? cached?.currency,
1024
- items: raw.items.map((item) => {
1025
- const key = item.code ?? item.providerItemId;
1026
- const fallback = key ? cachedItems.get(key) : void 0;
1027
- const resolvedName = item.itemName ?? item.providerItemName ?? fallback?.itemName ?? fallback?.providerItemName;
1028
- return {
1029
- ...item,
1030
- itemName: resolvedName,
1031
- providerItemName: resolvedName,
1032
- totalAmount: item.totalAmount ?? fallback?.totalAmount,
1033
- overrideAmount: item.overrideAmount ?? fallback?.overrideAmount,
1034
- currency: item.currency ?? fallback?.currency
1035
- };
1036
- }),
1037
- subscriptions: raw.subscriptions.map((sub) => {
1038
- const key = sub.code ?? sub.providerPlanId;
1039
- const fallback = key ? cachedSubs.get(key) : void 0;
1040
- const resolvedName = sub.subscriptionName ?? sub.providerPlanName ?? fallback?.subscriptionName ?? fallback?.providerPlanName;
1041
- return {
1042
- ...sub,
1043
- subscriptionName: resolvedName,
1044
- providerPlanName: resolvedName,
1045
- totalAmount: sub.totalAmount ?? fallback?.totalAmount,
1046
- overrideAmount: sub.overrideAmount ?? fallback?.overrideAmount,
1047
- currency: sub.currency ?? fallback?.currency
1048
- };
1049
- })
1059
+ products: mergedProducts
1050
1060
  };
1051
1061
  }
1052
1062
  };
@@ -1183,12 +1193,33 @@ async function loadFloPay(publishableKey, options) {
1183
1193
 
1184
1194
  // src/create-checkout-session.ts
1185
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
+ }
1186
1216
  async function createCheckoutSession(options) {
1187
1217
  const {
1188
1218
  billingApiUrl,
1189
1219
  checkoutBaseUrl,
1190
1220
  items = [],
1191
1221
  subscriptions = [],
1222
+ products,
1192
1223
  account,
1193
1224
  successUrl,
1194
1225
  cancelUrl,
@@ -1202,15 +1233,30 @@ async function createCheckoutSession(options) {
1202
1233
  currency,
1203
1234
  utmMetadata
1204
1235
  } = options;
1205
- 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
+ }
1206
1252
  const payload = {
1207
1253
  clientId,
1254
+ checkoutVersion: import_shared6.SDK_VERSION,
1208
1255
  successUrl,
1209
1256
  cancelUrl,
1210
1257
  currency: sessionCurrency,
1211
1258
  checkoutMode,
1212
- items: items.map((item) => (0, import_shared6.buildItemPayload)(item, sessionCurrency)),
1213
- subscriptions: subscriptions.map((sub) => (0, import_shared6.buildSubscriptionPayload)(sub, sessionCurrency)),
1259
+ products: wireProducts.map((product) => (0, import_shared6.buildProductPayload)(product, sessionCurrency)),
1214
1260
  accountData: {
1215
1261
  userId: account.userId,
1216
1262
  firstName: account.firstName ?? null,
@@ -1252,16 +1298,25 @@ async function createCheckoutSession(options) {
1252
1298
  } finally {
1253
1299
  clearTimeout(timer);
1254
1300
  }
1301
+ if (status >= 400) {
1302
+ throw buildCheckoutSessionError(status, body);
1303
+ }
1255
1304
  if (status === 201) {
1256
1305
  const uuid = body?.data?.uuid;
1257
1306
  if (!uuid) {
1258
1307
  throw new Error("Checkout session created but no UUID was returned by the billing API");
1259
1308
  }
1260
- if (items.length || subscriptions.length || currency) {
1309
+ if (wireProducts.length || sessionCurrency) {
1261
1310
  cacheSessionDisplayData(uuid, {
1262
- currency,
1263
- items,
1264
- 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
+ }))
1265
1320
  });
1266
1321
  }
1267
1322
  const redirectUrl = new URL(`${checkoutBaseUrl.replace(/\/+$/, "")}/secure`);