@fonderie/billing 5.0.2 → 5.3.0

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.
@@ -9,7 +9,11 @@ interface IBillingEvent {
9
9
  interface INormalizedSubscription {
10
10
  subscriberType: SubscriberType;
11
11
  subscriberId: string;
12
+ /** Plan name from the price nickname (legacy fallback; prefer resolving via priceLookupKey/priceId). */
12
13
  plan: string;
14
+ /** The subscription item's price lookup_key + id — for config-based plan attribution (§16.3). */
15
+ priceLookupKey: string | null;
16
+ priceId: string | null;
13
17
  status: string;
14
18
  providerCustomerId: string;
15
19
  providerSubscriptionId: string;
@@ -19,6 +23,16 @@ interface INormalizedSubscription {
19
23
  trialEndsAt: Date | null;
20
24
  interval: 'month' | 'year';
21
25
  }
26
+ interface IResolvedPrice {
27
+ priceId: string;
28
+ lookupKey: string | null;
29
+ unitAmount: number;
30
+ currency: string;
31
+ interval: 'month' | 'year';
32
+ nickname: string | null;
33
+ productId: string;
34
+ active: boolean;
35
+ }
22
36
  interface IBillingProvider {
23
37
  name: string;
24
38
  createCustomer(opts: {
@@ -40,6 +54,16 @@ interface IBillingProvider {
40
54
  }): Promise<{
41
55
  url: string;
42
56
  }>;
57
+ resolvePriceById(priceId: string): Promise<IResolvedPrice | null>;
58
+ resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>;
59
+ updateSubscription(opts: {
60
+ subscriptionId: string;
61
+ priceId: string;
62
+ }): Promise<{
63
+ status: string;
64
+ currentPeriodStart: Date | null;
65
+ currentPeriodEnd: Date | null;
66
+ }>;
43
67
  createPortalSession(opts: {
44
68
  customerId: string;
45
69
  returnUrl: string;
@@ -59,8 +83,30 @@ interface ICounterBackend {
59
83
  }
60
84
 
61
85
  interface IBillingPlanPrice {
62
- amount: number;
86
+ /** Stable Stripe Price lookup_key, e.g. "pro_monthly". Preferred reference. */
87
+ lookupKey?: string;
88
+ /** Stripe price id. Used for hydration and as a lookup_key fallback. */
63
89
  priceId?: string;
90
+ /**
91
+ * @deprecated Display amount in cents. When pricing hydration is on and the
92
+ * price resolves from Stripe, the live amount wins; this is the fallback only.
93
+ */
94
+ amount?: number;
95
+ }
96
+ /**
97
+ * Read-through pricing: amount/currency come from Stripe (source of truth) rather
98
+ * than the duplicated `amount` above. Gated by `hydration` (kill-switch, §16.9).
99
+ * See packages/billing/docs/pricing-hydration.md.
100
+ */
101
+ interface IBillingPricingConfig {
102
+ /** Kill-switch. When false (default), use the deprecated hardcoded amount/USD path. */
103
+ hydration?: boolean;
104
+ /** Fresh-cache TTL. Default 300_000 (5m). */
105
+ cacheTtlMs?: number;
106
+ /** Serve last-cached price on a transient resolution miss (lookup_key transfer). Default 3_600_000 (1h). */
107
+ transferGraceMs?: number;
108
+ /** Max age to serve stale price during a Stripe outage before giving up. Default 86_400_000 (24h). */
109
+ maxStaleMs?: number;
64
110
  }
65
111
  interface IBillingPlanDefaults {
66
112
  warnAt?: number;
@@ -92,6 +138,7 @@ interface IBillingConfig {
92
138
  backend?: RateLimitBackendConfig;
93
139
  };
94
140
  notifications?: IBillingNotificationsConfig;
141
+ pricing?: IBillingPricingConfig;
95
142
  }
96
143
  declare const MESSAGE_KEYS: {
97
144
  readonly limitWarning: "billing.limit-warning";
@@ -105,4 +152,4 @@ declare function requirePlan(plans: string | string[], store: IStoreAdapter, ctx
105
152
 
106
153
  declare function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware;
107
154
 
108
- export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IBillingEvent as b, type ICounterBackend as c, type IBillingPlan as d, type IBillingNotificationsConfig as e, type IBillingPlanDefaults as f, requirePlan as r, withBilling as w };
155
+ export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IResolvedPrice as b, type IBillingEvent as c, type ICounterBackend as d, type IBillingPlan as e, type IBillingNotificationsConfig as f, type IBillingPlanDefaults as g, type IBillingPlanPrice as h, type IBillingPricingConfig as i, requirePlan as r, withBilling as w };
@@ -9,7 +9,11 @@ interface IBillingEvent {
9
9
  interface INormalizedSubscription {
10
10
  subscriberType: SubscriberType;
11
11
  subscriberId: string;
12
+ /** Plan name from the price nickname (legacy fallback; prefer resolving via priceLookupKey/priceId). */
12
13
  plan: string;
14
+ /** The subscription item's price lookup_key + id — for config-based plan attribution (§16.3). */
15
+ priceLookupKey: string | null;
16
+ priceId: string | null;
13
17
  status: string;
14
18
  providerCustomerId: string;
15
19
  providerSubscriptionId: string;
@@ -19,6 +23,16 @@ interface INormalizedSubscription {
19
23
  trialEndsAt: Date | null;
20
24
  interval: 'month' | 'year';
21
25
  }
26
+ interface IResolvedPrice {
27
+ priceId: string;
28
+ lookupKey: string | null;
29
+ unitAmount: number;
30
+ currency: string;
31
+ interval: 'month' | 'year';
32
+ nickname: string | null;
33
+ productId: string;
34
+ active: boolean;
35
+ }
22
36
  interface IBillingProvider {
23
37
  name: string;
24
38
  createCustomer(opts: {
@@ -40,6 +54,16 @@ interface IBillingProvider {
40
54
  }): Promise<{
41
55
  url: string;
42
56
  }>;
57
+ resolvePriceById(priceId: string): Promise<IResolvedPrice | null>;
58
+ resolvePricesByLookupKey(lookupKeys: string[]): Promise<Map<string, IResolvedPrice>>;
59
+ updateSubscription(opts: {
60
+ subscriptionId: string;
61
+ priceId: string;
62
+ }): Promise<{
63
+ status: string;
64
+ currentPeriodStart: Date | null;
65
+ currentPeriodEnd: Date | null;
66
+ }>;
43
67
  createPortalSession(opts: {
44
68
  customerId: string;
45
69
  returnUrl: string;
@@ -59,8 +83,30 @@ interface ICounterBackend {
59
83
  }
60
84
 
61
85
  interface IBillingPlanPrice {
62
- amount: number;
86
+ /** Stable Stripe Price lookup_key, e.g. "pro_monthly". Preferred reference. */
87
+ lookupKey?: string;
88
+ /** Stripe price id. Used for hydration and as a lookup_key fallback. */
63
89
  priceId?: string;
90
+ /**
91
+ * @deprecated Display amount in cents. When pricing hydration is on and the
92
+ * price resolves from Stripe, the live amount wins; this is the fallback only.
93
+ */
94
+ amount?: number;
95
+ }
96
+ /**
97
+ * Read-through pricing: amount/currency come from Stripe (source of truth) rather
98
+ * than the duplicated `amount` above. Gated by `hydration` (kill-switch, §16.9).
99
+ * See packages/billing/docs/pricing-hydration.md.
100
+ */
101
+ interface IBillingPricingConfig {
102
+ /** Kill-switch. When false (default), use the deprecated hardcoded amount/USD path. */
103
+ hydration?: boolean;
104
+ /** Fresh-cache TTL. Default 300_000 (5m). */
105
+ cacheTtlMs?: number;
106
+ /** Serve last-cached price on a transient resolution miss (lookup_key transfer). Default 3_600_000 (1h). */
107
+ transferGraceMs?: number;
108
+ /** Max age to serve stale price during a Stripe outage before giving up. Default 86_400_000 (24h). */
109
+ maxStaleMs?: number;
64
110
  }
65
111
  interface IBillingPlanDefaults {
66
112
  warnAt?: number;
@@ -92,6 +138,7 @@ interface IBillingConfig {
92
138
  backend?: RateLimitBackendConfig;
93
139
  };
94
140
  notifications?: IBillingNotificationsConfig;
141
+ pricing?: IBillingPricingConfig;
95
142
  }
96
143
  declare const MESSAGE_KEYS: {
97
144
  readonly limitWarning: "billing.limit-warning";
@@ -105,4 +152,4 @@ declare function requirePlan(plans: string | string[], store: IStoreAdapter, ctx
105
152
 
106
153
  declare function withBilling(store: IStoreAdapter, config: IBillingConfig, backend: ICounterBackend): Middleware;
107
154
 
108
- export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IBillingEvent as b, type ICounterBackend as c, type IBillingPlan as d, type IBillingNotificationsConfig as e, type IBillingPlanDefaults as f, requirePlan as r, withBilling as w };
155
+ export { type BillingMessageKey as B, type IBillingConfig as I, MESSAGE_KEYS as M, type RateLimitBackendConfig as R, type IBillingProvider as a, type IResolvedPrice as b, type IBillingEvent as c, type ICounterBackend as d, type IBillingPlan as e, type IBillingNotificationsConfig as f, type IBillingPlanDefaults as g, type IBillingPlanPrice as h, type IBillingPricingConfig as i, requirePlan as r, withBilling as w };
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ BILLING_INTERVAL: () => BILLING_INTERVAL,
23
24
  BillingModule: () => BillingModule,
24
25
  DBCounterBackend: () => DBCounterBackend,
25
26
  MESSAGE_KEYS: () => MESSAGE_KEYS,
@@ -86,6 +87,54 @@ var recordUsageSchema = import_zod.z.object({
86
87
  quantity: import_zod.z.number().min(0).optional()
87
88
  });
88
89
 
90
+ // src/services/price-cache.ts
91
+ var PriceCache = class {
92
+ ttl;
93
+ grace;
94
+ maxStale;
95
+ byId = /* @__PURE__ */ new Map();
96
+ inflight = /* @__PURE__ */ new Map();
97
+ constructor(opts = {}) {
98
+ this.ttl = opts.ttlMs ?? 3e5;
99
+ this.grace = opts.graceMs ?? 36e5;
100
+ this.maxStale = opts.maxStaleMs ?? 864e5;
101
+ }
102
+ async byPriceId(priceId, provider) {
103
+ const now = Date.now();
104
+ const hit = this.byId.get(priceId);
105
+ if (hit && now - hit.at < this.ttl) return { price: hit.price, stale: false };
106
+ let fresh;
107
+ try {
108
+ fresh = await this.single(priceId, () => provider.resolvePriceById(priceId));
109
+ } catch {
110
+ if (hit && now - hit.at < this.maxStale) return { price: hit.price, stale: true };
111
+ return { price: null, stale: true };
112
+ }
113
+ if (fresh) {
114
+ this.byId.set(priceId, { price: fresh, at: now });
115
+ return { price: fresh, stale: false };
116
+ }
117
+ if (hit && now - hit.at < this.grace) return { price: hit.price, stale: true };
118
+ return { price: null, stale: true };
119
+ }
120
+ invalidate(priceId) {
121
+ if (priceId) this.byId.delete(priceId);
122
+ else this.byId.clear();
123
+ }
124
+ /** Warm the cache with prices already resolved elsewhere (e.g. boot guard). */
125
+ prime(prices) {
126
+ const now = Date.now();
127
+ for (const p of prices) this.byId.set(p.priceId, { price: p, at: now });
128
+ }
129
+ single(key, run) {
130
+ const existing = this.inflight.get(key);
131
+ if (existing) return existing;
132
+ const p = run().finally(() => this.inflight.delete(key));
133
+ this.inflight.set(key, p);
134
+ return p;
135
+ }
136
+ };
137
+
89
138
  // src/controllers/plan.controller.ts
90
139
  var import_core = require("@fonderie/core");
91
140
 
@@ -96,6 +145,18 @@ function getPlans(config) {
96
145
  function getPlanByName(name, config) {
97
146
  return config.plans.find((p) => p.name.toLowerCase() === name.toLowerCase()) ?? null;
98
147
  }
148
+ function resolvePlanNameByPrice(price, plans) {
149
+ const find = (pred) => plans.find((pl) => pred(pl.monthly) || pred(pl.yearly))?.name ?? null;
150
+ if (price.lookupKey) {
151
+ const m = find((p) => p?.lookupKey === price.lookupKey);
152
+ if (m) return m;
153
+ }
154
+ if (price.priceId) {
155
+ const m = find((p) => p?.priceId === price.priceId);
156
+ if (m) return m;
157
+ }
158
+ return null;
159
+ }
99
160
  async function syncPlansToDB(config, store) {
100
161
  const plans = config.plans;
101
162
  if (plans.length === 0) return;
@@ -314,12 +375,41 @@ function toUsageRecordDTO(record) {
314
375
  }
315
376
 
316
377
  // src/controllers/plan.controller.ts
317
- function planController(store) {
378
+ async function hydratePricing(dto, plan, config, cache) {
379
+ try {
380
+ let stale = false;
381
+ const resolve = async (priceId) => {
382
+ if (!priceId) return null;
383
+ const r = await cache.byPriceId(priceId, config.provider);
384
+ if (r.stale) stale = true;
385
+ return r.price;
386
+ };
387
+ const [m, y] = await Promise.all([resolve(plan.monthlyPriceId), resolve(plan.yearlyPriceId)]);
388
+ if (m && y && m.currency !== y.currency) {
389
+ throw new Error(
390
+ `[billing] plan "${plan.name}": monthly/yearly currency mismatch (${m.currency} vs ${y.currency})`
391
+ );
392
+ }
393
+ if (m) dto.pricing.monthly = m.unitAmount;
394
+ if (y) dto.pricing.yearly = y.unitAmount;
395
+ const currency = m?.currency ?? y?.currency;
396
+ if (currency) dto.pricing.currency = currency.toUpperCase();
397
+ if (stale) dto.pricingStale = true;
398
+ } catch (err) {
399
+ console.error(`[billing] pricing hydration failed for "${plan.name}":`, err.message);
400
+ dto.pricingStale = true;
401
+ }
402
+ }
403
+ function planController(store, config, cache) {
318
404
  const plans = new PlanModel(store);
405
+ const hydrate = config.pricing?.hydration === true;
319
406
  return {
320
407
  async list(_ctx) {
321
408
  const list = await plans.list();
322
409
  const dtos = list.map(toPlanDTO);
410
+ if (hydrate) {
411
+ await Promise.all(dtos.map((dto, i) => hydratePricing(dto, list[i], config, cache)));
412
+ }
323
413
  return (0, import_core.setApiResponse)(import_core.HTTP.OK, "PLAN_LIST", `Retrieved ${list.length} workspace plans`, {
324
414
  plans: dtos
325
415
  });
@@ -330,8 +420,10 @@ function planController(store) {
330
420
  if (!id) return (0, import_core.setApiResponse)(import_core.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Plan ID required");
331
421
  const plan = await plans.findById(id);
332
422
  if (!plan) return (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "NOT_FOUND", "Plan not found");
423
+ const dto = toPlanDTO(plan);
424
+ if (hydrate) await hydratePricing(dto, plan, config, cache);
333
425
  return (0, import_core.setApiResponse)(import_core.HTTP.OK, "PLAN_FETCHED", "Plan retrieved successfully.", {
334
- plan: toPlanDTO(plan)
426
+ plan: dto
335
427
  });
336
428
  },
337
429
  async create(ctx) {
@@ -588,6 +680,43 @@ function checkoutController(store, config) {
588
680
  `Plan ${planName} does not support ${interval} billing`
589
681
  );
590
682
  }
683
+ const current = await subscriptions.get(subscriber.type, subscriber.id);
684
+ const ACTIVE = ["active", "trialing", "past_due"];
685
+ if (current && ACTIVE.includes(current.status)) {
686
+ const currentTier = plans.findByNameInConfig(current.plan, config)?.tier ?? -1;
687
+ const targetTier = plan.tier ?? -1;
688
+ if (targetTier <= currentTier) {
689
+ return (0, import_core3.setApiResponse)(
690
+ import_core3.HTTP.UNPROCESSABLE,
691
+ "DOWNGRADE_NOT_ALLOWED",
692
+ `Cannot switch from ${current.plan} to a same-or-lower tier (${planName}) mid-cycle. Upgrades only.`
693
+ );
694
+ }
695
+ if (current.providerSubscriptionId) {
696
+ const res = await config.provider.updateSubscription({
697
+ subscriptionId: current.providerSubscriptionId,
698
+ priceId: pricing.priceId
699
+ });
700
+ const upsert = {
701
+ subscriberType: subscriber.type,
702
+ subscriberId: subscriber.id,
703
+ plan: planName,
704
+ interval,
705
+ status: res.status,
706
+ providerSubscriptionId: current.providerSubscriptionId
707
+ };
708
+ if (current.providerCustomerId) upsert.providerCustomerId = current.providerCustomerId;
709
+ if (res.currentPeriodStart) upsert.currentPeriodStart = res.currentPeriodStart;
710
+ if (res.currentPeriodEnd) upsert.currentPeriodEnd = res.currentPeriodEnd;
711
+ await subscriptions.upsert(upsert);
712
+ return (0, import_core3.setApiResponse)(
713
+ import_core3.HTTP.OK,
714
+ "SUBSCRIPTION_UPGRADED",
715
+ "Subscription upgraded; the prorated difference was charged.",
716
+ { upgraded: true, plan: planName }
717
+ );
718
+ }
719
+ }
591
720
  const { customerId } = await config.provider.createCustomer({
592
721
  email: ctx.user.email ?? "",
593
722
  subscriberType: subscriber.type,
@@ -727,7 +856,7 @@ function usageController(store) {
727
856
 
728
857
  // src/controllers/webhook.controller.ts
729
858
  var import_core5 = require("@fonderie/core");
730
- function webhookController(store, config) {
859
+ function webhookController(store, config, priceCache) {
731
860
  const subscriptions = new SubscriptionModel(store);
732
861
  return {
733
862
  async handle(ctx) {
@@ -749,11 +878,15 @@ function webhookController(store, config) {
749
878
  } catch {
750
879
  return (0, import_core5.setApiResponse)(import_core5.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
751
880
  }
881
+ if (priceCache && (event.type.startsWith("price.") || event.type.startsWith("product."))) {
882
+ priceCache.invalidate();
883
+ }
752
884
  if (event.subscription) {
885
+ const plan = event.type === "customer.subscription.deleted" ? event.subscription.plan : resolvePlanNameByPrice(event.subscription, config.plans) ?? event.subscription.plan;
753
886
  await subscriptions.upsert({
754
887
  subscriberType: event.subscription.subscriberType,
755
888
  subscriberId: event.subscription.subscriberId,
756
- plan: event.subscription.plan,
889
+ plan,
757
890
  interval: event.subscription.interval,
758
891
  status: event.subscription.status,
759
892
  providerCustomerId: event.subscription.providerCustomerId,
@@ -771,11 +904,16 @@ function webhookController(store, config) {
771
904
 
772
905
  // src/routes.ts
773
906
  function buildBillingRoutes(store, config) {
774
- const plan = planController(store);
907
+ const priceCache = new PriceCache({
908
+ ttlMs: config.pricing?.cacheTtlMs,
909
+ graceMs: config.pricing?.transferGraceMs,
910
+ maxStaleMs: config.pricing?.maxStaleMs
911
+ });
912
+ const plan = planController(store, config, priceCache);
775
913
  const subscription = subscriptionController(store);
776
914
  const checkout = checkoutController(store, config);
777
915
  const usage = usageController(store);
778
- const webhook = webhookController(store, config);
916
+ const webhook = webhookController(store, config, priceCache);
779
917
  return [
780
918
  // Plans — public read-only
781
919
  ["GET", "/plans", plan.list],
@@ -998,6 +1136,9 @@ var BillingModule = class {
998
1136
  }
999
1137
  };
1000
1138
 
1139
+ // src/types.ts
1140
+ var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
1141
+
1001
1142
  // src/providers/stripe.ts
1002
1143
  var _client = null;
1003
1144
  async function getClient(secretKey) {
@@ -1018,6 +1159,8 @@ function normalizeSubscription(sub) {
1018
1159
  subscriberType: sub.metadata?.["subscriberType"] ?? "workspace",
1019
1160
  subscriberId: sub.metadata?.["subscriberId"] ?? "",
1020
1161
  plan: item?.price.nickname ?? "unknown",
1162
+ priceLookupKey: item?.price.lookup_key ?? null,
1163
+ priceId: item?.price.id ?? null,
1021
1164
  status: sub.status,
1022
1165
  providerCustomerId: sub.customer,
1023
1166
  providerSubscriptionId: sub.id,
@@ -1025,7 +1168,19 @@ function normalizeSubscription(sub) {
1025
1168
  currentPeriodEnd: periodEnd ? new Date(periodEnd * 1e3) : /* @__PURE__ */ new Date(),
1026
1169
  cancelAtPeriodEnd: sub.cancel_at_period_end,
1027
1170
  trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1e3) : null,
1028
- interval: item?.price.recurring?.interval === "year" ? "year" : "month"
1171
+ interval: item?.price.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH
1172
+ };
1173
+ }
1174
+ function toResolvedPrice(p) {
1175
+ return {
1176
+ priceId: p.id,
1177
+ lookupKey: p.lookup_key ?? null,
1178
+ unitAmount: p.unit_amount ?? 0,
1179
+ currency: p.currency,
1180
+ interval: p.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH,
1181
+ nickname: p.nickname ?? null,
1182
+ productId: typeof p.product === "string" ? p.product : p.product?.id ?? "",
1183
+ active: p.active ?? true
1029
1184
  };
1030
1185
  }
1031
1186
  var StripeProvider = class {
@@ -1069,6 +1224,48 @@ var StripeProvider = class {
1069
1224
  });
1070
1225
  return { url: session.url ?? "" };
1071
1226
  }
1227
+ async resolvePriceById(priceId) {
1228
+ const stripe = await this.client();
1229
+ try {
1230
+ const p = await stripe.prices.retrieve(priceId, { expand: ["product"] });
1231
+ return toResolvedPrice(p);
1232
+ } catch {
1233
+ return null;
1234
+ }
1235
+ }
1236
+ async resolvePricesByLookupKey(lookupKeys) {
1237
+ const out = /* @__PURE__ */ new Map();
1238
+ if (lookupKeys.length === 0) return out;
1239
+ const stripe = await this.client();
1240
+ const res = await stripe.prices.list({
1241
+ lookup_keys: lookupKeys,
1242
+ active: true,
1243
+ expand: ["data.product"],
1244
+ limit: 100
1245
+ });
1246
+ for (const p of res.data) {
1247
+ if (p.lookup_key) out.set(p.lookup_key, toResolvedPrice(p));
1248
+ }
1249
+ return out;
1250
+ }
1251
+ async updateSubscription(opts) {
1252
+ const stripe = await this.client();
1253
+ const sub = await stripe.subscriptions.retrieve(opts.subscriptionId);
1254
+ const itemId = sub.items.data[0]?.id;
1255
+ const updated = await stripe.subscriptions.update(opts.subscriptionId, {
1256
+ items: [{ id: itemId, price: opts.priceId }],
1257
+ proration_behavior: "always_invoice",
1258
+ payment_behavior: "error_if_incomplete"
1259
+ });
1260
+ const item = updated.items?.data?.[0];
1261
+ const cps = item?.current_period_start ?? updated.current_period_start;
1262
+ const cpe = item?.current_period_end ?? updated.current_period_end;
1263
+ return {
1264
+ status: updated.status,
1265
+ currentPeriodStart: cps ? new Date(cps * 1e3) : null,
1266
+ currentPeriodEnd: cpe ? new Date(cpe * 1e3) : null
1267
+ };
1268
+ }
1072
1269
  async createPortalSession(opts) {
1073
1270
  const stripe = await this.client();
1074
1271
  const session = await stripe.billingPortal.sessions.create({
@@ -1183,6 +1380,7 @@ function requireFeature(key) {
1183
1380
  }
1184
1381
  // Annotate the CommonJS export names for ESM import in node:
1185
1382
  0 && (module.exports = {
1383
+ BILLING_INTERVAL,
1186
1384
  BillingModule,
1187
1385
  DBCounterBackend,
1188
1386
  MESSAGE_KEYS,