@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.js CHANGED
@@ -12,10 +12,25 @@ var schemas_exports = {};
12
12
  __export(schemas_exports, {
13
13
  checkoutSchema: () => checkoutSchema,
14
14
  createPlanSchema: () => createPlanSchema,
15
+ grantWalletSchema: () => grantWalletSchema,
15
16
  recordUsageSchema: () => recordUsageSchema,
16
- updatePlanSchema: () => updatePlanSchema
17
+ updatePlanSchema: () => updatePlanSchema,
18
+ walletCheckoutSchema: () => walletCheckoutSchema
17
19
  });
18
20
  import { z } from "zod";
21
+
22
+ // src/types.ts
23
+ var BILLING_INTERVALS = ["month", "year"];
24
+ var BILLING_INTERVAL = {
25
+ MONTH: "month",
26
+ YEAR: "year"
27
+ };
28
+ function isBillingInterval(value) {
29
+ return BILLING_INTERVALS.includes(value);
30
+ }
31
+ var WALLET_LEDGER_TYPES = ["purchase", "grant", "usage", "refund", "adjustment"];
32
+
33
+ // src/schemas.ts
19
34
  var planFields = {
20
35
  description: z.string().max(2e3).nullable().optional(),
21
36
  tier: z.number().int().min(0).optional(),
@@ -35,12 +50,29 @@ var createPlanSchema = z.object({
35
50
  var updatePlanSchema = z.object({ name: z.string().trim().min(1).max(200).optional(), ...planFields }).refine((o) => Object.values(o).some((v) => v !== void 0), "Provide at least one field");
36
51
  var checkoutSchema = z.object({
37
52
  plan: z.string().min(1, "plan is required"),
38
- interval: z.enum(["month", "year"]).optional()
53
+ interval: z.enum(BILLING_INTERVALS).optional()
39
54
  });
40
55
  var recordUsageSchema = z.object({
41
56
  metric: z.string().min(1, "metric is required").max(100),
42
57
  quantity: z.number().min(0).optional()
43
58
  });
59
+ var walletAmount = z.union([
60
+ z.string().regex(/^\d{1,30}$/, "amount must be a positive integer string"),
61
+ // JSON numbers past 2^53 arrive already rounded — force the digit-string
62
+ // form for anything larger instead of silently granting a wrong amount.
63
+ z.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
64
+ ]).transform((v) => BigInt(v)).refine((v) => v > 0n, "amount must be positive");
65
+ var walletCheckoutSchema = z.object({
66
+ packId: z.string().trim().min(1, "packId is required").max(100)
67
+ });
68
+ var grantWalletSchema = z.object({
69
+ subscriberType: z.enum(["user", "workspace"]),
70
+ subscriberId: z.string().uuid("subscriberId must be a UUID"),
71
+ amount: walletAmount,
72
+ currency: z.string().trim().regex(/^[A-Za-z]{3,20}$/, "currency must be a 3-20 letter code").transform((s) => s.toUpperCase()).optional(),
73
+ description: z.string().max(500).optional(),
74
+ idempotencyKey: z.string().min(1, "idempotencyKey is required").max(255)
75
+ });
44
76
 
45
77
  // src/services/price-cache.ts
46
78
  var PriceCache = class {
@@ -93,7 +125,62 @@ var PriceCache = class {
93
125
  // src/controllers/plan.controller.ts
94
126
  import { setApiResponse, HTTP, stringOrEmpty, numberOrZero } from "@fonderie/core";
95
127
 
128
+ // src/utils.ts
129
+ function toSafeNumber(amount) {
130
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER) || amount < -BigInt(Number.MAX_SAFE_INTEGER)) {
131
+ throw new Error(`[billing] amount ${amount} exceeds Number.MAX_SAFE_INTEGER`);
132
+ }
133
+ return Number(amount);
134
+ }
135
+ function normalizeCurrency(currency) {
136
+ return currency.trim().toUpperCase();
137
+ }
138
+ function parseWindowMs(window) {
139
+ const n = parseInt(window, 10);
140
+ const unit = window.slice(String(n).length);
141
+ switch (unit) {
142
+ case "h":
143
+ return n * 36e5;
144
+ case "d":
145
+ return n * 864e5;
146
+ case "m":
147
+ return n * 6e4;
148
+ default:
149
+ throw new Error(`Unknown window unit: '${unit}' in '${window}'`);
150
+ }
151
+ }
152
+ function resolveSubscriber(ctx) {
153
+ const wsFromHeader = ctx.request.headers.get("x-workspace-id");
154
+ if (wsFromHeader) {
155
+ return {
156
+ type: "workspace",
157
+ id: wsFromHeader
158
+ };
159
+ }
160
+ if (ctx.workspace?.id) {
161
+ return {
162
+ type: "workspace",
163
+ id: ctx.workspace.id
164
+ };
165
+ }
166
+ if (ctx.user?.id) {
167
+ return {
168
+ type: "user",
169
+ id: ctx.user.id
170
+ };
171
+ }
172
+ return null;
173
+ }
174
+
96
175
  // src/services/plans.ts
176
+ var planAmount = (v) => v == null ? null : toSafeNumber(BigInt(v));
177
+ function mapPlanRow(row) {
178
+ return {
179
+ ...row,
180
+ monthlyAmount: planAmount(row.monthlyAmount),
181
+ yearlyAmount: planAmount(row.yearlyAmount)
182
+ };
183
+ }
97
184
  function getPlans(config) {
98
185
  return config.plans;
99
186
  }
@@ -112,30 +199,33 @@ function resolvePlanNameByPrice(price, plans) {
112
199
  }
113
200
  return null;
114
201
  }
202
+ var walletToJson = (wallet) => wallet == null ? null : JSON.stringify(wallet, (_key, value) => typeof value === "bigint" ? value.toString() : value);
115
203
  async function syncPlansToDB(config, store) {
116
204
  const plans = config.plans;
117
205
  if (plans.length === 0) return;
118
206
  const values = plans.map((_, i) => {
119
- const b = i * 9;
120
- return `($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8}, $${b + 9}::jsonb)`;
207
+ const b = i * 10;
208
+ return `($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8}, $${b + 9}::jsonb, $${b + 10}::jsonb)`;
121
209
  });
122
210
  const params = plans.flatMap((plan) => [
123
211
  plan.name,
124
212
  plan.trialDays ?? 0,
125
- plan.monthly?.amount ?? null,
213
+ // bigint params go over the wire as strings; pg casts into the column type.
214
+ plan.monthly?.amount?.toString() ?? null,
126
215
  plan.monthly?.priceId ?? null,
127
- plan.yearly?.amount ?? null,
216
+ plan.yearly?.amount?.toString() ?? null,
128
217
  plan.yearly?.priceId ?? null,
129
218
  plan.description ?? null,
130
219
  plan.tier ?? 0,
131
- JSON.stringify(plan.metadata ?? {})
220
+ JSON.stringify(plan.metadata ?? {}),
221
+ walletToJson(plan.wallet)
132
222
  ]);
133
223
  await store.query(
134
224
  `INSERT INTO fonderie_plans
135
225
  (name, trial_days,
136
226
  monthly_amount, monthly_price_id,
137
227
  yearly_amount, yearly_price_id,
138
- description, tier, metadata)
228
+ description, tier, metadata, wallet)
139
229
  VALUES ${values.join(", ")}
140
230
  ON CONFLICT (name) DO UPDATE SET
141
231
  trial_days = EXCLUDED.trial_days,
@@ -145,7 +235,8 @@ async function syncPlansToDB(config, store) {
145
235
  yearly_price_id = EXCLUDED.yearly_price_id,
146
236
  description = EXCLUDED.description,
147
237
  tier = EXCLUDED.tier,
148
- metadata = EXCLUDED.metadata`,
238
+ metadata = EXCLUDED.metadata,
239
+ wallet = EXCLUDED.wallet`,
149
240
  params
150
241
  );
151
242
  }
@@ -165,13 +256,14 @@ var SELECT_PLAN = `
165
256
  metadata
166
257
  FROM fonderie_plans`;
167
258
  async function getDBPlans(store) {
168
- return store.query(
259
+ const rows = await store.query(
169
260
  `${SELECT_PLAN} WHERE active = true ORDER BY tier ASC, monthly_amount ASC NULLS LAST`
170
261
  );
262
+ return rows.map(mapPlanRow);
171
263
  }
172
264
  async function getPlanById(id, store) {
173
265
  const [row] = await store.query(`${SELECT_PLAN} WHERE id = $1`, [id]);
174
- return row ?? null;
266
+ return row ? mapPlanRow(row) : null;
175
267
  }
176
268
  async function createPlan(data, store) {
177
269
  const [row] = await store.query(
@@ -202,7 +294,7 @@ async function createPlan(data, store) {
202
294
  ]
203
295
  );
204
296
  if (!row) throw new Error("Failed to create plan");
205
- return row;
297
+ return mapPlanRow(row);
206
298
  }
207
299
  async function updatePlan(id, data, store) {
208
300
  const fieldMap = {
@@ -245,7 +337,7 @@ async function updatePlan(id, data, store) {
245
337
  description, tier, features, metadata`,
246
338
  params
247
339
  );
248
- return row ?? null;
340
+ return row ? mapPlanRow(row) : null;
249
341
  }
250
342
  async function deletePlan(id, store) {
251
343
  const rows = await store.query(
@@ -303,6 +395,7 @@ function toPlanDTO(plan) {
303
395
  metadata: plan.metadata && typeof plan.metadata === "object" ? plan.metadata : {}
304
396
  };
305
397
  }
398
+ var isoOrNull = (value) => value == null ? null : new Date(value).toISOString();
306
399
  function toSubscriptionDTO(sub) {
307
400
  return {
308
401
  id: sub.id,
@@ -312,20 +405,26 @@ function toSubscriptionDTO(sub) {
312
405
  interval: sub.interval,
313
406
  status: sub.status,
314
407
  cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
315
- currentPeriodStart: sub.currentPeriodStart,
316
- currentPeriodEnd: sub.currentPeriodEnd,
317
- trialEndsAt: sub.trialEndsAt,
318
- createdAt: sub.createdAt
408
+ currentPeriodStart: isoOrNull(sub.currentPeriodStart),
409
+ currentPeriodEnd: isoOrNull(sub.currentPeriodEnd),
410
+ trialEndsAt: isoOrNull(sub.trialEndsAt),
411
+ createdAt: isoOrNull(sub.createdAt) ?? ""
319
412
  };
320
413
  }
321
- function toUsageRecordDTO(record) {
414
+ function toWalletDTO(balance, currency, precision) {
415
+ return { balance: balance.toString(), currency, precision };
416
+ }
417
+ function toWalletTransactionDTO(entry) {
322
418
  return {
323
- id: record.id,
324
- subscriberType: record.subscriberType,
325
- subscriberId: record.subscriberId,
326
- metric: record.metric,
327
- quantity: record.quantity,
328
- recordedAt: record.recordedAt
419
+ id: entry.id,
420
+ type: entry.type,
421
+ amount: entry.amount.toString(),
422
+ balanceAfter: entry.balanceAfter.toString(),
423
+ currency: entry.currency,
424
+ description: entry.description,
425
+ providerTxId: entry.providerTxId,
426
+ metadata: entry.metadata,
427
+ createdAt: entry.createdAt
329
428
  };
330
429
  }
331
430
 
@@ -345,8 +444,8 @@ async function hydratePricing(dto, plan, config, cache) {
345
444
  `[billing] plan "${plan.name}": monthly/yearly currency mismatch (${m.currency} vs ${y.currency})`
346
445
  );
347
446
  }
348
- if (m) dto.pricing.monthly = m.unitAmount;
349
- if (y) dto.pricing.yearly = y.unitAmount;
447
+ if (m) dto.pricing.monthly = toSafeNumber(m.unitAmount);
448
+ if (y) dto.pricing.yearly = toSafeNumber(y.unitAmount);
350
449
  const currency = m?.currency ?? y?.currency;
351
450
  if (currency) dto.pricing.currency = currency.toUpperCase();
352
451
  if (stale) dto.pricingStale = true;
@@ -529,44 +628,6 @@ var SubscriptionModel = class {
529
628
  }
530
629
  };
531
630
 
532
- // src/utils.ts
533
- function parseWindowMs(window) {
534
- const n = parseInt(window, 10);
535
- const unit = window.slice(String(n).length);
536
- switch (unit) {
537
- case "h":
538
- return n * 36e5;
539
- case "d":
540
- return n * 864e5;
541
- case "m":
542
- return n * 6e4;
543
- default:
544
- throw new Error(`Unknown window unit: '${unit}' in '${window}'`);
545
- }
546
- }
547
- function resolveSubscriber(ctx) {
548
- const wsFromHeader = ctx.request.headers.get("x-workspace-id");
549
- if (wsFromHeader) {
550
- return {
551
- type: "workspace",
552
- id: wsFromHeader
553
- };
554
- }
555
- if (ctx.workspace?.id) {
556
- return {
557
- type: "workspace",
558
- id: ctx.workspace.id
559
- };
560
- }
561
- if (ctx.user?.id) {
562
- return {
563
- type: "user",
564
- id: ctx.user.id
565
- };
566
- }
567
- return null;
568
- }
569
-
570
631
  // src/controllers/subscription.controller.ts
571
632
  function subscriptionController(store) {
572
633
  const subscriptions = new SubscriptionModel(store);
@@ -597,6 +658,18 @@ function subscriptionController(store) {
597
658
 
598
659
  // src/controllers/checkout.controller.ts
599
660
  import { setApiResponse as setApiResponse3, HTTP as HTTP3 } from "@fonderie/core";
661
+ function planPriceFor(plan, interval) {
662
+ switch (interval) {
663
+ case BILLING_INTERVAL.MONTH:
664
+ return plan.monthly;
665
+ case BILLING_INTERVAL.YEAR:
666
+ return plan.yearly;
667
+ default: {
668
+ const unhandled = interval;
669
+ throw new Error(`[billing] unhandled billing interval: ${unhandled}`);
670
+ }
671
+ }
672
+ }
600
673
  function checkoutController(store, config) {
601
674
  const plans = new PlanModel(store);
602
675
  const subscriptions = new SubscriptionModel(store);
@@ -604,16 +677,16 @@ function checkoutController(store, config) {
604
677
  async createSession(ctx) {
605
678
  const body = ctx.meta["body"];
606
679
  const planName = body?.["plan"];
607
- const interval = body?.["interval"] ?? "month";
680
+ const interval = body?.["interval"] ?? BILLING_INTERVAL.MONTH;
608
681
  const subscriber = resolveSubscriber(ctx);
609
682
  if (typeof planName !== "string") {
610
683
  return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "plan is required");
611
684
  }
612
- if (interval !== "month" && interval !== "year") {
685
+ if (!isBillingInterval(interval)) {
613
686
  return setApiResponse3(
614
687
  HTTP3.UNPROCESSABLE,
615
688
  "INVALID_PARAMETER",
616
- "interval must be month or year"
689
+ `interval must be one of: ${BILLING_INTERVALS.join(", ")}`
617
690
  );
618
691
  }
619
692
  if (!subscriber) {
@@ -627,7 +700,7 @@ function checkoutController(store, config) {
627
700
  if (!plan) {
628
701
  return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", `Unknown plan: ${planName}`);
629
702
  }
630
- const pricing = interval === "year" ? plan.yearly : plan.monthly;
703
+ const pricing = planPriceFor(plan, interval);
631
704
  if (!pricing?.priceId) {
632
705
  return setApiResponse3(
633
706
  HTTP3.UNPROCESSABLE,
@@ -803,36 +876,701 @@ function usageController(store) {
803
876
  return setApiResponse4(HTTP4.OK, "USAGE_FETCHED", "Usage retrieved successfully.", {
804
877
  metric,
805
878
  total,
806
- since
879
+ // Explicit ISO — the client's IUsageResult.since promises a string.
880
+ since: since.toISOString()
807
881
  });
808
882
  }
809
883
  };
810
884
  }
811
885
 
812
- // src/controllers/webhook.controller.ts
886
+ // src/controllers/wallet.controller.ts
887
+ import { setApiResponse as setApiResponse6, HTTP as HTTP6 } from "@fonderie/core";
888
+
889
+ // src/errors.ts
890
+ var InsufficientFundsError = class extends Error {
891
+ constructor(available, required, currency) {
892
+ super(
893
+ `[billing:wallet] insufficient funds: available ${available}, required ${required} ${currency}`
894
+ );
895
+ this.available = available;
896
+ this.required = required;
897
+ this.currency = currency;
898
+ this.name = "InsufficientFundsError";
899
+ }
900
+ available;
901
+ required;
902
+ currency;
903
+ };
904
+ var DuplicateTransactionError = class extends Error {
905
+ constructor(idempotencyKey) {
906
+ super(
907
+ `[billing:wallet] idempotency key '${idempotencyKey}' was already used for a different subscriber or currency`
908
+ );
909
+ this.idempotencyKey = idempotencyKey;
910
+ this.name = "DuplicateTransactionError";
911
+ }
912
+ idempotencyKey;
913
+ };
914
+
915
+ // src/services/wallet.ts
916
+ var UNIQUE_VIOLATION = "23505";
917
+ function isIdempotencyConflict(err) {
918
+ const e = err;
919
+ if (e?.code !== UNIQUE_VIOLATION) return false;
920
+ if (typeof e.constraint === "string") return e.constraint.includes("idempotency_key");
921
+ return typeof e.message === "string" && e.message.includes("idempotency_key");
922
+ }
923
+ async function findByIdempotencyKey(sub, idempotencyKey, store) {
924
+ const [row] = await store.query(
925
+ `SELECT
926
+ subscriber_type AS "subscriberType",
927
+ subscriber_id AS "subscriberId",
928
+ currency
929
+ FROM fonderie_wallet_ledger
930
+ WHERE idempotency_key = $1`,
931
+ [idempotencyKey]
932
+ );
933
+ if (!row) return null;
934
+ if (row.subscriberType !== sub.subscriberType || row.subscriberId !== sub.subscriberId || row.currency !== sub.currency) {
935
+ throw new DuplicateTransactionError(idempotencyKey);
936
+ }
937
+ return row;
938
+ }
939
+ async function readBalance(sub, store) {
940
+ const [row] = await store.query(
941
+ `SELECT amount FROM fonderie_wallet_balances
942
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3`,
943
+ [sub.subscriberType, sub.subscriberId, sub.currency]
944
+ );
945
+ return BigInt(row?.amount ?? "0");
946
+ }
947
+ async function applyBalanceCredit(tx, sub, amount) {
948
+ const [row] = await tx.query(
949
+ `INSERT INTO fonderie_wallet_balances (subscriber_type, subscriber_id, currency, amount)
950
+ VALUES ($1, $2, $3, $4)
951
+ ON CONFLICT (subscriber_type, subscriber_id, currency) DO UPDATE SET
952
+ amount = fonderie_wallet_balances.amount + EXCLUDED.amount,
953
+ version = fonderie_wallet_balances.version + 1,
954
+ updated_at = now()
955
+ RETURNING amount`,
956
+ [sub.subscriberType, sub.subscriberId, sub.currency, amount.toString()]
957
+ );
958
+ return BigInt(row?.amount ?? "0");
959
+ }
960
+ async function insertLedgerRow(tx, sub, opts) {
961
+ await tx.query(
962
+ `INSERT INTO fonderie_wallet_ledger
963
+ (subscriber_type, subscriber_id, currency, type, amount, balance_after,
964
+ description, idempotency_key, metadata, provider_tx_id)
965
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
966
+ [
967
+ sub.subscriberType,
968
+ sub.subscriberId,
969
+ sub.currency,
970
+ opts.type,
971
+ opts.amount.toString(),
972
+ opts.balanceAfter.toString(),
973
+ opts.description,
974
+ opts.idempotencyKey,
975
+ JSON.stringify(opts.metadata),
976
+ opts.providerTxId
977
+ ]
978
+ );
979
+ }
980
+ async function creditWallet(opts, store) {
981
+ if (opts.amount < 0n) throw new Error("[billing:wallet] credit amount must be positive");
982
+ if (!opts.idempotencyKey) throw new Error("[billing:wallet] idempotencyKey is required");
983
+ if (opts.amount === 0n) {
984
+ return { balance: await readBalance(opts, store), duplicate: false };
985
+ }
986
+ try {
987
+ return await store.transaction(async (tx) => {
988
+ const existing = await findByIdempotencyKey(opts, opts.idempotencyKey, tx);
989
+ if (existing) return { balance: await readBalance(opts, tx), duplicate: true };
990
+ const balance = await applyBalanceCredit(tx, opts, opts.amount);
991
+ await insertLedgerRow(tx, opts, {
992
+ type: opts.type ?? "adjustment",
993
+ amount: opts.amount,
994
+ balanceAfter: balance,
995
+ idempotencyKey: opts.idempotencyKey,
996
+ description: opts.description ?? null,
997
+ metadata: opts.metadata ?? {},
998
+ providerTxId: opts.providerTxId ?? null
999
+ });
1000
+ return { balance, duplicate: false };
1001
+ });
1002
+ } catch (err) {
1003
+ if (isIdempotencyConflict(err)) {
1004
+ return { balance: await readBalance(opts, store), duplicate: true };
1005
+ }
1006
+ throw err;
1007
+ }
1008
+ }
1009
+ async function debitWallet(opts, store) {
1010
+ if (opts.amount < 0n) throw new Error("[billing:wallet] debit amount must be positive");
1011
+ if (!opts.idempotencyKey) throw new Error("[billing:wallet] idempotencyKey is required");
1012
+ if (opts.amount === 0n) {
1013
+ return { balance: await readBalance(opts, store), duplicate: false };
1014
+ }
1015
+ const floor = -(opts.overdraftLimit ?? 0n);
1016
+ try {
1017
+ return await store.transaction(async (tx) => {
1018
+ const existing = await findByIdempotencyKey(opts, opts.idempotencyKey, tx);
1019
+ if (existing) return { balance: await readBalance(opts, tx), duplicate: true };
1020
+ await tx.query(
1021
+ `INSERT INTO fonderie_wallet_balances (subscriber_type, subscriber_id, currency, amount)
1022
+ VALUES ($1, $2, $3, 0)
1023
+ ON CONFLICT (subscriber_type, subscriber_id, currency) DO NOTHING`,
1024
+ [opts.subscriberType, opts.subscriberId, opts.currency]
1025
+ );
1026
+ const [locked] = await tx.query(
1027
+ `SELECT amount FROM fonderie_wallet_balances
1028
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3
1029
+ FOR UPDATE`,
1030
+ [opts.subscriberType, opts.subscriberId, opts.currency]
1031
+ );
1032
+ const current = BigInt(locked?.amount ?? "0");
1033
+ if (current - opts.amount < floor) {
1034
+ throw new InsufficientFundsError(current, opts.amount, opts.currency);
1035
+ }
1036
+ const [updated] = await tx.query(
1037
+ `UPDATE fonderie_wallet_balances
1038
+ SET amount = amount - $4, version = version + 1, updated_at = now()
1039
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3
1040
+ AND amount - $4 >= $5
1041
+ RETURNING amount`,
1042
+ [
1043
+ opts.subscriberType,
1044
+ opts.subscriberId,
1045
+ opts.currency,
1046
+ opts.amount.toString(),
1047
+ floor.toString()
1048
+ ]
1049
+ );
1050
+ if (!updated) {
1051
+ throw new InsufficientFundsError(current, opts.amount, opts.currency);
1052
+ }
1053
+ const balance = BigInt(updated.amount);
1054
+ await insertLedgerRow(tx, opts, {
1055
+ type: opts.type ?? "usage",
1056
+ amount: -opts.amount,
1057
+ balanceAfter: balance,
1058
+ idempotencyKey: opts.idempotencyKey,
1059
+ description: opts.description ?? null,
1060
+ metadata: opts.metadata ?? {},
1061
+ providerTxId: null
1062
+ });
1063
+ return { balance, duplicate: false };
1064
+ });
1065
+ } catch (err) {
1066
+ if (isIdempotencyConflict(err)) {
1067
+ return { balance: await readBalance(opts, store), duplicate: true };
1068
+ }
1069
+ throw err;
1070
+ }
1071
+ }
1072
+ async function getWalletBalance(sub, store) {
1073
+ const [row] = await store.query(
1074
+ `SELECT amount, version, updated_at AS "updatedAt"
1075
+ FROM fonderie_wallet_balances
1076
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3`,
1077
+ [sub.subscriberType, sub.subscriberId, sub.currency]
1078
+ );
1079
+ if (!row) return { balance: 0n, version: 0, updatedAt: null };
1080
+ return {
1081
+ balance: BigInt(row.amount),
1082
+ version: Number(row.version),
1083
+ updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
1084
+ };
1085
+ }
1086
+ function encodeLedgerCursor(createdAt, id) {
1087
+ return Buffer.from(JSON.stringify([createdAt, id])).toString("base64url");
1088
+ }
1089
+ 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})?)?$/;
1090
+ 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}$/;
1091
+ function decodeLedgerCursor(cursor) {
1092
+ if (cursor.length > 256) return null;
1093
+ try {
1094
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
1095
+ if (!Array.isArray(parsed) || typeof parsed[0] !== "string" || typeof parsed[1] !== "string") {
1096
+ return null;
1097
+ }
1098
+ if (!CURSOR_TS_RE.test(parsed[0]) || !CURSOR_ID_RE.test(parsed[1])) return null;
1099
+ return { createdAt: parsed[0], id: parsed[1] };
1100
+ } catch {
1101
+ return null;
1102
+ }
1103
+ }
1104
+ async function getWalletLedger(opts, store) {
1105
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
1106
+ const params = [opts.subscriberType, opts.subscriberId, opts.currency];
1107
+ let cursorClause = "";
1108
+ if (opts.cursor) {
1109
+ params.push(opts.cursor.createdAt, opts.cursor.id);
1110
+ cursorClause = `AND (created_at, id) < ($4::timestamptz, $5::uuid)`;
1111
+ }
1112
+ params.push(limit + 1);
1113
+ const rows = await store.query(
1114
+ `SELECT
1115
+ id,
1116
+ subscriber_type AS "subscriberType",
1117
+ subscriber_id AS "subscriberId",
1118
+ currency,
1119
+ type,
1120
+ amount,
1121
+ balance_after AS "balanceAfter",
1122
+ description,
1123
+ idempotency_key AS "idempotencyKey",
1124
+ metadata,
1125
+ provider_tx_id AS "providerTxId",
1126
+ created_at AS "createdAt",
1127
+ created_at::text AS "createdAtRaw"
1128
+ FROM fonderie_wallet_ledger
1129
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3
1130
+ ${cursorClause}
1131
+ ORDER BY created_at DESC, id DESC
1132
+ LIMIT $${params.length}`,
1133
+ params
1134
+ );
1135
+ const page = rows.slice(0, limit);
1136
+ const entries = page.map((r) => ({
1137
+ id: r.id,
1138
+ subscriberType: r.subscriberType,
1139
+ subscriberId: r.subscriberId,
1140
+ currency: r.currency,
1141
+ type: r.type,
1142
+ amount: BigInt(r.amount),
1143
+ balanceAfter: BigInt(r.balanceAfter),
1144
+ description: r.description,
1145
+ idempotencyKey: r.idempotencyKey,
1146
+ metadata: r.metadata ?? {},
1147
+ providerTxId: r.providerTxId,
1148
+ createdAt: new Date(r.createdAt).toISOString()
1149
+ }));
1150
+ const lastRow = page[page.length - 1];
1151
+ const nextCursor = rows.length > limit && lastRow ? encodeLedgerCursor(lastRow.createdAtRaw, lastRow.id) : null;
1152
+ return { entries, nextCursor };
1153
+ }
1154
+ function resolvePlanWallet(plan, config) {
1155
+ if (!config.wallet || !plan.wallet) return null;
1156
+ return {
1157
+ currency: normalizeCurrency(plan.wallet.currency ?? config.wallet.currency ?? "USD"),
1158
+ precision: plan.wallet.precision ?? config.wallet.precision ?? 2,
1159
+ overdraftLimit: plan.wallet.overdraftLimit ?? 0n,
1160
+ grantAmount: plan.wallet.grantAmount ?? null,
1161
+ grantPeriod: plan.wallet.grantPeriod ?? "month",
1162
+ rates: plan.wallet.rates ?? {}
1163
+ };
1164
+ }
1165
+ function currentGrantPeriod(period, now = /* @__PURE__ */ new Date()) {
1166
+ const y = now.getUTCFullYear();
1167
+ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
1168
+ const d = String(now.getUTCDate()).padStart(2, "0");
1169
+ if (period === "month") return `${y}-${m}`;
1170
+ if (period === "day") return `${y}-${m}-${d}`;
1171
+ const thursday = new Date(Date.UTC(y, now.getUTCMonth(), now.getUTCDate()));
1172
+ thursday.setUTCDate(thursday.getUTCDate() + 4 - (thursday.getUTCDay() || 7));
1173
+ const isoYear = thursday.getUTCFullYear();
1174
+ const jan4 = new Date(Date.UTC(isoYear, 0, 4));
1175
+ jan4.setUTCDate(jan4.getUTCDate() + 4 - (jan4.getUTCDay() || 7));
1176
+ const week = 1 + Math.round((thursday.getTime() - jan4.getTime()) / (7 * 864e5));
1177
+ return `${isoYear}-W${String(week).padStart(2, "0")}`;
1178
+ }
1179
+ async function ensurePeriodicGrant(opts, store) {
1180
+ if (opts.amount <= 0n) return { granted: false, balance: null };
1181
+ const [seen] = await store.query(
1182
+ `SELECT period FROM fonderie_wallet_grants
1183
+ WHERE subscriber_type = $1 AND subscriber_id = $2 AND currency = $3 AND period = $4`,
1184
+ [opts.subscriberType, opts.subscriberId, opts.currency, opts.period]
1185
+ );
1186
+ if (seen) return { granted: false, balance: null };
1187
+ try {
1188
+ return await store.transaction(async (tx) => {
1189
+ const [marked] = await tx.query(
1190
+ `INSERT INTO fonderie_wallet_grants (subscriber_type, subscriber_id, currency, period, amount)
1191
+ VALUES ($1, $2, $3, $4, $5)
1192
+ ON CONFLICT (subscriber_type, subscriber_id, currency, period) DO NOTHING
1193
+ RETURNING period`,
1194
+ [
1195
+ opts.subscriberType,
1196
+ opts.subscriberId,
1197
+ opts.currency,
1198
+ opts.period,
1199
+ opts.amount.toString()
1200
+ ]
1201
+ );
1202
+ if (!marked) return { granted: false, balance: null };
1203
+ const balance = await applyBalanceCredit(tx, opts, opts.amount);
1204
+ await insertLedgerRow(tx, opts, {
1205
+ type: "grant",
1206
+ amount: opts.amount,
1207
+ balanceAfter: balance,
1208
+ idempotencyKey: `grant:${opts.subscriberType}:${opts.subscriberId}:${opts.currency}:${opts.period}`,
1209
+ description: opts.description ?? `Periodic grant ${opts.period}`,
1210
+ metadata: { period: opts.period },
1211
+ providerTxId: null
1212
+ });
1213
+ return { granted: true, balance };
1214
+ });
1215
+ } catch (err) {
1216
+ if (isIdempotencyConflict(err)) return { granted: false, balance: null };
1217
+ throw err;
1218
+ }
1219
+ }
1220
+
1221
+ // src/models/wallet.model.ts
1222
+ var WalletModel = class {
1223
+ constructor(store) {
1224
+ this.store = store;
1225
+ }
1226
+ store;
1227
+ credit(opts) {
1228
+ return creditWallet(opts, this.store);
1229
+ }
1230
+ debit(opts) {
1231
+ return debitWallet(opts, this.store);
1232
+ }
1233
+ balance(sub) {
1234
+ return getWalletBalance(sub, this.store);
1235
+ }
1236
+ ledger(opts) {
1237
+ return getWalletLedger(opts, this.store);
1238
+ }
1239
+ ensureGrant(opts) {
1240
+ return ensurePeriodicGrant(opts, this.store);
1241
+ }
1242
+ };
1243
+
1244
+ // src/services/credit-packs.ts
1245
+ function findCreditPack(packId, config) {
1246
+ const pack = config.wallet?.creditPacks?.find((p) => p.id === packId);
1247
+ if (!pack || pack.active === false) return null;
1248
+ return pack;
1249
+ }
1250
+ async function syncCreditPacksToDB(config, store) {
1251
+ const packs = config.wallet?.creditPacks ?? [];
1252
+ if (packs.length === 0) return;
1253
+ const defaultCurrency = normalizeCurrency(config.wallet?.currency ?? "USD");
1254
+ const values = packs.map((_, i) => {
1255
+ const b = i * 8;
1256
+ return `($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8}::jsonb)`;
1257
+ });
1258
+ const params = packs.flatMap((pack) => [
1259
+ pack.id,
1260
+ pack.name,
1261
+ normalizeCurrency(pack.currency ?? defaultCurrency),
1262
+ pack.credits.toString(),
1263
+ pack.priceAmount.toString(),
1264
+ pack.priceId ?? null,
1265
+ pack.active !== false,
1266
+ JSON.stringify(pack.metadata ?? {})
1267
+ ]);
1268
+ await store.query(
1269
+ `INSERT INTO fonderie_credit_packs
1270
+ (id, name, currency, credits, price_amount, price_id, active, metadata)
1271
+ VALUES ${values.join(", ")}
1272
+ ON CONFLICT (id) DO UPDATE SET
1273
+ name = EXCLUDED.name,
1274
+ currency = EXCLUDED.currency,
1275
+ credits = EXCLUDED.credits,
1276
+ price_amount = EXCLUDED.price_amount,
1277
+ price_id = EXCLUDED.price_id,
1278
+ active = EXCLUDED.active,
1279
+ metadata = EXCLUDED.metadata`,
1280
+ params
1281
+ );
1282
+ }
1283
+
1284
+ // src/helpers.ts
813
1285
  import { setApiResponse as setApiResponse5, HTTP as HTTP5 } from "@fonderie/core";
814
- function webhookController(store, config, priceCache) {
1286
+ function getBillingContext(ctx) {
1287
+ return ctx.meta["billing"] ?? null;
1288
+ }
1289
+ function hasFeature(ctx, key) {
1290
+ const billing = getBillingContext(ctx);
1291
+ if (!billing) return true;
1292
+ const status = billing.statuses[key];
1293
+ if (!status) return true;
1294
+ if (status.type === "feature") return status.enabled;
1295
+ return true;
1296
+ }
1297
+ function getPlanLimit(ctx, key) {
1298
+ const billing = getBillingContext(ctx);
1299
+ if (!billing) return null;
1300
+ const status = billing.statuses[key];
1301
+ if (!status || status.type === "feature") return null;
1302
+ return status.limit;
1303
+ }
1304
+ function getLimitStatus(ctx, key) {
1305
+ const billing = getBillingContext(ctx);
1306
+ if (!billing) return null;
1307
+ return billing.statuses[key] ?? null;
1308
+ }
1309
+ function getWalletStatus(ctx) {
1310
+ return getBillingContext(ctx)?.wallet ?? null;
1311
+ }
1312
+ function getWalletRate(ctx, metric) {
1313
+ return getWalletStatus(ctx)?.rates[metric]?.cost ?? null;
1314
+ }
1315
+ function requireWalletBalance(metric) {
1316
+ return (ctx, next) => {
1317
+ const wallet = getWalletStatus(ctx);
1318
+ const cost = wallet?.rates[metric]?.cost;
1319
+ if (!wallet || cost === void 0 || cost === 0n) return next();
1320
+ if (wallet.balance - cost < -wallet.overdraftLimit) {
1321
+ return Promise.resolve(
1322
+ setApiResponse5(HTTP5.PAYMENT_REQUIRED, "INSUFFICIENT_CREDITS", "Insufficient credits", {
1323
+ metric,
1324
+ cost: cost.toString(),
1325
+ balance: wallet.balance.toString(),
1326
+ currency: wallet.currency
1327
+ })
1328
+ );
1329
+ }
1330
+ return next();
1331
+ };
1332
+ }
1333
+ async function debitWalletForMetric(ctx, metric, opts, store) {
1334
+ const quantity = opts.quantity ?? 1;
1335
+ if (!Number.isInteger(quantity) || quantity <= 0) {
1336
+ throw new Error("[billing:wallet] quantity must be a positive integer");
1337
+ }
1338
+ const billing = getBillingContext(ctx);
1339
+ const wallet = billing?.wallet;
1340
+ const cost = wallet?.rates[metric]?.cost;
1341
+ if (!billing || !wallet || cost === void 0 || cost === 0n) return null;
1342
+ return debitWallet(
1343
+ {
1344
+ subscriberType: billing.subscriber.type,
1345
+ subscriberId: billing.subscriber.id,
1346
+ currency: wallet.currency,
1347
+ amount: cost * BigInt(quantity),
1348
+ overdraftLimit: wallet.overdraftLimit,
1349
+ idempotencyKey: opts.idempotencyKey,
1350
+ description: opts.description ?? metric,
1351
+ metadata: { metric, quantity, ...opts.metadata ?? {} }
1352
+ },
1353
+ store
1354
+ );
1355
+ }
1356
+ function insufficientCreditsResponse(err, metric) {
1357
+ return setApiResponse5(HTTP5.PAYMENT_REQUIRED, "INSUFFICIENT_CREDITS", "Insufficient credits", {
1358
+ ...metric ? { metric } : {},
1359
+ cost: err.required.toString(),
1360
+ balance: err.available.toString(),
1361
+ currency: err.currency
1362
+ });
1363
+ }
1364
+ function requireFeature(key) {
1365
+ return (ctx, next) => {
1366
+ if (!hasFeature(ctx, key)) {
1367
+ return Promise.resolve(
1368
+ setApiResponse5(
1369
+ HTTP5.PAYMENT_REQUIRED,
1370
+ "FEATURE_UNAVAILABLE",
1371
+ `Feature '${key}' is not available on your current plan`
1372
+ )
1373
+ );
1374
+ }
1375
+ return next();
1376
+ };
1377
+ }
1378
+
1379
+ // src/controllers/wallet.controller.ts
1380
+ function walletController(store, config) {
1381
+ const wallet = new WalletModel(store);
815
1382
  const subscriptions = new SubscriptionModel(store);
1383
+ const defaultCurrency = () => normalizeCurrency(config.wallet?.currency ?? "USD");
1384
+ const currencyOf = (ctx) => {
1385
+ const q = new URL(ctx.request.url).searchParams.get("currency");
1386
+ if (q) return normalizeCurrency(q);
1387
+ return getWalletStatus(ctx)?.currency ?? defaultCurrency();
1388
+ };
1389
+ const precisionOf = (ctx) => getWalletStatus(ctx)?.precision ?? config.wallet?.precision ?? 2;
816
1390
  return {
817
- async handle(ctx) {
818
- if (!config.webhookSecret) {
819
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Webhook secret not configured");
1391
+ async get(ctx) {
1392
+ const subscriber = resolveSubscriber(ctx);
1393
+ if (!subscriber) {
1394
+ return setApiResponse6(
1395
+ HTTP6.BAD_REQUEST,
1396
+ "SUBSCRIBER_REQUIRED",
1397
+ "Subscriber context required"
1398
+ );
820
1399
  }
821
- const signature = ctx.request.headers.get("stripe-signature") ?? ctx.request.headers.get("paypal-auth-algo") ?? "";
822
- if (!signature) {
823
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_REQUEST", "Missing webhook signature");
1400
+ const currency = currencyOf(ctx);
1401
+ const { balance } = await wallet.balance({
1402
+ subscriberType: subscriber.type,
1403
+ subscriberId: subscriber.id,
1404
+ currency
1405
+ });
1406
+ return setApiResponse6(HTTP6.OK, "WALLET_FETCHED", "Wallet retrieved successfully.", {
1407
+ wallet: toWalletDTO(balance, currency, precisionOf(ctx))
1408
+ });
1409
+ },
1410
+ async transactions(ctx) {
1411
+ const subscriber = resolveSubscriber(ctx);
1412
+ if (!subscriber) {
1413
+ return setApiResponse6(
1414
+ HTTP6.BAD_REQUEST,
1415
+ "SUBSCRIBER_REQUIRED",
1416
+ "Subscriber context required"
1417
+ );
824
1418
  }
825
- const payload = await ctx.request.text();
826
- let event;
1419
+ const params = new URL(ctx.request.url).searchParams;
1420
+ const rawLimit = params.get("limit");
1421
+ const limit = rawLimit !== null ? Number.parseInt(rawLimit, 10) : 50;
1422
+ if (Number.isNaN(limit) || limit < 1 || limit > 100) {
1423
+ return setApiResponse6(
1424
+ HTTP6.UNPROCESSABLE,
1425
+ "INVALID_PARAMETER",
1426
+ "limit must be an integer between 1 and 100"
1427
+ );
1428
+ }
1429
+ const rawCursor = params.get("cursor");
1430
+ const cursor = rawCursor !== null ? decodeLedgerCursor(rawCursor) : null;
1431
+ if (rawCursor !== null && cursor === null) {
1432
+ return setApiResponse6(HTTP6.UNPROCESSABLE, "INVALID_PARAMETER", "Malformed cursor");
1433
+ }
1434
+ const page = await wallet.ledger({
1435
+ subscriberType: subscriber.type,
1436
+ subscriberId: subscriber.id,
1437
+ currency: currencyOf(ctx),
1438
+ limit,
1439
+ ...cursor ? { cursor } : {}
1440
+ });
1441
+ return setApiResponse6(
1442
+ HTTP6.OK,
1443
+ "WALLET_TRANSACTIONS",
1444
+ `Retrieved ${page.entries.length} wallet transactions`,
1445
+ {
1446
+ transactions: page.entries.map(toWalletTransactionDTO),
1447
+ nextCursor: page.nextCursor
1448
+ }
1449
+ );
1450
+ },
1451
+ // One-time checkout for a credit pack. The pack's credits and the
1452
+ // buyer's WALLET currency are snapshotted into the session metadata at
1453
+ // creation time, so the webhook credits exactly what was bought (even
1454
+ // if config changes later) into the bucket the buyer's spend paths
1455
+ // actually read. pack.currency only prices the provider charge.
1456
+ async checkout(ctx) {
1457
+ const body = ctx.meta["body"];
1458
+ const subscriber = resolveSubscriber(ctx);
1459
+ if (!subscriber) {
1460
+ return setApiResponse6(
1461
+ HTTP6.BAD_REQUEST,
1462
+ "SUBSCRIBER_REQUIRED",
1463
+ "Subscriber context required"
1464
+ );
1465
+ }
1466
+ const pack = findCreditPack(body.packId, config);
1467
+ if (!pack) {
1468
+ return setApiResponse6(
1469
+ HTTP6.UNPROCESSABLE,
1470
+ "INVALID_PARAMETER",
1471
+ `Unknown credit pack: ${body.packId}`
1472
+ );
1473
+ }
1474
+ if (!config.provider.createPaymentCheckoutSession) {
1475
+ return setApiResponse6(
1476
+ HTTP6.NOT_IMPLEMENTED,
1477
+ "PAYMENT_NOT_SUPPORTED",
1478
+ `Provider '${config.provider.name}' does not support one-time payments`
1479
+ );
1480
+ }
1481
+ const creditCurrency = getWalletStatus(ctx)?.currency ?? defaultCurrency();
1482
+ const chargeCurrency = normalizeCurrency(pack.currency ?? creditCurrency);
1483
+ const current = await subscriptions.get(subscriber.type, subscriber.id);
1484
+ const customerId = current?.providerCustomerId ?? (await config.provider.createCustomer({
1485
+ email: ctx.user.email ?? "",
1486
+ subscriberType: subscriber.type,
1487
+ subscriberId: subscriber.id,
1488
+ userId: ctx.user.id
1489
+ })).customerId;
1490
+ const session = await config.provider.createPaymentCheckoutSession({
1491
+ customerId,
1492
+ amount: pack.priceAmount,
1493
+ currency: chargeCurrency,
1494
+ name: pack.name,
1495
+ ...pack.priceId ? { priceId: pack.priceId } : {},
1496
+ metadata: {
1497
+ subscriberType: subscriber.type,
1498
+ subscriberId: subscriber.id,
1499
+ packId: pack.id,
1500
+ credits: pack.credits.toString(),
1501
+ currency: creditCurrency
1502
+ },
1503
+ successUrl: config.successUrl,
1504
+ cancelUrl: config.cancelUrl
1505
+ });
1506
+ return setApiResponse6(HTTP6.OK, "CHECKOUT_URL", "Checkout session created.", {
1507
+ url: session.url,
1508
+ sessionId: session.sessionId
1509
+ });
1510
+ },
1511
+ // Admin-token-guarded manual grant (support/ops). Body is validated and
1512
+ // transformed by grantWalletSchema — amount arrives as a bigint.
1513
+ async grant(ctx) {
1514
+ const body = ctx.meta["body"];
1515
+ const currency = body.currency ? normalizeCurrency(body.currency) : defaultCurrency();
827
1516
  try {
828
- event = await config.provider.constructEvent({
829
- payload,
830
- signature,
831
- secret: config.webhookSecret
1517
+ const result = await wallet.credit({
1518
+ subscriberType: body.subscriberType,
1519
+ subscriberId: body.subscriberId,
1520
+ currency,
1521
+ amount: body.amount,
1522
+ type: "grant",
1523
+ description: body.description ?? "Manual grant",
1524
+ idempotencyKey: body.idempotencyKey
1525
+ });
1526
+ return setApiResponse6(HTTP6.OK, "WALLET_GRANTED", "Credits granted.", {
1527
+ balance: result.balance.toString(),
1528
+ currency,
1529
+ duplicate: result.duplicate
832
1530
  });
833
- } catch {
834
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
1531
+ } catch (err) {
1532
+ if (err instanceof DuplicateTransactionError) {
1533
+ return setApiResponse6(HTTP6.CONFLICT, "DUPLICATE_TRANSACTION", err.message);
1534
+ }
1535
+ throw err;
835
1536
  }
1537
+ }
1538
+ };
1539
+ }
1540
+
1541
+ // src/controllers/webhook.controller.ts
1542
+ import "@fonderie/core";
1543
+
1544
+ // src/controllers/webhook-shared.ts
1545
+ import { setApiResponse as setApiResponse7, HTTP as HTTP7 } from "@fonderie/core";
1546
+ async function readWebhookEvent(ctx, secret, provider, missingSecretMessage) {
1547
+ if (!secret) {
1548
+ return setApiResponse7(HTTP7.SERVER_ERROR, "SERVER_ERROR", missingSecretMessage);
1549
+ }
1550
+ const signature = ctx.request.headers.get("stripe-signature") ?? ctx.request.headers.get("paypal-auth-algo") ?? "";
1551
+ if (!signature) {
1552
+ return setApiResponse7(HTTP7.BAD_REQUEST, "INVALID_REQUEST", "Missing webhook signature");
1553
+ }
1554
+ const payload = await ctx.request.text();
1555
+ try {
1556
+ return await provider.constructEvent({ payload, signature, secret });
1557
+ } catch {
1558
+ return setApiResponse7(HTTP7.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
1559
+ }
1560
+ }
1561
+
1562
+ // src/controllers/webhook.controller.ts
1563
+ function webhookController(store, config, priceCache) {
1564
+ const subscriptions = new SubscriptionModel(store);
1565
+ return {
1566
+ async handle(ctx) {
1567
+ const event = await readWebhookEvent(
1568
+ ctx,
1569
+ config.webhookSecret,
1570
+ config.provider,
1571
+ "Webhook secret not configured"
1572
+ );
1573
+ if (event instanceof Response) return event;
836
1574
  if (priceCache && (event.type.startsWith("price.") || event.type.startsWith("product."))) {
837
1575
  priceCache.invalidate();
838
1576
  }
@@ -857,6 +1595,88 @@ function webhookController(store, config, priceCache) {
857
1595
  };
858
1596
  }
859
1597
 
1598
+ // src/controllers/payment-webhook.controller.ts
1599
+ import { setApiResponse as setApiResponse9, HTTP as HTTP9 } from "@fonderie/core";
1600
+ function paymentWebhookController(store, config) {
1601
+ const wallet = new WalletModel(store);
1602
+ return {
1603
+ async handle(ctx) {
1604
+ const event = await readWebhookEvent(
1605
+ ctx,
1606
+ config.wallet?.webhookSecret,
1607
+ config.provider,
1608
+ "Payment webhook secret not configured \u2014 set wallet.webhookSecret"
1609
+ );
1610
+ if (event instanceof Response) return event;
1611
+ const payment = event.payment;
1612
+ if (!payment) return Response.json({ received: true });
1613
+ const meta = payment.metadata;
1614
+ const packId = meta["packId"];
1615
+ if (!packId) return Response.json({ received: true });
1616
+ const subscriberType = meta["subscriberType"];
1617
+ const subscriberId = meta["subscriberId"];
1618
+ const credits = meta["credits"] ?? "";
1619
+ if (subscriberType !== "user" && subscriberType !== "workspace" || !subscriberId || !/^\d{1,30}$/.test(credits)) {
1620
+ return setApiResponse9(
1621
+ HTTP9.UNPROCESSABLE,
1622
+ "INVALID_PARAMETER",
1623
+ "Malformed wallet checkout metadata"
1624
+ );
1625
+ }
1626
+ const status = payment.paymentStatus ?? null;
1627
+ if (status !== null && status !== "paid" && status !== "no_payment_required") {
1628
+ return Response.json({ received: true, pending: true });
1629
+ }
1630
+ const currency = normalizeCurrency(meta["currency"] ?? config.wallet?.currency ?? "USD");
1631
+ try {
1632
+ const result = await wallet.credit({
1633
+ subscriberType,
1634
+ subscriberId,
1635
+ currency,
1636
+ amount: BigInt(credits),
1637
+ type: "purchase",
1638
+ idempotencyKey: `${config.provider.name}:checkout:${payment.sessionId}`,
1639
+ description: `Credit pack ${packId}`,
1640
+ metadata: {
1641
+ packId,
1642
+ amountPaid: payment.amountTotal?.toString() ?? null,
1643
+ paymentCurrency: payment.currency
1644
+ },
1645
+ ...payment.providerTxId ? { providerTxId: payment.providerTxId } : {}
1646
+ });
1647
+ return Response.json({ received: true, duplicate: result.duplicate });
1648
+ } catch (err) {
1649
+ if (err instanceof DuplicateTransactionError) {
1650
+ return setApiResponse9(HTTP9.CONFLICT, "DUPLICATE_TRANSACTION", err.message);
1651
+ }
1652
+ throw err;
1653
+ }
1654
+ }
1655
+ };
1656
+ }
1657
+
1658
+ // src/middlewares/admin-token.ts
1659
+ import { timingSafeEqual } from "crypto";
1660
+ import { setApiResponse as setApiResponse10, HTTP as HTTP10 } from "@fonderie/core";
1661
+ function safeTokenEqual(a, b) {
1662
+ const bufA = Buffer.from(a);
1663
+ const bufB = Buffer.from(b);
1664
+ if (bufA.length !== bufB.length) return false;
1665
+ return timingSafeEqual(bufA, bufB);
1666
+ }
1667
+ function requireAdminToken(adminToken) {
1668
+ return (ctx, next) => {
1669
+ const header = ctx.request.headers.get("authorization") ?? "";
1670
+ const token = header.startsWith("Bearer ") ? header.slice(7) : "";
1671
+ if (!token || !safeTokenEqual(token, adminToken)) {
1672
+ return Promise.resolve(
1673
+ setApiResponse10(HTTP10.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token")
1674
+ );
1675
+ }
1676
+ return next();
1677
+ };
1678
+ }
1679
+
860
1680
  // src/routes.ts
861
1681
  function buildBillingRoutes(store, config) {
862
1682
  const priceCache = new PriceCache({
@@ -869,7 +1689,7 @@ function buildBillingRoutes(store, config) {
869
1689
  const checkout = checkoutController(store, config);
870
1690
  const usage = usageController(store);
871
1691
  const webhook = webhookController(store, config, priceCache);
872
- return [
1692
+ const routes = [
873
1693
  // Plans — public read-only
874
1694
  ["GET", "/plans", plan.list],
875
1695
  ["GET", "/plans/:planId", plan.get],
@@ -877,8 +1697,10 @@ function buildBillingRoutes(store, config) {
877
1697
  ["POST", "/plans", validate(createPlanSchema), plan.create],
878
1698
  ["PUT", "/plans/:planId", validate(updatePlanSchema), plan.update],
879
1699
  ["DELETE", "/plans/:planId", plan.delete],
880
- // Billing — subscriber resolved from X-Workspace-ID header (workspace) or session (user)
881
- // Workspace membership is verified automatically by the withBilling global middleware
1700
+ // Billing — subscriber resolved from X-Workspace-ID header (workspace) or session (user).
1701
+ // The withBilling global middleware verifies workspace membership against
1702
+ // fonderie_role_user_workspaces (403 for non-members, fail-closed) before
1703
+ // any billing surface acts on a header-derived workspace id.
882
1704
  ["GET", "/billing/subscription", requireAuth, subscription.get],
883
1705
  ["POST", "/billing/checkout", requireAuth, validate(checkoutSchema), checkout.createSession],
884
1706
  ["POST", "/billing/portal", requireAuth, checkout.createPortal],
@@ -887,10 +1709,32 @@ function buildBillingRoutes(store, config) {
887
1709
  // Webhook — signature verified inside the handler
888
1710
  ["POST", "/billing/webhook", webhook.handle]
889
1711
  ];
1712
+ if (config.wallet) {
1713
+ const wallet = walletController(store, config);
1714
+ const paymentWebhook = paymentWebhookController(store, config);
1715
+ routes.push(
1716
+ ["GET", "/billing/wallet", requireAuth, wallet.get],
1717
+ ["GET", "/billing/wallet/transactions", requireAuth, wallet.transactions],
1718
+ ["POST", "/billing/wallet/checkout", requireAuth, validate(walletCheckoutSchema), wallet.checkout],
1719
+ // Payment webhook — separate endpoint and secret from the
1720
+ // subscription webhook; signature verified inside the handler.
1721
+ ["POST", "/billing/webhook/payment", paymentWebhook.handle]
1722
+ );
1723
+ if (config.wallet.adminToken) {
1724
+ routes.push([
1725
+ "POST",
1726
+ "/billing/wallet/grant",
1727
+ requireAdminToken(config.wallet.adminToken),
1728
+ validate(grantWalletSchema),
1729
+ wallet.grant
1730
+ ]);
1731
+ }
1732
+ }
1733
+ return routes;
890
1734
  }
891
1735
 
892
1736
  // src/middlewares/billing.ts
893
- import { setApiResponse as setApiResponse6, HTTP as HTTP6 } from "@fonderie/core";
1737
+ import { setApiResponse as setApiResponse11, HTTP as HTTP11 } from "@fonderie/core";
894
1738
 
895
1739
  // src/config.ts
896
1740
  var MESSAGE_KEYS = {
@@ -899,6 +1743,25 @@ var MESSAGE_KEYS = {
899
1743
  limitBlocked: "billing.limit-blocked"
900
1744
  };
901
1745
 
1746
+ // src/services/membership.ts
1747
+ async function isWorkspaceMember(userId, workspaceId, store) {
1748
+ try {
1749
+ const rows = await store.query(
1750
+ `SELECT 1 AS ok
1751
+ FROM fonderie_role_user_workspaces
1752
+ WHERE user_id = $1
1753
+ AND workspace_id = $2
1754
+ AND removed = false
1755
+ AND suspended = false
1756
+ LIMIT 1`,
1757
+ [userId, workspaceId]
1758
+ );
1759
+ return rows.length > 0;
1760
+ } catch {
1761
+ return false;
1762
+ }
1763
+ }
1764
+
902
1765
  // src/services/policy.ts
903
1766
  function buildBillingContext(opts) {
904
1767
  const { subscriber, plan, active, counters } = opts;
@@ -933,6 +1796,12 @@ function withBilling(store, config, backend) {
933
1796
  return async (ctx, next) => {
934
1797
  const subscriber = resolveSubscriber(ctx);
935
1798
  if (!subscriber) return next();
1799
+ if (subscriber.type === "workspace" && ctx.workspace?.id !== subscriber.id) {
1800
+ if (!ctx.user) return next();
1801
+ if (!await isWorkspaceMember(ctx.user.id, subscriber.id, store)) {
1802
+ return setApiResponse11(HTTP11.FORBIDDEN, "FORBIDDEN", "Not a member of this workspace");
1803
+ }
1804
+ }
936
1805
  const subscription = await getSubscription(subscriber.type, subscriber.id, store);
937
1806
  const planName = subscription?.plan ?? config.plans[0]?.name ?? "free";
938
1807
  const active = !subscription || subscription.status === "active" || subscription.status === "trialing";
@@ -947,10 +1816,40 @@ function withBilling(store, config, backend) {
947
1816
  }
948
1817
  const billingCtx = buildBillingContext({ subscriber, plan, active, counters });
949
1818
  ctx.meta["billing"] = billingCtx;
1819
+ const planWallet = resolvePlanWallet(plan, config);
1820
+ if (planWallet) {
1821
+ try {
1822
+ const sub = {
1823
+ subscriberType: subscriber.type,
1824
+ subscriberId: subscriber.id,
1825
+ currency: planWallet.currency
1826
+ };
1827
+ if (active && planWallet.grantAmount !== null && planWallet.grantAmount > 0n) {
1828
+ await ensurePeriodicGrant(
1829
+ {
1830
+ ...sub,
1831
+ amount: planWallet.grantAmount,
1832
+ period: currentGrantPeriod(planWallet.grantPeriod)
1833
+ },
1834
+ store
1835
+ );
1836
+ }
1837
+ const { balance } = await getWalletBalance(sub, store);
1838
+ billingCtx.wallet = {
1839
+ balance,
1840
+ currency: planWallet.currency,
1841
+ precision: planWallet.precision,
1842
+ overdraftLimit: planWallet.overdraftLimit,
1843
+ rates: planWallet.rates
1844
+ };
1845
+ } catch (err) {
1846
+ console.error("[billing] wallet context failed:", err.message);
1847
+ }
1848
+ }
950
1849
  for (const [key, status] of Object.entries(billingCtx.statuses)) {
951
1850
  if (status.type === "counter" && status.status === "blocked") {
952
- return setApiResponse6(
953
- HTTP6.TOO_MANY_REQUESTS,
1851
+ return setApiResponse11(
1852
+ HTTP11.TOO_MANY_REQUESTS,
954
1853
  "RATE_LIMIT_EXCEEDED",
955
1854
  `Limit exceeded for: ${key}`,
956
1855
  { key, limit: status.limit, used: status.used, resetsAt: status.resetsAt }
@@ -1081,7 +1980,13 @@ var BillingModule = class {
1081
1980
  name = "@fonderie/billing";
1082
1981
  deps = ["@fonderie/auth"];
1083
1982
  async install(app) {
1983
+ if (!this.config.wallet && this.config.plans.some((p) => p.wallet)) {
1984
+ console.warn(
1985
+ "[billing] plans define wallet economics but config.wallet is not set \u2014 wallet features are disabled"
1986
+ );
1987
+ }
1084
1988
  await syncPlansToDB(this.config, this.store);
1989
+ if (this.config.wallet) await syncCreditPacksToDB(this.config, this.store);
1085
1990
  const backend = createBackend(this.config.rateLimit?.backend, this.store);
1086
1991
  app.use(withBilling(this.store, this.config, backend));
1087
1992
  const routes = buildBillingRoutes(this.store, this.config);
@@ -1091,10 +1996,18 @@ var BillingModule = class {
1091
1996
  }
1092
1997
  };
1093
1998
 
1094
- // src/types.ts
1095
- var BILLING_INTERVAL = { MONTH: "month", YEAR: "year" };
1096
-
1097
1999
  // src/providers/stripe.ts
2000
+ function normalizePaymentSession(session) {
2001
+ const pi = session.payment_intent;
2002
+ return {
2003
+ sessionId: session.id,
2004
+ providerTxId: typeof pi === "string" ? pi : pi?.id ?? null,
2005
+ amountTotal: session.amount_total != null ? BigInt(session.amount_total) : null,
2006
+ currency: session.currency ?? null,
2007
+ paymentStatus: session.payment_status ?? null,
2008
+ metadata: session.metadata ?? {}
2009
+ };
2010
+ }
1098
2011
  var _client = null;
1099
2012
  async function getClient(secretKey) {
1100
2013
  if (_client) return _client;
@@ -1106,6 +2019,15 @@ async function getClient(secretKey) {
1106
2019
  _client = new Stripe(secretKey, { apiVersion: "2024-11-20.acacia" });
1107
2020
  return _client;
1108
2021
  }
2022
+ function toBillingInterval(raw) {
2023
+ if (isBillingInterval(raw)) return raw;
2024
+ if (raw !== void 0) {
2025
+ console.warn(
2026
+ `[billing:stripe] unsupported price interval '${raw}' \u2014 recording as '${BILLING_INTERVAL.MONTH}'`
2027
+ );
2028
+ }
2029
+ return BILLING_INTERVAL.MONTH;
2030
+ }
1109
2031
  function normalizeSubscription(sub) {
1110
2032
  const item = sub.items.data[0];
1111
2033
  const periodStart = item?.current_period_start ?? sub.current_period_start;
@@ -1123,16 +2045,16 @@ function normalizeSubscription(sub) {
1123
2045
  currentPeriodEnd: periodEnd ? new Date(periodEnd * 1e3) : /* @__PURE__ */ new Date(),
1124
2046
  cancelAtPeriodEnd: sub.cancel_at_period_end,
1125
2047
  trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1e3) : null,
1126
- interval: item?.price.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH
2048
+ interval: toBillingInterval(item?.price.recurring?.interval)
1127
2049
  };
1128
2050
  }
1129
2051
  function toResolvedPrice(p) {
1130
2052
  return {
1131
2053
  priceId: p.id,
1132
2054
  lookupKey: p.lookup_key ?? null,
1133
- unitAmount: p.unit_amount ?? 0,
2055
+ unitAmount: BigInt(p.unit_amount ?? 0),
1134
2056
  currency: p.currency,
1135
- interval: p.recurring?.interval === BILLING_INTERVAL.YEAR ? BILLING_INTERVAL.YEAR : BILLING_INTERVAL.MONTH,
2057
+ interval: toBillingInterval(p.recurring?.interval),
1136
2058
  nickname: p.nickname ?? null,
1137
2059
  productId: typeof p.product === "string" ? p.product : p.product?.id ?? "",
1138
2060
  active: p.active ?? true
@@ -1179,6 +2101,28 @@ var StripeProvider = class {
1179
2101
  });
1180
2102
  return { url: session.url ?? "" };
1181
2103
  }
2104
+ async createPaymentCheckoutSession(opts) {
2105
+ const stripe = await this.client();
2106
+ const lineItem = opts.priceId ? { price: opts.priceId, quantity: opts.quantity ?? 1 } : {
2107
+ price_data: {
2108
+ currency: opts.currency.toLowerCase(),
2109
+ // Stripe's SDK takes a JS number; toSafeNumber throws past 2^53
2110
+ // instead of silently rounding.
2111
+ unit_amount: toSafeNumber(opts.amount),
2112
+ product_data: { name: opts.name }
2113
+ },
2114
+ quantity: opts.quantity ?? 1
2115
+ };
2116
+ const session = await stripe.checkout.sessions.create({
2117
+ customer: opts.customerId,
2118
+ mode: "payment",
2119
+ line_items: [lineItem],
2120
+ success_url: opts.successUrl,
2121
+ cancel_url: opts.cancelUrl,
2122
+ metadata: opts.metadata
2123
+ });
2124
+ return { url: session.url ?? "", sessionId: session.id };
2125
+ }
1182
2126
  async resolvePriceById(priceId) {
1183
2127
  const stripe = await this.client();
1184
2128
  try {
@@ -1237,6 +2181,13 @@ var StripeProvider = class {
1237
2181
  } catch {
1238
2182
  throw new Error("[billing:stripe] Invalid webhook signature");
1239
2183
  }
2184
+ if (raw.type === "checkout.session.completed" || raw.type === "checkout.session.async_payment_succeeded") {
2185
+ const session = raw.data.object;
2186
+ if (session.mode === "payment") {
2187
+ return { type: raw.type, subscription: null, payment: normalizePaymentSession(session) };
2188
+ }
2189
+ return { type: raw.type, subscription: null };
2190
+ }
1240
2191
  const isSubscriptionEvent = [
1241
2192
  "customer.subscription.created",
1242
2193
  "customer.subscription.updated",
@@ -1257,29 +2208,29 @@ var StripeProvider = class {
1257
2208
  };
1258
2209
 
1259
2210
  // src/middlewares/require-plan.ts
1260
- import { setApiResponse as setApiResponse7, HTTP as HTTP7 } from "@fonderie/core";
2211
+ import { setApiResponse as setApiResponse12, HTTP as HTTP12 } from "@fonderie/core";
1261
2212
  function makeHandler(plans, store) {
1262
2213
  const allowed = Array.isArray(plans) ? plans : [plans];
1263
2214
  return async (ctx, next) => {
1264
2215
  if (!ctx.user) {
1265
- return setApiResponse7(HTTP7.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2216
+ return setApiResponse12(HTTP12.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1266
2217
  }
1267
2218
  const subscriber = resolveSubscriber(ctx);
1268
2219
  if (!subscriber) {
1269
- return setApiResponse7(HTTP7.BAD_REQUEST, "SUBSCRIBER_REQUIRED", "Subscriber context required");
2220
+ return setApiResponse12(HTTP12.BAD_REQUEST, "SUBSCRIBER_REQUIRED", "Subscriber context required");
1270
2221
  }
1271
2222
  const subscription = await getSubscription(subscriber.type, subscriber.id, store);
1272
2223
  if (!subscription || !allowed.includes(subscription.plan)) {
1273
- return setApiResponse7(
1274
- HTTP7.PAYMENT_REQUIRED,
2224
+ return setApiResponse12(
2225
+ HTTP12.PAYMENT_REQUIRED,
1275
2226
  "PLAN_UPGRADE_REQUIRED",
1276
2227
  "Plan upgrade required",
1277
2228
  { required: allowed, current: subscription?.plan ?? "none" }
1278
2229
  );
1279
2230
  }
1280
2231
  if (subscription.status !== "active" && subscription.status !== "trialing") {
1281
- return setApiResponse7(
1282
- HTTP7.PAYMENT_REQUIRED,
2232
+ return setApiResponse12(
2233
+ HTTP12.PAYMENT_REQUIRED,
1283
2234
  "SUBSCRIPTION_INACTIVE",
1284
2235
  "Subscription is not active",
1285
2236
  { status: subscription.status }
@@ -1293,55 +2244,26 @@ function requirePlan(plans, store, ctx, next) {
1293
2244
  if (ctx !== void 0 && next !== void 0) return handler(ctx, next);
1294
2245
  return handler;
1295
2246
  }
1296
-
1297
- // src/helpers.ts
1298
- import { setApiResponse as setApiResponse8, HTTP as HTTP8 } from "@fonderie/core";
1299
- function getBillingContext(ctx) {
1300
- return ctx.meta["billing"] ?? null;
1301
- }
1302
- function hasFeature(ctx, key) {
1303
- const billing = getBillingContext(ctx);
1304
- if (!billing) return true;
1305
- const status = billing.statuses[key];
1306
- if (!status) return true;
1307
- if (status.type === "feature") return status.enabled;
1308
- return true;
1309
- }
1310
- function getPlanLimit(ctx, key) {
1311
- const billing = getBillingContext(ctx);
1312
- if (!billing) return null;
1313
- const status = billing.statuses[key];
1314
- if (!status || status.type === "feature") return null;
1315
- return status.limit;
1316
- }
1317
- function getLimitStatus(ctx, key) {
1318
- const billing = getBillingContext(ctx);
1319
- if (!billing) return null;
1320
- return billing.statuses[key] ?? null;
1321
- }
1322
- function requireFeature(key) {
1323
- return (ctx, next) => {
1324
- if (!hasFeature(ctx, key)) {
1325
- return Promise.resolve(
1326
- setApiResponse8(
1327
- HTTP8.PAYMENT_REQUIRED,
1328
- "FEATURE_UNAVAILABLE",
1329
- `Feature '${key}' is not available on your current plan`
1330
- )
1331
- );
1332
- }
1333
- return next();
1334
- };
1335
- }
1336
2247
  export {
1337
2248
  BILLING_INTERVAL,
2249
+ BILLING_INTERVALS,
1338
2250
  BillingModule,
1339
2251
  DBCounterBackend,
2252
+ DuplicateTransactionError,
2253
+ InsufficientFundsError,
1340
2254
  MESSAGE_KEYS,
1341
2255
  MemoryCounterBackend,
1342
2256
  StripeProvider,
2257
+ WALLET_LEDGER_TYPES,
1343
2258
  createPlan,
2259
+ creditWallet,
2260
+ currentGrantPeriod,
2261
+ debitWallet,
2262
+ debitWalletForMetric,
2263
+ decodeLedgerCursor,
1344
2264
  deletePlan,
2265
+ encodeLedgerCursor,
2266
+ ensurePeriodicGrant,
1345
2267
  getDBPlans,
1346
2268
  getLimitStatus,
1347
2269
  getPlanById,
@@ -1350,14 +2272,23 @@ export {
1350
2272
  getPlans,
1351
2273
  getSubscription,
1352
2274
  getUsage,
2275
+ getWalletBalance,
2276
+ getWalletLedger,
2277
+ getWalletRate,
2278
+ getWalletStatus,
1353
2279
  hasFeature,
2280
+ insufficientCreditsResponse,
2281
+ isBillingInterval,
1354
2282
  recordUsage,
1355
2283
  requireFeature,
1356
2284
  requirePlan,
2285
+ requireWalletBalance,
2286
+ resolvePlanWallet,
1357
2287
  schemas_exports as schemas,
1358
2288
  toPlanDTO,
1359
2289
  toSubscriptionDTO,
1360
- toUsageRecordDTO,
2290
+ toWalletDTO,
2291
+ toWalletTransactionDTO,
1361
2292
  updatePlan,
1362
2293
  withBilling
1363
2294
  };