@fonderie/billing 5.3.1 → 6.0.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.
package/dist/index.cjs CHANGED
@@ -21,13 +21,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  BILLING_INTERVAL: () => BILLING_INTERVAL,
24
+ BILLING_INTERVALS: () => BILLING_INTERVALS,
24
25
  BillingModule: () => BillingModule,
25
26
  DBCounterBackend: () => DBCounterBackend,
27
+ DuplicateTransactionError: () => DuplicateTransactionError,
28
+ InsufficientFundsError: () => InsufficientFundsError,
26
29
  MESSAGE_KEYS: () => MESSAGE_KEYS,
27
30
  MemoryCounterBackend: () => MemoryCounterBackend,
28
31
  StripeProvider: () => StripeProvider,
32
+ WALLET_LEDGER_TYPES: () => WALLET_LEDGER_TYPES,
29
33
  createPlan: () => createPlan,
34
+ creditWallet: () => creditWallet,
35
+ currentGrantPeriod: () => currentGrantPeriod,
36
+ debitWallet: () => debitWallet,
37
+ debitWalletForMetric: () => debitWalletForMetric,
38
+ decodeLedgerCursor: () => decodeLedgerCursor,
30
39
  deletePlan: () => deletePlan,
40
+ encodeLedgerCursor: () => encodeLedgerCursor,
41
+ ensurePeriodicGrant: () => ensurePeriodicGrant,
31
42
  getDBPlans: () => getDBPlans,
32
43
  getLimitStatus: () => getLimitStatus,
33
44
  getPlanById: () => getPlanById,
@@ -36,14 +47,23 @@ __export(index_exports, {
36
47
  getPlans: () => getPlans,
37
48
  getSubscription: () => getSubscription,
38
49
  getUsage: () => getUsage,
50
+ getWalletBalance: () => getWalletBalance,
51
+ getWalletLedger: () => getWalletLedger,
52
+ getWalletRate: () => getWalletRate,
53
+ getWalletStatus: () => getWalletStatus,
39
54
  hasFeature: () => hasFeature,
55
+ insufficientCreditsResponse: () => insufficientCreditsResponse,
56
+ isBillingInterval: () => isBillingInterval,
40
57
  recordUsage: () => recordUsage,
41
58
  requireFeature: () => requireFeature,
42
59
  requirePlan: () => requirePlan,
60
+ requireWalletBalance: () => requireWalletBalance,
61
+ resolvePlanWallet: () => resolvePlanWallet,
43
62
  schemas: () => schemas_exports,
44
63
  toPlanDTO: () => toPlanDTO,
45
64
  toSubscriptionDTO: () => toSubscriptionDTO,
46
- toUsageRecordDTO: () => toUsageRecordDTO,
65
+ toWalletDTO: () => toWalletDTO,
66
+ toWalletTransactionDTO: () => toWalletTransactionDTO,
47
67
  updatePlan: () => updatePlan,
48
68
  withBilling: () => withBilling
49
69
  });
@@ -57,10 +77,25 @@ var schemas_exports = {};
57
77
  __export(schemas_exports, {
58
78
  checkoutSchema: () => checkoutSchema,
59
79
  createPlanSchema: () => createPlanSchema,
80
+ grantWalletSchema: () => grantWalletSchema,
60
81
  recordUsageSchema: () => recordUsageSchema,
61
- updatePlanSchema: () => updatePlanSchema
82
+ updatePlanSchema: () => updatePlanSchema,
83
+ walletCheckoutSchema: () => walletCheckoutSchema
62
84
  });
63
85
  var import_zod = require("zod");
86
+
87
+ // src/types.ts
88
+ var BILLING_INTERVALS = ["month", "year"];
89
+ var BILLING_INTERVAL = {
90
+ MONTH: "month",
91
+ YEAR: "year"
92
+ };
93
+ function isBillingInterval(value) {
94
+ return BILLING_INTERVALS.includes(value);
95
+ }
96
+ var WALLET_LEDGER_TYPES = ["purchase", "grant", "usage", "refund", "adjustment"];
97
+
98
+ // src/schemas.ts
64
99
  var planFields = {
65
100
  description: import_zod.z.string().max(2e3).nullable().optional(),
66
101
  tier: import_zod.z.number().int().min(0).optional(),
@@ -80,12 +115,29 @@ var createPlanSchema = import_zod.z.object({
80
115
  var updatePlanSchema = import_zod.z.object({ name: import_zod.z.string().trim().min(1).max(200).optional(), ...planFields }).refine((o) => Object.values(o).some((v) => v !== void 0), "Provide at least one field");
81
116
  var checkoutSchema = import_zod.z.object({
82
117
  plan: import_zod.z.string().min(1, "plan is required"),
83
- interval: import_zod.z.enum(["month", "year"]).optional()
118
+ interval: import_zod.z.enum(BILLING_INTERVALS).optional()
84
119
  });
85
120
  var recordUsageSchema = import_zod.z.object({
86
121
  metric: import_zod.z.string().min(1, "metric is required").max(100),
87
122
  quantity: import_zod.z.number().min(0).optional()
88
123
  });
124
+ var walletAmount = import_zod.z.union([
125
+ import_zod.z.string().regex(/^\d{1,30}$/, "amount must be a positive integer string"),
126
+ // JSON numbers past 2^53 arrive already rounded — force the digit-string
127
+ // form for anything larger instead of silently granting a wrong amount.
128
+ import_zod.z.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
129
+ ]).transform((v) => BigInt(v)).refine((v) => v > 0n, "amount must be positive");
130
+ var walletCheckoutSchema = import_zod.z.object({
131
+ packId: import_zod.z.string().trim().min(1, "packId is required").max(100)
132
+ });
133
+ var grantWalletSchema = import_zod.z.object({
134
+ subscriberType: import_zod.z.enum(["user", "workspace"]),
135
+ subscriberId: import_zod.z.string().uuid("subscriberId must be a UUID"),
136
+ amount: walletAmount,
137
+ currency: import_zod.z.string().trim().regex(/^[A-Za-z]{3,20}$/, "currency must be a 3-20 letter code").transform((s) => s.toUpperCase()).optional(),
138
+ description: import_zod.z.string().max(500).optional(),
139
+ idempotencyKey: import_zod.z.string().min(1, "idempotencyKey is required").max(255)
140
+ });
89
141
 
90
142
  // src/services/price-cache.ts
91
143
  var PriceCache = class {
@@ -138,7 +190,62 @@ var PriceCache = class {
138
190
  // src/controllers/plan.controller.ts
139
191
  var import_core = require("@fonderie/core");
140
192
 
193
+ // src/utils.ts
194
+ function toSafeNumber(amount) {
195
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER) || amount < -BigInt(Number.MAX_SAFE_INTEGER)) {
196
+ throw new Error(`[billing] amount ${amount} exceeds Number.MAX_SAFE_INTEGER`);
197
+ }
198
+ return Number(amount);
199
+ }
200
+ function normalizeCurrency(currency) {
201
+ return currency.trim().toUpperCase();
202
+ }
203
+ function parseWindowMs(window) {
204
+ const n = parseInt(window, 10);
205
+ const unit = window.slice(String(n).length);
206
+ switch (unit) {
207
+ case "h":
208
+ return n * 36e5;
209
+ case "d":
210
+ return n * 864e5;
211
+ case "m":
212
+ return n * 6e4;
213
+ default:
214
+ throw new Error(`Unknown window unit: '${unit}' in '${window}'`);
215
+ }
216
+ }
217
+ function resolveSubscriber(ctx) {
218
+ const wsFromHeader = ctx.request.headers.get("x-workspace-id");
219
+ if (wsFromHeader) {
220
+ return {
221
+ type: "workspace",
222
+ id: wsFromHeader
223
+ };
224
+ }
225
+ if (ctx.workspace?.id) {
226
+ return {
227
+ type: "workspace",
228
+ id: ctx.workspace.id
229
+ };
230
+ }
231
+ if (ctx.user?.id) {
232
+ return {
233
+ type: "user",
234
+ id: ctx.user.id
235
+ };
236
+ }
237
+ return null;
238
+ }
239
+
141
240
  // src/services/plans.ts
241
+ var planAmount = (v) => v == null ? null : toSafeNumber(BigInt(v));
242
+ function mapPlanRow(row) {
243
+ return {
244
+ ...row,
245
+ monthlyAmount: planAmount(row.monthlyAmount),
246
+ yearlyAmount: planAmount(row.yearlyAmount)
247
+ };
248
+ }
142
249
  function getPlans(config) {
143
250
  return config.plans;
144
251
  }
@@ -157,30 +264,33 @@ function resolvePlanNameByPrice(price, plans) {
157
264
  }
158
265
  return null;
159
266
  }
267
+ var walletToJson = (wallet) => wallet == null ? null : JSON.stringify(wallet, (_key, value) => typeof value === "bigint" ? value.toString() : value);
160
268
  async function syncPlansToDB(config, store) {
161
269
  const plans = config.plans;
162
270
  if (plans.length === 0) return;
163
271
  const values = plans.map((_, i) => {
164
- const b = i * 9;
165
- return `($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8}, $${b + 9}::jsonb)`;
272
+ const b = i * 10;
273
+ return `($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8}, $${b + 9}::jsonb, $${b + 10}::jsonb)`;
166
274
  });
167
275
  const params = plans.flatMap((plan) => [
168
276
  plan.name,
169
277
  plan.trialDays ?? 0,
170
- plan.monthly?.amount ?? null,
278
+ // bigint params go over the wire as strings; pg casts into the column type.
279
+ plan.monthly?.amount?.toString() ?? null,
171
280
  plan.monthly?.priceId ?? null,
172
- plan.yearly?.amount ?? null,
281
+ plan.yearly?.amount?.toString() ?? null,
173
282
  plan.yearly?.priceId ?? null,
174
283
  plan.description ?? null,
175
284
  plan.tier ?? 0,
176
- JSON.stringify(plan.metadata ?? {})
285
+ JSON.stringify(plan.metadata ?? {}),
286
+ walletToJson(plan.wallet)
177
287
  ]);
178
288
  await store.query(
179
289
  `INSERT INTO fonderie_plans
180
290
  (name, trial_days,
181
291
  monthly_amount, monthly_price_id,
182
292
  yearly_amount, yearly_price_id,
183
- description, tier, metadata)
293
+ description, tier, metadata, wallet)
184
294
  VALUES ${values.join(", ")}
185
295
  ON CONFLICT (name) DO UPDATE SET
186
296
  trial_days = EXCLUDED.trial_days,
@@ -190,7 +300,8 @@ async function syncPlansToDB(config, store) {
190
300
  yearly_price_id = EXCLUDED.yearly_price_id,
191
301
  description = EXCLUDED.description,
192
302
  tier = EXCLUDED.tier,
193
- metadata = EXCLUDED.metadata`,
303
+ metadata = EXCLUDED.metadata,
304
+ wallet = EXCLUDED.wallet`,
194
305
  params
195
306
  );
196
307
  }
@@ -210,13 +321,14 @@ var SELECT_PLAN = `
210
321
  metadata
211
322
  FROM fonderie_plans`;
212
323
  async function getDBPlans(store) {
213
- return store.query(
324
+ const rows = await store.query(
214
325
  `${SELECT_PLAN} WHERE active = true ORDER BY tier ASC, monthly_amount ASC NULLS LAST`
215
326
  );
327
+ return rows.map(mapPlanRow);
216
328
  }
217
329
  async function getPlanById(id, store) {
218
330
  const [row] = await store.query(`${SELECT_PLAN} WHERE id = $1`, [id]);
219
- return row ?? null;
331
+ return row ? mapPlanRow(row) : null;
220
332
  }
221
333
  async function createPlan(data, store) {
222
334
  const [row] = await store.query(
@@ -247,7 +359,7 @@ async function createPlan(data, store) {
247
359
  ]
248
360
  );
249
361
  if (!row) throw new Error("Failed to create plan");
250
- return row;
362
+ return mapPlanRow(row);
251
363
  }
252
364
  async function updatePlan(id, data, store) {
253
365
  const fieldMap = {
@@ -290,7 +402,7 @@ async function updatePlan(id, data, store) {
290
402
  description, tier, features, metadata`,
291
403
  params
292
404
  );
293
- return row ?? null;
405
+ return row ? mapPlanRow(row) : null;
294
406
  }
295
407
  async function deletePlan(id, store) {
296
408
  const rows = await store.query(
@@ -348,6 +460,7 @@ function toPlanDTO(plan) {
348
460
  metadata: plan.metadata && typeof plan.metadata === "object" ? plan.metadata : {}
349
461
  };
350
462
  }
463
+ var isoOrNull = (value) => value == null ? null : new Date(value).toISOString();
351
464
  function toSubscriptionDTO(sub) {
352
465
  return {
353
466
  id: sub.id,
@@ -357,20 +470,26 @@ function toSubscriptionDTO(sub) {
357
470
  interval: sub.interval,
358
471
  status: sub.status,
359
472
  cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
360
- currentPeriodStart: sub.currentPeriodStart,
361
- currentPeriodEnd: sub.currentPeriodEnd,
362
- trialEndsAt: sub.trialEndsAt,
363
- createdAt: sub.createdAt
473
+ currentPeriodStart: isoOrNull(sub.currentPeriodStart),
474
+ currentPeriodEnd: isoOrNull(sub.currentPeriodEnd),
475
+ trialEndsAt: isoOrNull(sub.trialEndsAt),
476
+ createdAt: isoOrNull(sub.createdAt) ?? ""
364
477
  };
365
478
  }
366
- function toUsageRecordDTO(record) {
479
+ function toWalletDTO(balance, currency, precision) {
480
+ return { balance: balance.toString(), currency, precision };
481
+ }
482
+ function toWalletTransactionDTO(entry) {
367
483
  return {
368
- id: record.id,
369
- subscriberType: record.subscriberType,
370
- subscriberId: record.subscriberId,
371
- metric: record.metric,
372
- quantity: record.quantity,
373
- recordedAt: record.recordedAt
484
+ id: entry.id,
485
+ type: entry.type,
486
+ amount: entry.amount.toString(),
487
+ balanceAfter: entry.balanceAfter.toString(),
488
+ currency: entry.currency,
489
+ description: entry.description,
490
+ providerTxId: entry.providerTxId,
491
+ metadata: entry.metadata,
492
+ createdAt: entry.createdAt
374
493
  };
375
494
  }
376
495
 
@@ -390,8 +509,8 @@ async function hydratePricing(dto, plan, config, cache) {
390
509
  `[billing] plan "${plan.name}": monthly/yearly currency mismatch (${m.currency} vs ${y.currency})`
391
510
  );
392
511
  }
393
- if (m) dto.pricing.monthly = m.unitAmount;
394
- if (y) dto.pricing.yearly = y.unitAmount;
512
+ if (m) dto.pricing.monthly = toSafeNumber(m.unitAmount);
513
+ if (y) dto.pricing.yearly = toSafeNumber(y.unitAmount);
395
514
  const currency = m?.currency ?? y?.currency;
396
515
  if (currency) dto.pricing.currency = currency.toUpperCase();
397
516
  if (stale) dto.pricingStale = true;
@@ -574,44 +693,6 @@ var SubscriptionModel = class {
574
693
  }
575
694
  };
576
695
 
577
- // src/utils.ts
578
- function parseWindowMs(window) {
579
- const n = parseInt(window, 10);
580
- const unit = window.slice(String(n).length);
581
- switch (unit) {
582
- case "h":
583
- return n * 36e5;
584
- case "d":
585
- return n * 864e5;
586
- case "m":
587
- return n * 6e4;
588
- default:
589
- throw new Error(`Unknown window unit: '${unit}' in '${window}'`);
590
- }
591
- }
592
- function resolveSubscriber(ctx) {
593
- const wsFromHeader = ctx.request.headers.get("x-workspace-id");
594
- if (wsFromHeader) {
595
- return {
596
- type: "workspace",
597
- id: wsFromHeader
598
- };
599
- }
600
- if (ctx.workspace?.id) {
601
- return {
602
- type: "workspace",
603
- id: ctx.workspace.id
604
- };
605
- }
606
- if (ctx.user?.id) {
607
- return {
608
- type: "user",
609
- id: ctx.user.id
610
- };
611
- }
612
- return null;
613
- }
614
-
615
696
  // src/controllers/subscription.controller.ts
616
697
  function subscriptionController(store) {
617
698
  const subscriptions = new SubscriptionModel(store);
@@ -642,6 +723,18 @@ function subscriptionController(store) {
642
723
 
643
724
  // src/controllers/checkout.controller.ts
644
725
  var import_core3 = require("@fonderie/core");
726
+ function planPriceFor(plan, interval) {
727
+ switch (interval) {
728
+ case BILLING_INTERVAL.MONTH:
729
+ return plan.monthly;
730
+ case BILLING_INTERVAL.YEAR:
731
+ return plan.yearly;
732
+ default: {
733
+ const unhandled = interval;
734
+ throw new Error(`[billing] unhandled billing interval: ${unhandled}`);
735
+ }
736
+ }
737
+ }
645
738
  function checkoutController(store, config) {
646
739
  const plans = new PlanModel(store);
647
740
  const subscriptions = new SubscriptionModel(store);
@@ -649,16 +742,16 @@ function checkoutController(store, config) {
649
742
  async createSession(ctx) {
650
743
  const body = ctx.meta["body"];
651
744
  const planName = body?.["plan"];
652
- const interval = body?.["interval"] ?? "month";
745
+ const interval = body?.["interval"] ?? BILLING_INTERVAL.MONTH;
653
746
  const subscriber = resolveSubscriber(ctx);
654
747
  if (typeof planName !== "string") {
655
748
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "plan is required");
656
749
  }
657
- if (interval !== "month" && interval !== "year") {
750
+ if (!isBillingInterval(interval)) {
658
751
  return (0, import_core3.setApiResponse)(
659
752
  import_core3.HTTP.UNPROCESSABLE,
660
753
  "INVALID_PARAMETER",
661
- "interval must be month or year"
754
+ `interval must be one of: ${BILLING_INTERVALS.join(", ")}`
662
755
  );
663
756
  }
664
757
  if (!subscriber) {
@@ -672,7 +765,7 @@ function checkoutController(store, config) {
672
765
  if (!plan) {
673
766
  return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", `Unknown plan: ${planName}`);
674
767
  }
675
- const pricing = interval === "year" ? plan.yearly : plan.monthly;
768
+ const pricing = planPriceFor(plan, interval);
676
769
  if (!pricing?.priceId) {
677
770
  return (0, import_core3.setApiResponse)(
678
771
  import_core3.HTTP.UNPROCESSABLE,
@@ -848,36 +941,701 @@ function usageController(store) {
848
941
  return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "USAGE_FETCHED", "Usage retrieved successfully.", {
849
942
  metric,
850
943
  total,
851
- since
944
+ // Explicit ISO — the client's IUsageResult.since promises a string.
945
+ since: since.toISOString()
852
946
  });
853
947
  }
854
948
  };
855
949
  }
856
950
 
857
- // src/controllers/webhook.controller.ts
951
+ // src/controllers/wallet.controller.ts
952
+ var import_core6 = require("@fonderie/core");
953
+
954
+ // src/errors.ts
955
+ var InsufficientFundsError = class extends Error {
956
+ constructor(available, required, currency) {
957
+ super(
958
+ `[billing:wallet] insufficient funds: available ${available}, required ${required} ${currency}`
959
+ );
960
+ this.available = available;
961
+ this.required = required;
962
+ this.currency = currency;
963
+ this.name = "InsufficientFundsError";
964
+ }
965
+ available;
966
+ required;
967
+ currency;
968
+ };
969
+ var DuplicateTransactionError = class extends Error {
970
+ constructor(idempotencyKey) {
971
+ super(
972
+ `[billing:wallet] idempotency key '${idempotencyKey}' was already used for a different subscriber or currency`
973
+ );
974
+ this.idempotencyKey = idempotencyKey;
975
+ this.name = "DuplicateTransactionError";
976
+ }
977
+ idempotencyKey;
978
+ };
979
+
980
+ // src/services/wallet.ts
981
+ var UNIQUE_VIOLATION = "23505";
982
+ function isIdempotencyConflict(err) {
983
+ const e = err;
984
+ if (e?.code !== UNIQUE_VIOLATION) return false;
985
+ if (typeof e.constraint === "string") return e.constraint.includes("idempotency_key");
986
+ return typeof e.message === "string" && e.message.includes("idempotency_key");
987
+ }
988
+ async function findByIdempotencyKey(sub, idempotencyKey, store) {
989
+ const [row] = await store.query(
990
+ `SELECT
991
+ subscriber_type AS "subscriberType",
992
+ subscriber_id AS "subscriberId",
993
+ currency
994
+ FROM fonderie_wallet_ledger
995
+ WHERE idempotency_key = $1`,
996
+ [idempotencyKey]
997
+ );
998
+ if (!row) return null;
999
+ if (row.subscriberType !== sub.subscriberType || row.subscriberId !== sub.subscriberId || row.currency !== sub.currency) {
1000
+ throw new DuplicateTransactionError(idempotencyKey);
1001
+ }
1002
+ return row;
1003
+ }
1004
+ async function readBalance(sub, store) {
1005
+ const [row] = await store.query(
1006
+ `SELECT amount FROM fonderie_wallet_balances
1007
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3`,
1008
+ [sub.subscriberType, sub.subscriberId, sub.currency]
1009
+ );
1010
+ return BigInt(row?.amount ?? "0");
1011
+ }
1012
+ async function applyBalanceCredit(tx, sub, amount) {
1013
+ const [row] = await tx.query(
1014
+ `INSERT INTO fonderie_wallet_balances (subscriber_type, subscriber_id, currency, amount)
1015
+ VALUES ($1, $2, $3, $4)
1016
+ ON CONFLICT (subscriber_type, subscriber_id, currency) DO UPDATE SET
1017
+ amount = fonderie_wallet_balances.amount + EXCLUDED.amount,
1018
+ version = fonderie_wallet_balances.version + 1,
1019
+ updated_at = now()
1020
+ RETURNING amount`,
1021
+ [sub.subscriberType, sub.subscriberId, sub.currency, amount.toString()]
1022
+ );
1023
+ return BigInt(row?.amount ?? "0");
1024
+ }
1025
+ async function insertLedgerRow(tx, sub, opts) {
1026
+ await tx.query(
1027
+ `INSERT INTO fonderie_wallet_ledger
1028
+ (subscriber_type, subscriber_id, currency, type, amount, balance_after,
1029
+ description, idempotency_key, metadata, provider_tx_id)
1030
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
1031
+ [
1032
+ sub.subscriberType,
1033
+ sub.subscriberId,
1034
+ sub.currency,
1035
+ opts.type,
1036
+ opts.amount.toString(),
1037
+ opts.balanceAfter.toString(),
1038
+ opts.description,
1039
+ opts.idempotencyKey,
1040
+ JSON.stringify(opts.metadata),
1041
+ opts.providerTxId
1042
+ ]
1043
+ );
1044
+ }
1045
+ async function creditWallet(opts, store) {
1046
+ if (opts.amount < 0n) throw new Error("[billing:wallet] credit amount must be positive");
1047
+ if (!opts.idempotencyKey) throw new Error("[billing:wallet] idempotencyKey is required");
1048
+ if (opts.amount === 0n) {
1049
+ return { balance: await readBalance(opts, store), duplicate: false };
1050
+ }
1051
+ try {
1052
+ return await store.transaction(async (tx) => {
1053
+ const existing = await findByIdempotencyKey(opts, opts.idempotencyKey, tx);
1054
+ if (existing) return { balance: await readBalance(opts, tx), duplicate: true };
1055
+ const balance = await applyBalanceCredit(tx, opts, opts.amount);
1056
+ await insertLedgerRow(tx, opts, {
1057
+ type: opts.type ?? "adjustment",
1058
+ amount: opts.amount,
1059
+ balanceAfter: balance,
1060
+ idempotencyKey: opts.idempotencyKey,
1061
+ description: opts.description ?? null,
1062
+ metadata: opts.metadata ?? {},
1063
+ providerTxId: opts.providerTxId ?? null
1064
+ });
1065
+ return { balance, duplicate: false };
1066
+ });
1067
+ } catch (err) {
1068
+ if (isIdempotencyConflict(err)) {
1069
+ return { balance: await readBalance(opts, store), duplicate: true };
1070
+ }
1071
+ throw err;
1072
+ }
1073
+ }
1074
+ async function debitWallet(opts, store) {
1075
+ if (opts.amount < 0n) throw new Error("[billing:wallet] debit amount must be positive");
1076
+ if (!opts.idempotencyKey) throw new Error("[billing:wallet] idempotencyKey is required");
1077
+ if (opts.amount === 0n) {
1078
+ return { balance: await readBalance(opts, store), duplicate: false };
1079
+ }
1080
+ const floor = -(opts.overdraftLimit ?? 0n);
1081
+ try {
1082
+ return await store.transaction(async (tx) => {
1083
+ const existing = await findByIdempotencyKey(opts, opts.idempotencyKey, tx);
1084
+ if (existing) return { balance: await readBalance(opts, tx), duplicate: true };
1085
+ await tx.query(
1086
+ `INSERT INTO fonderie_wallet_balances (subscriber_type, subscriber_id, currency, amount)
1087
+ VALUES ($1, $2, $3, 0)
1088
+ ON CONFLICT (subscriber_type, subscriber_id, currency) DO NOTHING`,
1089
+ [opts.subscriberType, opts.subscriberId, opts.currency]
1090
+ );
1091
+ const [locked] = await tx.query(
1092
+ `SELECT amount FROM fonderie_wallet_balances
1093
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3
1094
+ FOR UPDATE`,
1095
+ [opts.subscriberType, opts.subscriberId, opts.currency]
1096
+ );
1097
+ const current = BigInt(locked?.amount ?? "0");
1098
+ if (current - opts.amount < floor) {
1099
+ throw new InsufficientFundsError(current, opts.amount, opts.currency);
1100
+ }
1101
+ const [updated] = await tx.query(
1102
+ `UPDATE fonderie_wallet_balances
1103
+ SET amount = amount - $4, version = version + 1, updated_at = now()
1104
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3
1105
+ AND amount - $4 >= $5
1106
+ RETURNING amount`,
1107
+ [
1108
+ opts.subscriberType,
1109
+ opts.subscriberId,
1110
+ opts.currency,
1111
+ opts.amount.toString(),
1112
+ floor.toString()
1113
+ ]
1114
+ );
1115
+ if (!updated) {
1116
+ throw new InsufficientFundsError(current, opts.amount, opts.currency);
1117
+ }
1118
+ const balance = BigInt(updated.amount);
1119
+ await insertLedgerRow(tx, opts, {
1120
+ type: opts.type ?? "usage",
1121
+ amount: -opts.amount,
1122
+ balanceAfter: balance,
1123
+ idempotencyKey: opts.idempotencyKey,
1124
+ description: opts.description ?? null,
1125
+ metadata: opts.metadata ?? {},
1126
+ providerTxId: null
1127
+ });
1128
+ return { balance, duplicate: false };
1129
+ });
1130
+ } catch (err) {
1131
+ if (isIdempotencyConflict(err)) {
1132
+ return { balance: await readBalance(opts, store), duplicate: true };
1133
+ }
1134
+ throw err;
1135
+ }
1136
+ }
1137
+ async function getWalletBalance(sub, store) {
1138
+ const [row] = await store.query(
1139
+ `SELECT amount, version, updated_at AS "updatedAt"
1140
+ FROM fonderie_wallet_balances
1141
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3`,
1142
+ [sub.subscriberType, sub.subscriberId, sub.currency]
1143
+ );
1144
+ if (!row) return { balance: 0n, version: 0, updatedAt: null };
1145
+ return {
1146
+ balance: BigInt(row.amount),
1147
+ version: Number(row.version),
1148
+ updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
1149
+ };
1150
+ }
1151
+ function encodeLedgerCursor(createdAt, id) {
1152
+ return Buffer.from(JSON.stringify([createdAt, id])).toString("base64url");
1153
+ }
1154
+ var CURSOR_TS_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}(:?\d{2})?)?$/;
1155
+ var CURSOR_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
1156
+ function decodeLedgerCursor(cursor) {
1157
+ if (cursor.length > 256) return null;
1158
+ try {
1159
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
1160
+ if (!Array.isArray(parsed) || typeof parsed[0] !== "string" || typeof parsed[1] !== "string") {
1161
+ return null;
1162
+ }
1163
+ if (!CURSOR_TS_RE.test(parsed[0]) || !CURSOR_ID_RE.test(parsed[1])) return null;
1164
+ return { createdAt: parsed[0], id: parsed[1] };
1165
+ } catch {
1166
+ return null;
1167
+ }
1168
+ }
1169
+ async function getWalletLedger(opts, store) {
1170
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
1171
+ const params = [opts.subscriberType, opts.subscriberId, opts.currency];
1172
+ let cursorClause = "";
1173
+ if (opts.cursor) {
1174
+ params.push(opts.cursor.createdAt, opts.cursor.id);
1175
+ cursorClause = `AND (created_at, id) < ($4::timestamptz, $5::uuid)`;
1176
+ }
1177
+ params.push(limit + 1);
1178
+ const rows = await store.query(
1179
+ `SELECT
1180
+ id,
1181
+ subscriber_type AS "subscriberType",
1182
+ subscriber_id AS "subscriberId",
1183
+ currency,
1184
+ type,
1185
+ amount,
1186
+ balance_after AS "balanceAfter",
1187
+ description,
1188
+ idempotency_key AS "idempotencyKey",
1189
+ metadata,
1190
+ provider_tx_id AS "providerTxId",
1191
+ created_at AS "createdAt",
1192
+ created_at::text AS "createdAtRaw"
1193
+ FROM fonderie_wallet_ledger
1194
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3
1195
+ ${cursorClause}
1196
+ ORDER BY created_at DESC, id DESC
1197
+ LIMIT $${params.length}`,
1198
+ params
1199
+ );
1200
+ const page = rows.slice(0, limit);
1201
+ const entries = page.map((r) => ({
1202
+ id: r.id,
1203
+ subscriberType: r.subscriberType,
1204
+ subscriberId: r.subscriberId,
1205
+ currency: r.currency,
1206
+ type: r.type,
1207
+ amount: BigInt(r.amount),
1208
+ balanceAfter: BigInt(r.balanceAfter),
1209
+ description: r.description,
1210
+ idempotencyKey: r.idempotencyKey,
1211
+ metadata: r.metadata ?? {},
1212
+ providerTxId: r.providerTxId,
1213
+ createdAt: new Date(r.createdAt).toISOString()
1214
+ }));
1215
+ const lastRow = page[page.length - 1];
1216
+ const nextCursor = rows.length > limit && lastRow ? encodeLedgerCursor(lastRow.createdAtRaw, lastRow.id) : null;
1217
+ return { entries, nextCursor };
1218
+ }
1219
+ function resolvePlanWallet(plan, config) {
1220
+ if (!config.wallet || !plan.wallet) return null;
1221
+ return {
1222
+ currency: normalizeCurrency(plan.wallet.currency ?? config.wallet.currency ?? "USD"),
1223
+ precision: plan.wallet.precision ?? config.wallet.precision ?? 2,
1224
+ overdraftLimit: plan.wallet.overdraftLimit ?? 0n,
1225
+ grantAmount: plan.wallet.grantAmount ?? null,
1226
+ grantPeriod: plan.wallet.grantPeriod ?? "month",
1227
+ rates: plan.wallet.rates ?? {}
1228
+ };
1229
+ }
1230
+ function currentGrantPeriod(period, now = /* @__PURE__ */ new Date()) {
1231
+ const y = now.getUTCFullYear();
1232
+ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
1233
+ const d = String(now.getUTCDate()).padStart(2, "0");
1234
+ if (period === "month") return `${y}-${m}`;
1235
+ if (period === "day") return `${y}-${m}-${d}`;
1236
+ const thursday = new Date(Date.UTC(y, now.getUTCMonth(), now.getUTCDate()));
1237
+ thursday.setUTCDate(thursday.getUTCDate() + 4 - (thursday.getUTCDay() || 7));
1238
+ const isoYear = thursday.getUTCFullYear();
1239
+ const jan4 = new Date(Date.UTC(isoYear, 0, 4));
1240
+ jan4.setUTCDate(jan4.getUTCDate() + 4 - (jan4.getUTCDay() || 7));
1241
+ const week = 1 + Math.round((thursday.getTime() - jan4.getTime()) / (7 * 864e5));
1242
+ return `${isoYear}-W${String(week).padStart(2, "0")}`;
1243
+ }
1244
+ async function ensurePeriodicGrant(opts, store) {
1245
+ if (opts.amount <= 0n) return { granted: false, balance: null };
1246
+ const [seen] = await store.query(
1247
+ `SELECT period FROM fonderie_wallet_grants
1248
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3 AND period = $4`,
1249
+ [opts.subscriberType, opts.subscriberId, opts.currency, opts.period]
1250
+ );
1251
+ if (seen) return { granted: false, balance: null };
1252
+ try {
1253
+ return await store.transaction(async (tx) => {
1254
+ const [marked] = await tx.query(
1255
+ `INSERT INTO fonderie_wallet_grants (subscriber_type, subscriber_id, currency, period, amount)
1256
+ VALUES ($1, $2, $3, $4, $5)
1257
+ ON CONFLICT (subscriber_type, subscriber_id, currency, period) DO NOTHING
1258
+ RETURNING period`,
1259
+ [
1260
+ opts.subscriberType,
1261
+ opts.subscriberId,
1262
+ opts.currency,
1263
+ opts.period,
1264
+ opts.amount.toString()
1265
+ ]
1266
+ );
1267
+ if (!marked) return { granted: false, balance: null };
1268
+ const balance = await applyBalanceCredit(tx, opts, opts.amount);
1269
+ await insertLedgerRow(tx, opts, {
1270
+ type: "grant",
1271
+ amount: opts.amount,
1272
+ balanceAfter: balance,
1273
+ idempotencyKey: `grant:${opts.subscriberType}:${opts.subscriberId}:${opts.currency}:${opts.period}`,
1274
+ description: opts.description ?? `Periodic grant ${opts.period}`,
1275
+ metadata: { period: opts.period },
1276
+ providerTxId: null
1277
+ });
1278
+ return { granted: true, balance };
1279
+ });
1280
+ } catch (err) {
1281
+ if (isIdempotencyConflict(err)) return { granted: false, balance: null };
1282
+ throw err;
1283
+ }
1284
+ }
1285
+
1286
+ // src/models/wallet.model.ts
1287
+ var WalletModel = class {
1288
+ constructor(store) {
1289
+ this.store = store;
1290
+ }
1291
+ store;
1292
+ credit(opts) {
1293
+ return creditWallet(opts, this.store);
1294
+ }
1295
+ debit(opts) {
1296
+ return debitWallet(opts, this.store);
1297
+ }
1298
+ balance(sub) {
1299
+ return getWalletBalance(sub, this.store);
1300
+ }
1301
+ ledger(opts) {
1302
+ return getWalletLedger(opts, this.store);
1303
+ }
1304
+ ensureGrant(opts) {
1305
+ return ensurePeriodicGrant(opts, this.store);
1306
+ }
1307
+ };
1308
+
1309
+ // src/services/credit-packs.ts
1310
+ function findCreditPack(packId, config) {
1311
+ const pack = config.wallet?.creditPacks?.find((p) => p.id === packId);
1312
+ if (!pack || pack.active === false) return null;
1313
+ return pack;
1314
+ }
1315
+ async function syncCreditPacksToDB(config, store) {
1316
+ const packs = config.wallet?.creditPacks ?? [];
1317
+ if (packs.length === 0) return;
1318
+ const defaultCurrency = normalizeCurrency(config.wallet?.currency ?? "USD");
1319
+ const values = packs.map((_, i) => {
1320
+ const b = i * 8;
1321
+ return `($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8}::jsonb)`;
1322
+ });
1323
+ const params = packs.flatMap((pack) => [
1324
+ pack.id,
1325
+ pack.name,
1326
+ normalizeCurrency(pack.currency ?? defaultCurrency),
1327
+ pack.credits.toString(),
1328
+ pack.priceAmount.toString(),
1329
+ pack.priceId ?? null,
1330
+ pack.active !== false,
1331
+ JSON.stringify(pack.metadata ?? {})
1332
+ ]);
1333
+ await store.query(
1334
+ `INSERT INTO fonderie_credit_packs
1335
+ (id, name, currency, credits, price_amount, price_id, active, metadata)
1336
+ VALUES ${values.join(", ")}
1337
+ ON CONFLICT (id) DO UPDATE SET
1338
+ name = EXCLUDED.name,
1339
+ currency = EXCLUDED.currency,
1340
+ credits = EXCLUDED.credits,
1341
+ price_amount = EXCLUDED.price_amount,
1342
+ price_id = EXCLUDED.price_id,
1343
+ active = EXCLUDED.active,
1344
+ metadata = EXCLUDED.metadata`,
1345
+ params
1346
+ );
1347
+ }
1348
+
1349
+ // src/helpers.ts
858
1350
  var import_core5 = require("@fonderie/core");
859
- function webhookController(store, config, priceCache) {
1351
+ function getBillingContext(ctx) {
1352
+ return ctx.meta["billing"] ?? null;
1353
+ }
1354
+ function hasFeature(ctx, key) {
1355
+ const billing = getBillingContext(ctx);
1356
+ if (!billing) return true;
1357
+ const status = billing.statuses[key];
1358
+ if (!status) return true;
1359
+ if (status.type === "feature") return status.enabled;
1360
+ return true;
1361
+ }
1362
+ function getPlanLimit(ctx, key) {
1363
+ const billing = getBillingContext(ctx);
1364
+ if (!billing) return null;
1365
+ const status = billing.statuses[key];
1366
+ if (!status || status.type === "feature") return null;
1367
+ return status.limit;
1368
+ }
1369
+ function getLimitStatus(ctx, key) {
1370
+ const billing = getBillingContext(ctx);
1371
+ if (!billing) return null;
1372
+ return billing.statuses[key] ?? null;
1373
+ }
1374
+ function getWalletStatus(ctx) {
1375
+ return getBillingContext(ctx)?.wallet ?? null;
1376
+ }
1377
+ function getWalletRate(ctx, metric) {
1378
+ return getWalletStatus(ctx)?.rates[metric]?.cost ?? null;
1379
+ }
1380
+ function requireWalletBalance(metric) {
1381
+ return (ctx, next) => {
1382
+ const wallet = getWalletStatus(ctx);
1383
+ const cost = wallet?.rates[metric]?.cost;
1384
+ if (!wallet || cost === void 0 || cost === 0n) return next();
1385
+ if (wallet.balance - cost < -wallet.overdraftLimit) {
1386
+ return Promise.resolve(
1387
+ (0, import_core5.setApiResponse)(import_core5.HTTP.PAYMENT_REQUIRED, "INSUFFICIENT_CREDITS", "Insufficient credits", {
1388
+ metric,
1389
+ cost: cost.toString(),
1390
+ balance: wallet.balance.toString(),
1391
+ currency: wallet.currency
1392
+ })
1393
+ );
1394
+ }
1395
+ return next();
1396
+ };
1397
+ }
1398
+ async function debitWalletForMetric(ctx, metric, opts, store) {
1399
+ const quantity = opts.quantity ?? 1;
1400
+ if (!Number.isInteger(quantity) || quantity <= 0) {
1401
+ throw new Error("[billing:wallet] quantity must be a positive integer");
1402
+ }
1403
+ const billing = getBillingContext(ctx);
1404
+ const wallet = billing?.wallet;
1405
+ const cost = wallet?.rates[metric]?.cost;
1406
+ if (!billing || !wallet || cost === void 0 || cost === 0n) return null;
1407
+ return debitWallet(
1408
+ {
1409
+ subscriberType: billing.subscriber.type,
1410
+ subscriberId: billing.subscriber.id,
1411
+ currency: wallet.currency,
1412
+ amount: cost * BigInt(quantity),
1413
+ overdraftLimit: wallet.overdraftLimit,
1414
+ idempotencyKey: opts.idempotencyKey,
1415
+ description: opts.description ?? metric,
1416
+ metadata: { metric, quantity, ...opts.metadata ?? {} }
1417
+ },
1418
+ store
1419
+ );
1420
+ }
1421
+ function insufficientCreditsResponse(err, metric) {
1422
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.PAYMENT_REQUIRED, "INSUFFICIENT_CREDITS", "Insufficient credits", {
1423
+ ...metric ? { metric } : {},
1424
+ cost: err.required.toString(),
1425
+ balance: err.available.toString(),
1426
+ currency: err.currency
1427
+ });
1428
+ }
1429
+ function requireFeature(key) {
1430
+ return (ctx, next) => {
1431
+ if (!hasFeature(ctx, key)) {
1432
+ return Promise.resolve(
1433
+ (0, import_core5.setApiResponse)(
1434
+ import_core5.HTTP.PAYMENT_REQUIRED,
1435
+ "FEATURE_UNAVAILABLE",
1436
+ `Feature '${key}' is not available on your current plan`
1437
+ )
1438
+ );
1439
+ }
1440
+ return next();
1441
+ };
1442
+ }
1443
+
1444
+ // src/controllers/wallet.controller.ts
1445
+ function walletController(store, config) {
1446
+ const wallet = new WalletModel(store);
860
1447
  const subscriptions = new SubscriptionModel(store);
1448
+ const defaultCurrency = () => normalizeCurrency(config.wallet?.currency ?? "USD");
1449
+ const currencyOf = (ctx) => {
1450
+ const q = new URL(ctx.request.url).searchParams.get("currency");
1451
+ if (q) return normalizeCurrency(q);
1452
+ return getWalletStatus(ctx)?.currency ?? defaultCurrency();
1453
+ };
1454
+ const precisionOf = (ctx) => getWalletStatus(ctx)?.precision ?? config.wallet?.precision ?? 2;
861
1455
  return {
862
- async handle(ctx) {
863
- if (!config.webhookSecret) {
864
- return (0, import_core5.setApiResponse)(import_core5.HTTP.SERVER_ERROR, "SERVER_ERROR", "Webhook secret not configured");
1456
+ async get(ctx) {
1457
+ const subscriber = resolveSubscriber(ctx);
1458
+ if (!subscriber) {
1459
+ return (0, import_core6.setApiResponse)(
1460
+ import_core6.HTTP.BAD_REQUEST,
1461
+ "SUBSCRIBER_REQUIRED",
1462
+ "Subscriber context required"
1463
+ );
1464
+ }
1465
+ const currency = currencyOf(ctx);
1466
+ const { balance } = await wallet.balance({
1467
+ subscriberType: subscriber.type,
1468
+ subscriberId: subscriber.id,
1469
+ currency
1470
+ });
1471
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "WALLET_FETCHED", "Wallet retrieved successfully.", {
1472
+ wallet: toWalletDTO(balance, currency, precisionOf(ctx))
1473
+ });
1474
+ },
1475
+ async transactions(ctx) {
1476
+ const subscriber = resolveSubscriber(ctx);
1477
+ if (!subscriber) {
1478
+ return (0, import_core6.setApiResponse)(
1479
+ import_core6.HTTP.BAD_REQUEST,
1480
+ "SUBSCRIBER_REQUIRED",
1481
+ "Subscriber context required"
1482
+ );
1483
+ }
1484
+ const params = new URL(ctx.request.url).searchParams;
1485
+ const rawLimit = params.get("limit");
1486
+ const limit = rawLimit !== null ? Number.parseInt(rawLimit, 10) : 50;
1487
+ if (Number.isNaN(limit) || limit < 1 || limit > 100) {
1488
+ return (0, import_core6.setApiResponse)(
1489
+ import_core6.HTTP.UNPROCESSABLE,
1490
+ "INVALID_PARAMETER",
1491
+ "limit must be an integer between 1 and 100"
1492
+ );
865
1493
  }
866
- const signature = ctx.request.headers.get("stripe-signature") ?? ctx.request.headers.get("paypal-auth-algo") ?? "";
867
- if (!signature) {
868
- return (0, import_core5.setApiResponse)(import_core5.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Missing webhook signature");
1494
+ const rawCursor = params.get("cursor");
1495
+ const cursor = rawCursor !== null ? decodeLedgerCursor(rawCursor) : null;
1496
+ if (rawCursor !== null && cursor === null) {
1497
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Malformed cursor");
869
1498
  }
870
- const payload = await ctx.request.text();
871
- let event;
1499
+ const page = await wallet.ledger({
1500
+ subscriberType: subscriber.type,
1501
+ subscriberId: subscriber.id,
1502
+ currency: currencyOf(ctx),
1503
+ limit,
1504
+ ...cursor ? { cursor } : {}
1505
+ });
1506
+ return (0, import_core6.setApiResponse)(
1507
+ import_core6.HTTP.OK,
1508
+ "WALLET_TRANSACTIONS",
1509
+ `Retrieved ${page.entries.length} wallet transactions`,
1510
+ {
1511
+ transactions: page.entries.map(toWalletTransactionDTO),
1512
+ nextCursor: page.nextCursor
1513
+ }
1514
+ );
1515
+ },
1516
+ // One-time checkout for a credit pack. The pack's credits and the
1517
+ // buyer's WALLET currency are snapshotted into the session metadata at
1518
+ // creation time, so the webhook credits exactly what was bought (even
1519
+ // if config changes later) into the bucket the buyer's spend paths
1520
+ // actually read. pack.currency only prices the provider charge.
1521
+ async checkout(ctx) {
1522
+ const body = ctx.meta["body"];
1523
+ const subscriber = resolveSubscriber(ctx);
1524
+ if (!subscriber) {
1525
+ return (0, import_core6.setApiResponse)(
1526
+ import_core6.HTTP.BAD_REQUEST,
1527
+ "SUBSCRIBER_REQUIRED",
1528
+ "Subscriber context required"
1529
+ );
1530
+ }
1531
+ const pack = findCreditPack(body.packId, config);
1532
+ if (!pack) {
1533
+ return (0, import_core6.setApiResponse)(
1534
+ import_core6.HTTP.UNPROCESSABLE,
1535
+ "INVALID_PARAMETER",
1536
+ `Unknown credit pack: ${body.packId}`
1537
+ );
1538
+ }
1539
+ if (!config.provider.createPaymentCheckoutSession) {
1540
+ return (0, import_core6.setApiResponse)(
1541
+ import_core6.HTTP.NOT_IMPLEMENTED,
1542
+ "PAYMENT_NOT_SUPPORTED",
1543
+ `Provider '${config.provider.name}' does not support one-time payments`
1544
+ );
1545
+ }
1546
+ const creditCurrency = getWalletStatus(ctx)?.currency ?? defaultCurrency();
1547
+ const chargeCurrency = normalizeCurrency(pack.currency ?? creditCurrency);
1548
+ const current = await subscriptions.get(subscriber.type, subscriber.id);
1549
+ const customerId = current?.providerCustomerId ?? (await config.provider.createCustomer({
1550
+ email: ctx.user.email ?? "",
1551
+ subscriberType: subscriber.type,
1552
+ subscriberId: subscriber.id,
1553
+ userId: ctx.user.id
1554
+ })).customerId;
1555
+ const session = await config.provider.createPaymentCheckoutSession({
1556
+ customerId,
1557
+ amount: pack.priceAmount,
1558
+ currency: chargeCurrency,
1559
+ name: pack.name,
1560
+ ...pack.priceId ? { priceId: pack.priceId } : {},
1561
+ metadata: {
1562
+ subscriberType: subscriber.type,
1563
+ subscriberId: subscriber.id,
1564
+ packId: pack.id,
1565
+ credits: pack.credits.toString(),
1566
+ currency: creditCurrency
1567
+ },
1568
+ successUrl: config.successUrl,
1569
+ cancelUrl: config.cancelUrl
1570
+ });
1571
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "CHECKOUT_URL", "Checkout session created.", {
1572
+ url: session.url,
1573
+ sessionId: session.sessionId
1574
+ });
1575
+ },
1576
+ // Admin-token-guarded manual grant (support/ops). Body is validated and
1577
+ // transformed by grantWalletSchema — amount arrives as a bigint.
1578
+ async grant(ctx) {
1579
+ const body = ctx.meta["body"];
1580
+ const currency = body.currency ? normalizeCurrency(body.currency) : defaultCurrency();
872
1581
  try {
873
- event = await config.provider.constructEvent({
874
- payload,
875
- signature,
876
- secret: config.webhookSecret
1582
+ const result = await wallet.credit({
1583
+ subscriberType: body.subscriberType,
1584
+ subscriberId: body.subscriberId,
1585
+ currency,
1586
+ amount: body.amount,
1587
+ type: "grant",
1588
+ description: body.description ?? "Manual grant",
1589
+ idempotencyKey: body.idempotencyKey
877
1590
  });
878
- } catch {
879
- return (0, import_core5.setApiResponse)(import_core5.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
1591
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "WALLET_GRANTED", "Credits granted.", {
1592
+ balance: result.balance.toString(),
1593
+ currency,
1594
+ duplicate: result.duplicate
1595
+ });
1596
+ } catch (err) {
1597
+ if (err instanceof DuplicateTransactionError) {
1598
+ return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "DUPLICATE_TRANSACTION", err.message);
1599
+ }
1600
+ throw err;
880
1601
  }
1602
+ }
1603
+ };
1604
+ }
1605
+
1606
+ // src/controllers/webhook.controller.ts
1607
+ var import_core8 = require("@fonderie/core");
1608
+
1609
+ // src/controllers/webhook-shared.ts
1610
+ var import_core7 = require("@fonderie/core");
1611
+ async function readWebhookEvent(ctx, secret, provider, missingSecretMessage) {
1612
+ if (!secret) {
1613
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", missingSecretMessage);
1614
+ }
1615
+ const signature = ctx.request.headers.get("stripe-signature") ?? ctx.request.headers.get("paypal-auth-algo") ?? "";
1616
+ if (!signature) {
1617
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Missing webhook signature");
1618
+ }
1619
+ const payload = await ctx.request.text();
1620
+ try {
1621
+ return await provider.constructEvent({ payload, signature, secret });
1622
+ } catch {
1623
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
1624
+ }
1625
+ }
1626
+
1627
+ // src/controllers/webhook.controller.ts
1628
+ function webhookController(store, config, priceCache) {
1629
+ const subscriptions = new SubscriptionModel(store);
1630
+ return {
1631
+ async handle(ctx) {
1632
+ const event = await readWebhookEvent(
1633
+ ctx,
1634
+ config.webhookSecret,
1635
+ config.provider,
1636
+ "Webhook secret not configured"
1637
+ );
1638
+ if (event instanceof Response) return event;
881
1639
  if (priceCache && (event.type.startsWith("price.") || event.type.startsWith("product."))) {
882
1640
  priceCache.invalidate();
883
1641
  }
@@ -902,6 +1660,88 @@ function webhookController(store, config, priceCache) {
902
1660
  };
903
1661
  }
904
1662
 
1663
+ // src/controllers/payment-webhook.controller.ts
1664
+ var import_core9 = require("@fonderie/core");
1665
+ function paymentWebhookController(store, config) {
1666
+ const wallet = new WalletModel(store);
1667
+ return {
1668
+ async handle(ctx) {
1669
+ const event = await readWebhookEvent(
1670
+ ctx,
1671
+ config.wallet?.webhookSecret,
1672
+ config.provider,
1673
+ "Payment webhook secret not configured \u2014 set wallet.webhookSecret"
1674
+ );
1675
+ if (event instanceof Response) return event;
1676
+ const payment = event.payment;
1677
+ if (!payment) return Response.json({ received: true });
1678
+ const meta = payment.metadata;
1679
+ const packId = meta["packId"];
1680
+ if (!packId) return Response.json({ received: true });
1681
+ const subscriberType = meta["subscriberType"];
1682
+ const subscriberId = meta["subscriberId"];
1683
+ const credits = meta["credits"] ?? "";
1684
+ if (subscriberType !== "user" && subscriberType !== "workspace" || !subscriberId || !/^\d{1,30}$/.test(credits)) {
1685
+ return (0, import_core9.setApiResponse)(
1686
+ import_core9.HTTP.UNPROCESSABLE,
1687
+ "INVALID_PARAMETER",
1688
+ "Malformed wallet checkout metadata"
1689
+ );
1690
+ }
1691
+ const status = payment.paymentStatus ?? null;
1692
+ if (status !== null && status !== "paid" && status !== "no_payment_required") {
1693
+ return Response.json({ received: true, pending: true });
1694
+ }
1695
+ const currency = normalizeCurrency(meta["currency"] ?? config.wallet?.currency ?? "USD");
1696
+ try {
1697
+ const result = await wallet.credit({
1698
+ subscriberType,
1699
+ subscriberId,
1700
+ currency,
1701
+ amount: BigInt(credits),
1702
+ type: "purchase",
1703
+ idempotencyKey: `${config.provider.name}:checkout:${payment.sessionId}`,
1704
+ description: `Credit pack ${packId}`,
1705
+ metadata: {
1706
+ packId,
1707
+ amountPaid: payment.amountTotal?.toString() ?? null,
1708
+ paymentCurrency: payment.currency
1709
+ },
1710
+ ...payment.providerTxId ? { providerTxId: payment.providerTxId } : {}
1711
+ });
1712
+ return Response.json({ received: true, duplicate: result.duplicate });
1713
+ } catch (err) {
1714
+ if (err instanceof DuplicateTransactionError) {
1715
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, "DUPLICATE_TRANSACTION", err.message);
1716
+ }
1717
+ throw err;
1718
+ }
1719
+ }
1720
+ };
1721
+ }
1722
+
1723
+ // src/middlewares/admin-token.ts
1724
+ var import_node_crypto = require("crypto");
1725
+ var import_core10 = require("@fonderie/core");
1726
+ function safeTokenEqual(a, b) {
1727
+ const bufA = Buffer.from(a);
1728
+ const bufB = Buffer.from(b);
1729
+ if (bufA.length !== bufB.length) return false;
1730
+ return (0, import_node_crypto.timingSafeEqual)(bufA, bufB);
1731
+ }
1732
+ function requireAdminToken(adminToken) {
1733
+ return (ctx, next) => {
1734
+ const header = ctx.request.headers.get("authorization") ?? "";
1735
+ const token = header.startsWith("Bearer ") ? header.slice(7) : "";
1736
+ if (!token || !safeTokenEqual(token, adminToken)) {
1737
+ return Promise.resolve(
1738
+ (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token")
1739
+ );
1740
+ }
1741
+ return next();
1742
+ };
1743
+ }
1744
+
905
1745
  // src/routes.ts
906
1746
  function buildBillingRoutes(store, config) {
907
1747
  const priceCache = new PriceCache({
@@ -914,7 +1754,7 @@ function buildBillingRoutes(store, config) {
914
1754
  const checkout = checkoutController(store, config);
915
1755
  const usage = usageController(store);
916
1756
  const webhook = webhookController(store, config, priceCache);
917
- return [
1757
+ const routes = [
918
1758
  // Plans — public read-only
919
1759
  ["GET", "/plans", plan.list],
920
1760
  ["GET", "/plans/:planId", plan.get],
@@ -922,8 +1762,10 @@ function buildBillingRoutes(store, config) {
922
1762
  ["POST", "/plans", (0, import_middlewares.validate)(createPlanSchema), plan.create],
923
1763
  ["PUT", "/plans/:planId", (0, import_middlewares.validate)(updatePlanSchema), plan.update],
924
1764
  ["DELETE", "/plans/:planId", plan.delete],
925
- // Billing — subscriber resolved from X-Workspace-ID header (workspace) or session (user)
926
- // Workspace membership is verified automatically by the withBilling global middleware
1765
+ // Billing — subscriber resolved from X-Workspace-ID header (workspace) or session (user).
1766
+ // The withBilling global middleware verifies workspace membership against
1767
+ // fonderie_role_user_workspaces (403 for non-members, fail-closed) before
1768
+ // any billing surface acts on a header-derived workspace id.
927
1769
  ["GET", "/billing/subscription", import_middlewares.requireAuth, subscription.get],
928
1770
  ["POST", "/billing/checkout", import_middlewares.requireAuth, (0, import_middlewares.validate)(checkoutSchema), checkout.createSession],
929
1771
  ["POST", "/billing/portal", import_middlewares.requireAuth, checkout.createPortal],
@@ -932,10 +1774,32 @@ function buildBillingRoutes(store, config) {
932
1774
  // Webhook — signature verified inside the handler
933
1775
  ["POST", "/billing/webhook", webhook.handle]
934
1776
  ];
1777
+ if (config.wallet) {
1778
+ const wallet = walletController(store, config);
1779
+ const paymentWebhook = paymentWebhookController(store, config);
1780
+ routes.push(
1781
+ ["GET", "/billing/wallet", import_middlewares.requireAuth, wallet.get],
1782
+ ["GET", "/billing/wallet/transactions", import_middlewares.requireAuth, wallet.transactions],
1783
+ ["POST", "/billing/wallet/checkout", import_middlewares.requireAuth, (0, import_middlewares.validate)(walletCheckoutSchema), wallet.checkout],
1784
+ // Payment webhook — separate endpoint and secret from the
1785
+ // subscription webhook; signature verified inside the handler.
1786
+ ["POST", "/billing/webhook/payment", paymentWebhook.handle]
1787
+ );
1788
+ if (config.wallet.adminToken) {
1789
+ routes.push([
1790
+ "POST",
1791
+ "/billing/wallet/grant",
1792
+ requireAdminToken(config.wallet.adminToken),
1793
+ (0, import_middlewares.validate)(grantWalletSchema),
1794
+ wallet.grant
1795
+ ]);
1796
+ }
1797
+ }
1798
+ return routes;
935
1799
  }
936
1800
 
937
1801
  // src/middlewares/billing.ts
938
- var import_core6 = require("@fonderie/core");
1802
+ var import_core11 = require("@fonderie/core");
939
1803
 
940
1804
  // src/config.ts
941
1805
  var MESSAGE_KEYS = {
@@ -944,6 +1808,25 @@ var MESSAGE_KEYS = {
944
1808
  limitBlocked: "billing.limit-blocked"
945
1809
  };
946
1810
 
1811
+ // src/services/membership.ts
1812
+ async function isWorkspaceMember(userId, workspaceId, store) {
1813
+ try {
1814
+ const rows = await store.query(
1815
+ `SELECT 1 AS ok
1816
+ FROM fonderie_role_user_workspaces
1817
+ WHERE user_id = $1
1818
+ AND workspace_id = $2
1819
+ AND removed = false
1820
+ AND suspended = false
1821
+ LIMIT 1`,
1822
+ [userId, workspaceId]
1823
+ );
1824
+ return rows.length > 0;
1825
+ } catch {
1826
+ return false;
1827
+ }
1828
+ }
1829
+
947
1830
  // src/services/policy.ts
948
1831
  function buildBillingContext(opts) {
949
1832
  const { subscriber, plan, active, counters } = opts;
@@ -978,6 +1861,12 @@ function withBilling(store, config, backend) {
978
1861
  return async (ctx, next) => {
979
1862
  const subscriber = resolveSubscriber(ctx);
980
1863
  if (!subscriber) return next();
1864
+ if (subscriber.type === "workspace" && ctx.workspace?.id !== subscriber.id) {
1865
+ if (!ctx.user) return next();
1866
+ if (!await isWorkspaceMember(ctx.user.id, subscriber.id, store)) {
1867
+ return (0, import_core11.setApiResponse)(import_core11.HTTP.FORBIDDEN, "FORBIDDEN", "Not a member of this workspace");
1868
+ }
1869
+ }
981
1870
  const subscription = await getSubscription(subscriber.type, subscriber.id, store);
982
1871
  const planName = subscription?.plan ?? config.plans[0]?.name ?? "free";
983
1872
  const active = !subscription || subscription.status === "active" || subscription.status === "trialing";
@@ -992,10 +1881,40 @@ function withBilling(store, config, backend) {
992
1881
  }
993
1882
  const billingCtx = buildBillingContext({ subscriber, plan, active, counters });
994
1883
  ctx.meta["billing"] = billingCtx;
1884
+ const planWallet = resolvePlanWallet(plan, config);
1885
+ if (planWallet) {
1886
+ try {
1887
+ const sub = {
1888
+ subscriberType: subscriber.type,
1889
+ subscriberId: subscriber.id,
1890
+ currency: planWallet.currency
1891
+ };
1892
+ if (active && planWallet.grantAmount !== null && planWallet.grantAmount > 0n) {
1893
+ await ensurePeriodicGrant(
1894
+ {
1895
+ ...sub,
1896
+ amount: planWallet.grantAmount,
1897
+ period: currentGrantPeriod(planWallet.grantPeriod)
1898
+ },
1899
+ store
1900
+ );
1901
+ }
1902
+ const { balance } = await getWalletBalance(sub, store);
1903
+ billingCtx.wallet = {
1904
+ balance,
1905
+ currency: planWallet.currency,
1906
+ precision: planWallet.precision,
1907
+ overdraftLimit: planWallet.overdraftLimit,
1908
+ rates: planWallet.rates
1909
+ };
1910
+ } catch (err) {
1911
+ console.error("[billing] wallet context failed:", err.message);
1912
+ }
1913
+ }
995
1914
  for (const [key, status] of Object.entries(billingCtx.statuses)) {
996
1915
  if (status.type === "counter" && status.status === "blocked") {
997
- return (0, import_core6.setApiResponse)(
998
- import_core6.HTTP.TOO_MANY_REQUESTS,
1916
+ return (0, import_core11.setApiResponse)(
1917
+ import_core11.HTTP.TOO_MANY_REQUESTS,
999
1918
  "RATE_LIMIT_EXCEEDED",
1000
1919
  `Limit exceeded for: ${key}`,
1001
1920
  { key, limit: status.limit, used: status.used, resetsAt: status.resetsAt }
@@ -1126,7 +2045,13 @@ var BillingModule = class {
1126
2045
  name = "@fonderie/billing";
1127
2046
  deps = ["@fonderie/auth"];
1128
2047
  async install(app) {
2048
+ if (!this.config.wallet && this.config.plans.some((p) => p.wallet)) {
2049
+ console.warn(
2050
+ "[billing] plans define wallet economics but config.wallet is not set \u2014 wallet features are disabled"
2051
+ );
2052
+ }
1129
2053
  await syncPlansToDB(this.config, this.store);
2054
+ if (this.config.wallet) await syncCreditPacksToDB(this.config, this.store);
1130
2055
  const backend = createBackend(this.config.rateLimit?.backend, this.store);
1131
2056
  app.use(withBilling(this.store, this.config, backend));
1132
2057
  const routes = buildBillingRoutes(this.store, this.config);
@@ -1136,10 +2061,18 @@ var BillingModule = class {
1136
2061
  }
1137
2062
  };
1138
2063
 
1139
- // src/types.ts
1140
- var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
1141
-
1142
2064
  // src/providers/stripe.ts
2065
+ function normalizePaymentSession(session) {
2066
+ const pi = session.payment_intent;
2067
+ return {
2068
+ sessionId: session.id,
2069
+ providerTxId: typeof pi === "string" ? pi : pi?.id ?? null,
2070
+ amountTotal: session.amount_total != null ? BigInt(session.amount_total) : null,
2071
+ currency: session.currency ?? null,
2072
+ paymentStatus: session.payment_status ?? null,
2073
+ metadata: session.metadata ?? {}
2074
+ };
2075
+ }
1143
2076
  var _client = null;
1144
2077
  async function getClient(secretKey) {
1145
2078
  if (_client) return _client;
@@ -1151,6 +2084,15 @@ async function getClient(secretKey) {
1151
2084
  _client = new Stripe(secretKey, { apiVersion: "2024-11-20.acacia" });
1152
2085
  return _client;
1153
2086
  }
2087
+ function toBillingInterval(raw) {
2088
+ if (isBillingInterval(raw)) return raw;
2089
+ if (raw !== void 0) {
2090
+ console.warn(
2091
+ `[billing:stripe] unsupported price interval '${raw}' \u2014 recording as '${BILLING_INTERVAL.MONTH}'`
2092
+ );
2093
+ }
2094
+ return BILLING_INTERVAL.MONTH;
2095
+ }
1154
2096
  function normalizeSubscription(sub) {
1155
2097
  const item = sub.items.data[0];
1156
2098
  const periodStart = item?.current_period_start ?? sub.current_period_start;
@@ -1168,16 +2110,16 @@ function normalizeSubscription(sub) {
1168
2110
  currentPeriodEnd: periodEnd ? new Date(periodEnd * 1e3) : /* @__PURE__ */ new Date(),
1169
2111
  cancelAtPeriodEnd: sub.cancel_at_period_end,
1170
2112
  trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1e3) : null,
1171
- interval: item?.price.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH
2113
+ interval: toBillingInterval(item?.price.recurring?.interval)
1172
2114
  };
1173
2115
  }
1174
2116
  function toResolvedPrice(p) {
1175
2117
  return {
1176
2118
  priceId: p.id,
1177
2119
  lookupKey: p.lookup_key ?? null,
1178
- unitAmount: p.unit_amount ?? 0,
2120
+ unitAmount: BigInt(p.unit_amount ?? 0),
1179
2121
  currency: p.currency,
1180
- interval: p.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH,
2122
+ interval: toBillingInterval(p.recurring?.interval),
1181
2123
  nickname: p.nickname ?? null,
1182
2124
  productId: typeof p.product === "string" ? p.product : p.product?.id ?? "",
1183
2125
  active: p.active ?? true
@@ -1224,6 +2166,28 @@ var StripeProvider = class {
1224
2166
  });
1225
2167
  return { url: session.url ?? "" };
1226
2168
  }
2169
+ async createPaymentCheckoutSession(opts) {
2170
+ const stripe = await this.client();
2171
+ const lineItem = opts.priceId ? { price: opts.priceId, quantity: opts.quantity ?? 1 } : {
2172
+ price_data: {
2173
+ currency: opts.currency.toLowerCase(),
2174
+ // Stripe's SDK takes a JS number; toSafeNumber throws past 2^53
2175
+ // instead of silently rounding.
2176
+ unit_amount: toSafeNumber(opts.amount),
2177
+ product_data: { name: opts.name }
2178
+ },
2179
+ quantity: opts.quantity ?? 1
2180
+ };
2181
+ const session = await stripe.checkout.sessions.create({
2182
+ customer: opts.customerId,
2183
+ mode: "payment",
2184
+ line_items: [lineItem],
2185
+ success_url: opts.successUrl,
2186
+ cancel_url: opts.cancelUrl,
2187
+ metadata: opts.metadata
2188
+ });
2189
+ return { url: session.url ?? "", sessionId: session.id };
2190
+ }
1227
2191
  async resolvePriceById(priceId) {
1228
2192
  const stripe = await this.client();
1229
2193
  try {
@@ -1282,6 +2246,13 @@ var StripeProvider = class {
1282
2246
  } catch {
1283
2247
  throw new Error("[billing:stripe] Invalid webhook signature");
1284
2248
  }
2249
+ if (raw.type === "checkout.session.completed" || raw.type === "checkout.session.async_payment_succeeded") {
2250
+ const session = raw.data.object;
2251
+ if (session.mode === "payment") {
2252
+ return { type: raw.type, subscription: null, payment: normalizePaymentSession(session) };
2253
+ }
2254
+ return { type: raw.type, subscription: null };
2255
+ }
1285
2256
  const isSubscriptionEvent = [
1286
2257
  "customer.subscription.created",
1287
2258
  "customer.subscription.updated",
@@ -1302,29 +2273,29 @@ var StripeProvider = class {
1302
2273
  };
1303
2274
 
1304
2275
  // src/middlewares/require-plan.ts
1305
- var import_core7 = require("@fonderie/core");
2276
+ var import_core12 = require("@fonderie/core");
1306
2277
  function makeHandler(plans, store) {
1307
2278
  const allowed = Array.isArray(plans) ? plans : [plans];
1308
2279
  return async (ctx, next) => {
1309
2280
  if (!ctx.user) {
1310
- return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2281
+ return (0, import_core12.setApiResponse)(import_core12.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1311
2282
  }
1312
2283
  const subscriber = resolveSubscriber(ctx);
1313
2284
  if (!subscriber) {
1314
- return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "SUBSCRIBER_REQUIRED", "Subscriber context required");
2285
+ return (0, import_core12.setApiResponse)(import_core12.HTTP.BAD_REQUEST, "SUBSCRIBER_REQUIRED", "Subscriber context required");
1315
2286
  }
1316
2287
  const subscription = await getSubscription(subscriber.type, subscriber.id, store);
1317
2288
  if (!subscription || !allowed.includes(subscription.plan)) {
1318
- return (0, import_core7.setApiResponse)(
1319
- import_core7.HTTP.PAYMENT_REQUIRED,
2289
+ return (0, import_core12.setApiResponse)(
2290
+ import_core12.HTTP.PAYMENT_REQUIRED,
1320
2291
  "PLAN_UPGRADE_REQUIRED",
1321
2292
  "Plan upgrade required",
1322
2293
  { required: allowed, current: subscription?.plan ?? "none" }
1323
2294
  );
1324
2295
  }
1325
2296
  if (subscription.status !== "active" && subscription.status !== "trialing") {
1326
- return (0, import_core7.setApiResponse)(
1327
- import_core7.HTTP.PAYMENT_REQUIRED,
2297
+ return (0, import_core12.setApiResponse)(
2298
+ import_core12.HTTP.PAYMENT_REQUIRED,
1328
2299
  "SUBSCRIPTION_INACTIVE",
1329
2300
  "Subscription is not active",
1330
2301
  { status: subscription.status }
@@ -1338,56 +2309,27 @@ function requirePlan(plans, store, ctx, next) {
1338
2309
  if (ctx !== void 0 && next !== void 0) return handler(ctx, next);
1339
2310
  return handler;
1340
2311
  }
1341
-
1342
- // src/helpers.ts
1343
- var import_core8 = require("@fonderie/core");
1344
- function getBillingContext(ctx) {
1345
- return ctx.meta["billing"] ?? null;
1346
- }
1347
- function hasFeature(ctx, key) {
1348
- const billing = getBillingContext(ctx);
1349
- if (!billing) return true;
1350
- const status = billing.statuses[key];
1351
- if (!status) return true;
1352
- if (status.type === "feature") return status.enabled;
1353
- return true;
1354
- }
1355
- function getPlanLimit(ctx, key) {
1356
- const billing = getBillingContext(ctx);
1357
- if (!billing) return null;
1358
- const status = billing.statuses[key];
1359
- if (!status || status.type === "feature") return null;
1360
- return status.limit;
1361
- }
1362
- function getLimitStatus(ctx, key) {
1363
- const billing = getBillingContext(ctx);
1364
- if (!billing) return null;
1365
- return billing.statuses[key] ?? null;
1366
- }
1367
- function requireFeature(key) {
1368
- return (ctx, next) => {
1369
- if (!hasFeature(ctx, key)) {
1370
- return Promise.resolve(
1371
- (0, import_core8.setApiResponse)(
1372
- import_core8.HTTP.PAYMENT_REQUIRED,
1373
- "FEATURE_UNAVAILABLE",
1374
- `Feature '${key}' is not available on your current plan`
1375
- )
1376
- );
1377
- }
1378
- return next();
1379
- };
1380
- }
1381
2312
  // Annotate the CommonJS export names for ESM import in node:
1382
2313
  0 && (module.exports = {
1383
2314
  BILLING_INTERVAL,
2315
+ BILLING_INTERVALS,
1384
2316
  BillingModule,
1385
2317
  DBCounterBackend,
2318
+ DuplicateTransactionError,
2319
+ InsufficientFundsError,
1386
2320
  MESSAGE_KEYS,
1387
2321
  MemoryCounterBackend,
1388
2322
  StripeProvider,
2323
+ WALLET_LEDGER_TYPES,
1389
2324
  createPlan,
2325
+ creditWallet,
2326
+ currentGrantPeriod,
2327
+ debitWallet,
2328
+ debitWalletForMetric,
2329
+ decodeLedgerCursor,
1390
2330
  deletePlan,
2331
+ encodeLedgerCursor,
2332
+ ensurePeriodicGrant,
1391
2333
  getDBPlans,
1392
2334
  getLimitStatus,
1393
2335
  getPlanById,
@@ -1396,14 +2338,23 @@ function requireFeature(key) {
1396
2338
  getPlans,
1397
2339
  getSubscription,
1398
2340
  getUsage,
2341
+ getWalletBalance,
2342
+ getWalletLedger,
2343
+ getWalletRate,
2344
+ getWalletStatus,
1399
2345
  hasFeature,
2346
+ insufficientCreditsResponse,
2347
+ isBillingInterval,
1400
2348
  recordUsage,
1401
2349
  requireFeature,
1402
2350
  requirePlan,
2351
+ requireWalletBalance,
2352
+ resolvePlanWallet,
1403
2353
  schemas,
1404
2354
  toPlanDTO,
1405
2355
  toSubscriptionDTO,
1406
- toUsageRecordDTO,
2356
+ toWalletDTO,
2357
+ toWalletTransactionDTO,
1407
2358
  updatePlan,
1408
2359
  withBilling
1409
2360
  });