@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/README.md +34 -0
- package/brain/outcomes.md +71 -0
- package/brain/signatures.md +191 -17
- package/dist/{index-Byy5mBE4.d.ts → index-BdNYDuhk.d.ts} +107 -9
- package/dist/{index-DjAGcrSi.d.cts → index-Ca4pXx07.d.cts} +107 -9
- package/dist/index.cjs +1097 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +137 -15
- package/dist/index.d.ts +137 -15
- package/dist/index.js +1076 -145
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +180 -0
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +180 -0
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/006_wallet.sql +85 -0
- package/dist/types.cjs +17 -3
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +34 -7
- package/dist/types.d.ts +34 -7
- package/dist/types.js +13 -2
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
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(
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
414
|
+
function toWalletDTO(balance, currency, precision) {
|
|
415
|
+
return { balance: balance.toString(), currency, precision };
|
|
416
|
+
}
|
|
417
|
+
function toWalletTransactionDTO(entry) {
|
|
322
418
|
return {
|
|
323
|
-
id:
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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"] ??
|
|
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
|
|
685
|
+
if (!isBillingInterval(interval)) {
|
|
613
686
|
return setApiResponse3(
|
|
614
687
|
HTTP3.UNPROCESSABLE,
|
|
615
688
|
"INVALID_PARAMETER",
|
|
616
|
-
|
|
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 =
|
|
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/
|
|
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
|
|
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
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
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
|
|
826
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
953
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2216
|
+
return setApiResponse12(HTTP12.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
|
|
1266
2217
|
}
|
|
1267
2218
|
const subscriber = resolveSubscriber(ctx);
|
|
1268
2219
|
if (!subscriber) {
|
|
1269
|
-
return
|
|
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
|
|
1274
|
-
|
|
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
|
|
1282
|
-
|
|
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
|
-
|
|
2290
|
+
toWalletDTO,
|
|
2291
|
+
toWalletTransactionDTO,
|
|
1361
2292
|
updatePlan,
|
|
1362
2293
|
withBilling
|
|
1363
2294
|
};
|