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