@fonderie/billing 1.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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index-CL94UDoW.d.ts +107 -0
- package/dist/index-CRFRDPsJ.d.cts +107 -0
- package/dist/index.cjs +1170 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +144 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +1120 -0
- package/dist/index.js.map +1 -0
- package/dist/middlewares/index.cjs +254 -0
- package/dist/middlewares/index.cjs.map +1 -0
- package/dist/middlewares/index.d.cts +4 -0
- package/dist/middlewares/index.d.ts +4 -0
- package/dist/middlewares/index.js +226 -0
- package/dist/middlewares/index.js.map +1 -0
- package/dist/migrations/index.js +7 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/sql/001_billing.sql +38 -0
- package/dist/migrations/sql/002_billing_plan_fields.sql +5 -0
- package/dist/migrations/sql/003_drop_limits.sql +1 -0
- package/dist/migrations/sql/004_polymorphic_subscribers.sql +49 -0
- package/dist/migrations/sql/005_billing_notifications.sql +13 -0
- package/dist/types.cjs +19 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.cts +76 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +86 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
BillingModule: () => BillingModule,
|
|
24
|
+
DBCounterBackend: () => DBCounterBackend,
|
|
25
|
+
MESSAGE_KEYS: () => MESSAGE_KEYS,
|
|
26
|
+
MemoryCounterBackend: () => MemoryCounterBackend,
|
|
27
|
+
StripeProvider: () => StripeProvider,
|
|
28
|
+
createPlan: () => createPlan,
|
|
29
|
+
deletePlan: () => deletePlan,
|
|
30
|
+
getDBPlans: () => getDBPlans,
|
|
31
|
+
getLimitStatus: () => getLimitStatus,
|
|
32
|
+
getPlanById: () => getPlanById,
|
|
33
|
+
getPlanByName: () => getPlanByName,
|
|
34
|
+
getPlanLimit: () => getPlanLimit,
|
|
35
|
+
getPlans: () => getPlans,
|
|
36
|
+
getSubscription: () => getSubscription,
|
|
37
|
+
getUsage: () => getUsage,
|
|
38
|
+
hasFeature: () => hasFeature,
|
|
39
|
+
recordUsage: () => recordUsage,
|
|
40
|
+
requireFeature: () => requireFeature,
|
|
41
|
+
requirePlan: () => requirePlan,
|
|
42
|
+
toPlanDTO: () => toPlanDTO,
|
|
43
|
+
toSubscriptionDTO: () => toSubscriptionDTO,
|
|
44
|
+
toUsageRecordDTO: () => toUsageRecordDTO,
|
|
45
|
+
updatePlan: () => updatePlan,
|
|
46
|
+
withBilling: () => withBilling
|
|
47
|
+
});
|
|
48
|
+
module.exports = __toCommonJS(index_exports);
|
|
49
|
+
|
|
50
|
+
// src/routes.ts
|
|
51
|
+
var import_middlewares = require("@fonderie/core/middlewares");
|
|
52
|
+
|
|
53
|
+
// src/controllers/plan.controller.ts
|
|
54
|
+
var import_core = require("@fonderie/core");
|
|
55
|
+
|
|
56
|
+
// src/services/plans.ts
|
|
57
|
+
function getPlans(config) {
|
|
58
|
+
return config.plans;
|
|
59
|
+
}
|
|
60
|
+
function getPlanByName(name, config) {
|
|
61
|
+
return config.plans.find((p) => p.name.toLowerCase() === name.toLowerCase()) ?? null;
|
|
62
|
+
}
|
|
63
|
+
async function syncPlansToDB(config, store) {
|
|
64
|
+
const plans = config.plans;
|
|
65
|
+
if (plans.length === 0) return;
|
|
66
|
+
const values = plans.map((_, i) => {
|
|
67
|
+
const b = i * 9;
|
|
68
|
+
return `($${b + 1}, $${b + 2}, $${b + 3}, $${b + 4}, $${b + 5}, $${b + 6}, $${b + 7}, $${b + 8}, $${b + 9}::jsonb)`;
|
|
69
|
+
});
|
|
70
|
+
const params = plans.flatMap((plan) => [
|
|
71
|
+
plan.name,
|
|
72
|
+
plan.trialDays ?? 0,
|
|
73
|
+
plan.monthly?.amount ?? null,
|
|
74
|
+
plan.monthly?.priceId ?? null,
|
|
75
|
+
plan.yearly?.amount ?? null,
|
|
76
|
+
plan.yearly?.priceId ?? null,
|
|
77
|
+
plan.description ?? null,
|
|
78
|
+
plan.tier ?? 0,
|
|
79
|
+
JSON.stringify(plan.metadata ?? {})
|
|
80
|
+
]);
|
|
81
|
+
await store.query(
|
|
82
|
+
`INSERT INTO fonderie_plans
|
|
83
|
+
(name, trial_days,
|
|
84
|
+
monthly_amount, monthly_price_id,
|
|
85
|
+
yearly_amount, yearly_price_id,
|
|
86
|
+
description, tier, metadata)
|
|
87
|
+
VALUES ${values.join(", ")}
|
|
88
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
89
|
+
trial_days = EXCLUDED.trial_days,
|
|
90
|
+
monthly_amount = EXCLUDED.monthly_amount,
|
|
91
|
+
monthly_price_id = EXCLUDED.monthly_price_id,
|
|
92
|
+
yearly_amount = EXCLUDED.yearly_amount,
|
|
93
|
+
yearly_price_id = EXCLUDED.yearly_price_id,
|
|
94
|
+
description = EXCLUDED.description,
|
|
95
|
+
tier = EXCLUDED.tier,
|
|
96
|
+
metadata = EXCLUDED.metadata`,
|
|
97
|
+
params
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
var SELECT_PLAN = `
|
|
101
|
+
SELECT
|
|
102
|
+
id,
|
|
103
|
+
name,
|
|
104
|
+
seats,
|
|
105
|
+
trial_days AS "trialDays",
|
|
106
|
+
monthly_amount AS "monthlyAmount",
|
|
107
|
+
monthly_price_id AS "monthlyPriceId",
|
|
108
|
+
yearly_amount AS "yearlyAmount",
|
|
109
|
+
yearly_price_id AS "yearlyPriceId",
|
|
110
|
+
description,
|
|
111
|
+
tier,
|
|
112
|
+
features,
|
|
113
|
+
metadata
|
|
114
|
+
FROM fonderie_plans`;
|
|
115
|
+
async function getDBPlans(store) {
|
|
116
|
+
return store.query(
|
|
117
|
+
`${SELECT_PLAN} WHERE active = true ORDER BY tier ASC, monthly_amount ASC NULLS LAST`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
async function getPlanById(id, store) {
|
|
121
|
+
const [row] = await store.query(`${SELECT_PLAN} WHERE id = $1`, [id]);
|
|
122
|
+
return row ?? null;
|
|
123
|
+
}
|
|
124
|
+
async function createPlan(data, store) {
|
|
125
|
+
const [row] = await store.query(
|
|
126
|
+
`INSERT INTO fonderie_plans
|
|
127
|
+
(name, seats, trial_days, monthly_amount, monthly_price_id,
|
|
128
|
+
yearly_amount, yearly_price_id, description, tier, features, metadata)
|
|
129
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
|
|
130
|
+
RETURNING
|
|
131
|
+
id, name, seats,
|
|
132
|
+
trial_days AS "trialDays",
|
|
133
|
+
monthly_amount AS "monthlyAmount",
|
|
134
|
+
monthly_price_id AS "monthlyPriceId",
|
|
135
|
+
yearly_amount AS "yearlyAmount",
|
|
136
|
+
yearly_price_id AS "yearlyPriceId",
|
|
137
|
+
description, tier, features, metadata`,
|
|
138
|
+
[
|
|
139
|
+
data.name,
|
|
140
|
+
data.seats ?? null,
|
|
141
|
+
data.trialDays ?? 0,
|
|
142
|
+
data.monthlyAmount ?? null,
|
|
143
|
+
data.monthlyPriceId ?? null,
|
|
144
|
+
data.yearlyAmount ?? null,
|
|
145
|
+
data.yearlyPriceId ?? null,
|
|
146
|
+
data.description ?? null,
|
|
147
|
+
data.tier ?? 0,
|
|
148
|
+
JSON.stringify(data.features ?? []),
|
|
149
|
+
JSON.stringify(data.metadata ?? {})
|
|
150
|
+
]
|
|
151
|
+
);
|
|
152
|
+
if (!row) throw new Error("Failed to create plan");
|
|
153
|
+
return row;
|
|
154
|
+
}
|
|
155
|
+
async function updatePlan(id, data, store) {
|
|
156
|
+
const fieldMap = {
|
|
157
|
+
name: "name",
|
|
158
|
+
seats: "seats",
|
|
159
|
+
trialDays: "trial_days",
|
|
160
|
+
monthlyAmount: "monthly_amount",
|
|
161
|
+
monthlyPriceId: "monthly_price_id",
|
|
162
|
+
yearlyAmount: "yearly_amount",
|
|
163
|
+
yearlyPriceId: "yearly_price_id",
|
|
164
|
+
description: "description",
|
|
165
|
+
tier: "tier"
|
|
166
|
+
};
|
|
167
|
+
const jsonbFields = /* @__PURE__ */ new Set(["features", "metadata"]);
|
|
168
|
+
const setClauses = [];
|
|
169
|
+
const params = [id];
|
|
170
|
+
for (const [key, col] of Object.entries(fieldMap)) {
|
|
171
|
+
if (key in data) {
|
|
172
|
+
params.push(data[key]);
|
|
173
|
+
setClauses.push(`${col} = $${params.length}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const key of jsonbFields) {
|
|
177
|
+
if (key in data) {
|
|
178
|
+
params.push(JSON.stringify(data[key]));
|
|
179
|
+
setClauses.push(`${key} = $${params.length}::jsonb`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (setClauses.length === 0) return getPlanById(id, store);
|
|
183
|
+
const [row] = await store.query(
|
|
184
|
+
`UPDATE fonderie_plans SET ${setClauses.join(", ")}
|
|
185
|
+
WHERE id = $1
|
|
186
|
+
RETURNING
|
|
187
|
+
id, name, seats,
|
|
188
|
+
trial_days AS "trialDays",
|
|
189
|
+
monthly_amount AS "monthlyAmount",
|
|
190
|
+
monthly_price_id AS "monthlyPriceId",
|
|
191
|
+
yearly_amount AS "yearlyAmount",
|
|
192
|
+
yearly_price_id AS "yearlyPriceId",
|
|
193
|
+
description, tier, features, metadata`,
|
|
194
|
+
params
|
|
195
|
+
);
|
|
196
|
+
return row ?? null;
|
|
197
|
+
}
|
|
198
|
+
async function deletePlan(id, store) {
|
|
199
|
+
const rows = await store.query(
|
|
200
|
+
`DELETE FROM fonderie_plans WHERE id = $1 RETURNING id`,
|
|
201
|
+
[id]
|
|
202
|
+
);
|
|
203
|
+
return rows.length > 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/models/plan.model.ts
|
|
207
|
+
var PlanModel = class {
|
|
208
|
+
constructor(store) {
|
|
209
|
+
this.store = store;
|
|
210
|
+
}
|
|
211
|
+
store;
|
|
212
|
+
listFromConfig(config) {
|
|
213
|
+
return getPlans(config);
|
|
214
|
+
}
|
|
215
|
+
findByNameInConfig(name, config) {
|
|
216
|
+
return getPlanByName(name, config);
|
|
217
|
+
}
|
|
218
|
+
list() {
|
|
219
|
+
return getDBPlans(this.store);
|
|
220
|
+
}
|
|
221
|
+
findById(id) {
|
|
222
|
+
return getPlanById(id, this.store);
|
|
223
|
+
}
|
|
224
|
+
create(data) {
|
|
225
|
+
return createPlan(data, this.store);
|
|
226
|
+
}
|
|
227
|
+
update(id, data) {
|
|
228
|
+
return updatePlan(id, data, this.store);
|
|
229
|
+
}
|
|
230
|
+
delete(id) {
|
|
231
|
+
return deletePlan(id, this.store);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// src/dtos/billing.ts
|
|
236
|
+
function toPlanDTO(plan) {
|
|
237
|
+
return {
|
|
238
|
+
id: plan.id,
|
|
239
|
+
planId: plan.name.toUpperCase(),
|
|
240
|
+
name: plan.name,
|
|
241
|
+
description: plan.description ?? "",
|
|
242
|
+
tier: plan.tier,
|
|
243
|
+
seats: plan.seats,
|
|
244
|
+
trialDays: plan.trialDays,
|
|
245
|
+
pricing: {
|
|
246
|
+
monthly: plan.monthlyAmount ?? 0,
|
|
247
|
+
yearly: plan.yearlyAmount ?? 0,
|
|
248
|
+
currency: "USD"
|
|
249
|
+
},
|
|
250
|
+
features: Array.isArray(plan.features) ? plan.features : [],
|
|
251
|
+
metadata: plan.metadata && typeof plan.metadata === "object" ? plan.metadata : {}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function toSubscriptionDTO(sub) {
|
|
255
|
+
return {
|
|
256
|
+
id: sub.id,
|
|
257
|
+
subscriberType: sub.subscriberType,
|
|
258
|
+
subscriberId: sub.subscriberId,
|
|
259
|
+
plan: sub.plan,
|
|
260
|
+
interval: sub.interval,
|
|
261
|
+
status: sub.status,
|
|
262
|
+
cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
|
|
263
|
+
currentPeriodStart: sub.currentPeriodStart,
|
|
264
|
+
currentPeriodEnd: sub.currentPeriodEnd,
|
|
265
|
+
trialEndsAt: sub.trialEndsAt,
|
|
266
|
+
createdAt: sub.createdAt
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function toUsageRecordDTO(record) {
|
|
270
|
+
return {
|
|
271
|
+
id: record.id,
|
|
272
|
+
subscriberType: record.subscriberType,
|
|
273
|
+
subscriberId: record.subscriberId,
|
|
274
|
+
metric: record.metric,
|
|
275
|
+
quantity: record.quantity,
|
|
276
|
+
recordedAt: record.recordedAt
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/controllers/plan.controller.ts
|
|
281
|
+
function planController(store) {
|
|
282
|
+
const plans = new PlanModel(store);
|
|
283
|
+
return {
|
|
284
|
+
async list(_ctx) {
|
|
285
|
+
const list = await plans.list();
|
|
286
|
+
const dtos = list.map(toPlanDTO);
|
|
287
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.OK, "PLAN_LIST", `Retrieved ${list.length} workspace plans`, {
|
|
288
|
+
plans: dtos
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
async get(ctx) {
|
|
292
|
+
const params = ctx.meta["params"];
|
|
293
|
+
const id = params?.["planId"];
|
|
294
|
+
if (!id) return (0, import_core.setApiResponse)(import_core.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Plan ID required");
|
|
295
|
+
const plan = await plans.findById(id);
|
|
296
|
+
if (!plan) return (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "NOT_FOUND", "Plan not found");
|
|
297
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.OK, "PLAN_FETCHED", "Plan retrieved successfully.", {
|
|
298
|
+
plan: toPlanDTO(plan)
|
|
299
|
+
});
|
|
300
|
+
},
|
|
301
|
+
async create(ctx) {
|
|
302
|
+
const body = ctx.meta["body"];
|
|
303
|
+
const name = (0, import_core.stringOrEmpty)(body?.["name"]);
|
|
304
|
+
if (!name) {
|
|
305
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "VALIDATION_ERROR", "name is required");
|
|
306
|
+
}
|
|
307
|
+
const plan = await plans.create({
|
|
308
|
+
name,
|
|
309
|
+
description: body?.["description"] != null ? String(body["description"]) : null,
|
|
310
|
+
tier: body?.["tier"] != null ? (0, import_core.numberOrZero)(body["tier"]) : 0,
|
|
311
|
+
seats: body?.["seats"] != null ? (0, import_core.numberOrZero)(body["seats"]) : null,
|
|
312
|
+
trialDays: body?.["trialDays"] != null ? (0, import_core.numberOrZero)(body["trialDays"]) : 0,
|
|
313
|
+
monthlyAmount: body?.["monthlyAmount"] != null ? (0, import_core.numberOrZero)(body["monthlyAmount"]) : null,
|
|
314
|
+
monthlyPriceId: body?.["monthlyPriceId"] != null ? String(body["monthlyPriceId"]) : null,
|
|
315
|
+
yearlyAmount: body?.["yearlyAmount"] != null ? (0, import_core.numberOrZero)(body["yearlyAmount"]) : null,
|
|
316
|
+
yearlyPriceId: body?.["yearlyPriceId"] != null ? String(body["yearlyPriceId"]) : null,
|
|
317
|
+
features: body?.["features"],
|
|
318
|
+
metadata: body?.["metadata"]
|
|
319
|
+
});
|
|
320
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.CREATED, "PLAN_CREATED", "Plan created successfully.", {
|
|
321
|
+
plan: toPlanDTO(plan)
|
|
322
|
+
});
|
|
323
|
+
},
|
|
324
|
+
async update(ctx) {
|
|
325
|
+
const params = ctx.meta["params"];
|
|
326
|
+
const id = params?.["planId"];
|
|
327
|
+
if (!id) {
|
|
328
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Plan ID required");
|
|
329
|
+
}
|
|
330
|
+
const body = ctx.meta["body"];
|
|
331
|
+
if (!body || Object.keys(body).length === 0) {
|
|
332
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "VALIDATION_ERROR", "Request body is empty");
|
|
333
|
+
}
|
|
334
|
+
const patch = {};
|
|
335
|
+
const allowed = [
|
|
336
|
+
"name",
|
|
337
|
+
"description",
|
|
338
|
+
"tier",
|
|
339
|
+
"seats",
|
|
340
|
+
"trialDays",
|
|
341
|
+
"monthlyAmount",
|
|
342
|
+
"monthlyPriceId",
|
|
343
|
+
"yearlyAmount",
|
|
344
|
+
"yearlyPriceId",
|
|
345
|
+
"features",
|
|
346
|
+
"metadata"
|
|
347
|
+
];
|
|
348
|
+
for (const key of allowed) {
|
|
349
|
+
if (key in body) patch[key] = body[key];
|
|
350
|
+
}
|
|
351
|
+
const plan = await plans.update(id, patch);
|
|
352
|
+
if (!plan) {
|
|
353
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "NOT_FOUND", "Plan not found");
|
|
354
|
+
}
|
|
355
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.OK, "PLAN_UPDATED", "Plan updated successfully.", {
|
|
356
|
+
plan: toPlanDTO(plan)
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
async delete(ctx) {
|
|
360
|
+
const params = ctx.meta["params"];
|
|
361
|
+
const id = params?.["planId"];
|
|
362
|
+
if (!id) {
|
|
363
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Plan ID required");
|
|
364
|
+
}
|
|
365
|
+
const deleted = await plans.delete(id);
|
|
366
|
+
if (!deleted) {
|
|
367
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.NOT_FOUND, "NOT_FOUND", "Plan not found");
|
|
368
|
+
}
|
|
369
|
+
return (0, import_core.setApiResponse)(import_core.HTTP.OK, "PLAN_DELETED", "Plan deleted successfully.");
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/controllers/subscription.controller.ts
|
|
375
|
+
var import_core2 = require("@fonderie/core");
|
|
376
|
+
|
|
377
|
+
// src/services/subscriptions.ts
|
|
378
|
+
var SELECT_SUBSCRIPTION = `
|
|
379
|
+
SELECT
|
|
380
|
+
id,
|
|
381
|
+
subscriber_type AS "subscriberType",
|
|
382
|
+
subscriber_id AS "subscriberId",
|
|
383
|
+
plan,
|
|
384
|
+
interval,
|
|
385
|
+
status,
|
|
386
|
+
provider_customer_id AS "providerCustomerId",
|
|
387
|
+
provider_subscription_id AS "providerSubscriptionId",
|
|
388
|
+
current_period_start AS "currentPeriodStart",
|
|
389
|
+
current_period_end AS "currentPeriodEnd",
|
|
390
|
+
cancel_at_period_end AS "cancelAtPeriodEnd",
|
|
391
|
+
trial_ends_at AS "trialEndsAt",
|
|
392
|
+
created_at AS "createdAt"
|
|
393
|
+
FROM fonderie_subscriptions`;
|
|
394
|
+
async function getSubscription(subscriberType, subscriberId, store) {
|
|
395
|
+
const [row] = await store.query(
|
|
396
|
+
`${SELECT_SUBSCRIPTION} WHERE subscriber_type = $1 AND subscriber_id = $2`,
|
|
397
|
+
[subscriberType, subscriberId]
|
|
398
|
+
);
|
|
399
|
+
return row ?? null;
|
|
400
|
+
}
|
|
401
|
+
async function upsertSubscription(data, store) {
|
|
402
|
+
await store.query(
|
|
403
|
+
`INSERT INTO fonderie_subscriptions
|
|
404
|
+
(subscriber_type, subscriber_id, plan, interval, status,
|
|
405
|
+
provider_customer_id, provider_subscription_id,
|
|
406
|
+
current_period_start, current_period_end,
|
|
407
|
+
cancel_at_period_end, trial_ends_at)
|
|
408
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
409
|
+
ON CONFLICT (subscriber_type, subscriber_id) DO UPDATE SET
|
|
410
|
+
plan = $3,
|
|
411
|
+
interval = $4,
|
|
412
|
+
status = $5,
|
|
413
|
+
provider_customer_id = COALESCE($6, fonderie_subscriptions.provider_customer_id),
|
|
414
|
+
provider_subscription_id = COALESCE($7, fonderie_subscriptions.provider_subscription_id),
|
|
415
|
+
current_period_start = $8,
|
|
416
|
+
current_period_end = $9,
|
|
417
|
+
cancel_at_period_end = $10,
|
|
418
|
+
trial_ends_at = $11`,
|
|
419
|
+
[
|
|
420
|
+
data.subscriberType,
|
|
421
|
+
data.subscriberId,
|
|
422
|
+
data.plan,
|
|
423
|
+
data.interval ?? "month",
|
|
424
|
+
data.status,
|
|
425
|
+
data.providerCustomerId ?? null,
|
|
426
|
+
data.providerSubscriptionId ?? null,
|
|
427
|
+
data.currentPeriodStart ?? null,
|
|
428
|
+
data.currentPeriodEnd ?? null,
|
|
429
|
+
data.cancelAtPeriodEnd ?? false,
|
|
430
|
+
data.trialEndsAt ?? null
|
|
431
|
+
]
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/models/subscription.model.ts
|
|
436
|
+
var SubscriptionModel = class {
|
|
437
|
+
constructor(store) {
|
|
438
|
+
this.store = store;
|
|
439
|
+
}
|
|
440
|
+
store;
|
|
441
|
+
get(subscriberType, subscriberId) {
|
|
442
|
+
return getSubscription(subscriberType, subscriberId, this.store);
|
|
443
|
+
}
|
|
444
|
+
upsert(data) {
|
|
445
|
+
return upsertSubscription(data, this.store);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// src/utils.ts
|
|
450
|
+
function parseWindowMs(window) {
|
|
451
|
+
const n = parseInt(window, 10);
|
|
452
|
+
const unit = window.slice(String(n).length);
|
|
453
|
+
switch (unit) {
|
|
454
|
+
case "h":
|
|
455
|
+
return n * 36e5;
|
|
456
|
+
case "d":
|
|
457
|
+
return n * 864e5;
|
|
458
|
+
case "m":
|
|
459
|
+
return n * 6e4;
|
|
460
|
+
default:
|
|
461
|
+
throw new Error(`Unknown window unit: '${unit}' in '${window}'`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function resolveSubscriber(ctx) {
|
|
465
|
+
const wsFromHeader = ctx.request.headers.get("x-workspace-id");
|
|
466
|
+
if (wsFromHeader) {
|
|
467
|
+
return {
|
|
468
|
+
type: "workspace",
|
|
469
|
+
id: wsFromHeader
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
if (ctx.workspace?.id) {
|
|
473
|
+
return {
|
|
474
|
+
type: "workspace",
|
|
475
|
+
id: ctx.workspace.id
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (ctx.user?.id) {
|
|
479
|
+
return {
|
|
480
|
+
type: "user",
|
|
481
|
+
id: ctx.user.id
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/controllers/subscription.controller.ts
|
|
488
|
+
function subscriptionController(store) {
|
|
489
|
+
const subscriptions = new SubscriptionModel(store);
|
|
490
|
+
return {
|
|
491
|
+
async get(ctx) {
|
|
492
|
+
const subscriber = resolveSubscriber(ctx);
|
|
493
|
+
if (!subscriber) {
|
|
494
|
+
return (0, import_core2.setApiResponse)(
|
|
495
|
+
import_core2.HTTP.BAD_REQUEST,
|
|
496
|
+
"SUBSCRIBER_REQUIRED",
|
|
497
|
+
"Subscriber context required"
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const subscription = await subscriptions.get(subscriber.type, subscriber.id);
|
|
501
|
+
if (!subscription)
|
|
502
|
+
return (0, import_core2.setApiResponse)(import_core2.HTTP.NOT_FOUND, "NOT_FOUND", "No active subscription");
|
|
503
|
+
return (0, import_core2.setApiResponse)(
|
|
504
|
+
import_core2.HTTP.OK,
|
|
505
|
+
"SUBSCRIPTION_FETCHED",
|
|
506
|
+
"Subscription retrieved successfully.",
|
|
507
|
+
{
|
|
508
|
+
subscription: toSubscriptionDTO(subscription)
|
|
509
|
+
}
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/controllers/checkout.controller.ts
|
|
516
|
+
var import_core3 = require("@fonderie/core");
|
|
517
|
+
function checkoutController(store, config) {
|
|
518
|
+
const plans = new PlanModel(store);
|
|
519
|
+
const subscriptions = new SubscriptionModel(store);
|
|
520
|
+
return {
|
|
521
|
+
async createSession(ctx) {
|
|
522
|
+
const body = ctx.meta["body"];
|
|
523
|
+
const planName = body?.["plan"];
|
|
524
|
+
const interval = body?.["interval"] ?? "month";
|
|
525
|
+
const subscriber = resolveSubscriber(ctx);
|
|
526
|
+
if (typeof planName !== "string") {
|
|
527
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "plan is required");
|
|
528
|
+
}
|
|
529
|
+
if (interval !== "month" && interval !== "year") {
|
|
530
|
+
return (0, import_core3.setApiResponse)(
|
|
531
|
+
import_core3.HTTP.UNPROCESSABLE,
|
|
532
|
+
"INVALID_PARAMETER",
|
|
533
|
+
"interval must be month or year"
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (!subscriber) {
|
|
537
|
+
return (0, import_core3.setApiResponse)(
|
|
538
|
+
import_core3.HTTP.BAD_REQUEST,
|
|
539
|
+
"SUBSCRIBER_REQUIRED",
|
|
540
|
+
"Subscriber context required"
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
const plan = plans.findByNameInConfig(planName, config);
|
|
544
|
+
if (!plan) {
|
|
545
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", `Unknown plan: ${planName}`);
|
|
546
|
+
}
|
|
547
|
+
const pricing = interval === "year" ? plan.yearly : plan.monthly;
|
|
548
|
+
if (!pricing?.priceId) {
|
|
549
|
+
return (0, import_core3.setApiResponse)(
|
|
550
|
+
import_core3.HTTP.UNPROCESSABLE,
|
|
551
|
+
"INVALID_PARAMETER",
|
|
552
|
+
`Plan ${planName} does not support ${interval} billing`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
const { customerId } = await config.provider.createCustomer({
|
|
556
|
+
email: ctx.user.email ?? "",
|
|
557
|
+
subscriberType: subscriber.type,
|
|
558
|
+
subscriberId: subscriber.id,
|
|
559
|
+
userId: ctx.user.id
|
|
560
|
+
});
|
|
561
|
+
const sessionOpts = {
|
|
562
|
+
customerId,
|
|
563
|
+
priceId: pricing.priceId,
|
|
564
|
+
subscriberType: subscriber.type,
|
|
565
|
+
subscriberId: subscriber.id,
|
|
566
|
+
successUrl: config.successUrl,
|
|
567
|
+
cancelUrl: config.cancelUrl
|
|
568
|
+
};
|
|
569
|
+
if (plan.trialDays !== void 0) sessionOpts.trialDays = plan.trialDays;
|
|
570
|
+
const { url } = await config.provider.createCheckoutSession(sessionOpts);
|
|
571
|
+
await subscriptions.upsert({
|
|
572
|
+
subscriberType: subscriber.type,
|
|
573
|
+
subscriberId: subscriber.id,
|
|
574
|
+
plan: planName,
|
|
575
|
+
interval,
|
|
576
|
+
status: "incomplete",
|
|
577
|
+
providerCustomerId: customerId
|
|
578
|
+
});
|
|
579
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "CHECKOUT_URL", "Checkout session created.", { url });
|
|
580
|
+
},
|
|
581
|
+
async createPortal(ctx) {
|
|
582
|
+
const subscriber = resolveSubscriber(ctx);
|
|
583
|
+
if (!subscriber) {
|
|
584
|
+
return (0, import_core3.setApiResponse)(
|
|
585
|
+
import_core3.HTTP.BAD_REQUEST,
|
|
586
|
+
"SUBSCRIBER_REQUIRED",
|
|
587
|
+
"Subscriber context required"
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const subscription = await subscriptions.get(subscriber.type, subscriber.id);
|
|
591
|
+
if (!subscription?.providerCustomerId) {
|
|
592
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.NOT_FOUND, "NOT_FOUND", "No active subscription");
|
|
593
|
+
}
|
|
594
|
+
const { url } = await config.provider.createPortalSession({
|
|
595
|
+
customerId: subscription.providerCustomerId,
|
|
596
|
+
returnUrl: config.successUrl
|
|
597
|
+
});
|
|
598
|
+
return (0, import_core3.setApiResponse)(import_core3.HTTP.OK, "PORTAL_URL", "Portal session created.", { url });
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/controllers/usage.controller.ts
|
|
604
|
+
var import_core4 = require("@fonderie/core");
|
|
605
|
+
|
|
606
|
+
// src/services/usage.ts
|
|
607
|
+
async function recordUsage(opts, store) {
|
|
608
|
+
await store.query(
|
|
609
|
+
`INSERT INTO fonderie_usage_records (subscriber_type, subscriber_id, metric, quantity)
|
|
610
|
+
VALUES ($1, $2, $3, $4)`,
|
|
611
|
+
[opts.subscriberType, opts.subscriberId, opts.metric, opts.quantity]
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
async function getUsage(subscriberType, subscriberId, metric, since, store) {
|
|
615
|
+
const rows = await store.query(
|
|
616
|
+
`SELECT COALESCE(SUM(quantity), 0) AS total
|
|
617
|
+
FROM fonderie_usage_records
|
|
618
|
+
WHERE subscriber_type = $1
|
|
619
|
+
AND subscriber_id = $2
|
|
620
|
+
AND metric = $3
|
|
621
|
+
AND recorded_at >= $4`,
|
|
622
|
+
[subscriberType, subscriberId, metric, since]
|
|
623
|
+
);
|
|
624
|
+
return parseInt(rows[0]?.total ?? "0", 10);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/models/usage.model.ts
|
|
628
|
+
var UsageModel = class {
|
|
629
|
+
constructor(store) {
|
|
630
|
+
this.store = store;
|
|
631
|
+
}
|
|
632
|
+
store;
|
|
633
|
+
record(opts) {
|
|
634
|
+
return recordUsage(opts, this.store);
|
|
635
|
+
}
|
|
636
|
+
get(subscriberType, subscriberId, metric, since) {
|
|
637
|
+
return getUsage(subscriberType, subscriberId, metric, since, this.store);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// src/controllers/usage.controller.ts
|
|
642
|
+
function usageController(store) {
|
|
643
|
+
const usage = new UsageModel(store);
|
|
644
|
+
return {
|
|
645
|
+
async record(ctx) {
|
|
646
|
+
const body = ctx.meta["body"];
|
|
647
|
+
const metric = body?.["metric"];
|
|
648
|
+
const quantity = body?.["quantity"];
|
|
649
|
+
const subscriber = resolveSubscriber(ctx);
|
|
650
|
+
if (!subscriber) {
|
|
651
|
+
return (0, import_core4.setApiResponse)(
|
|
652
|
+
import_core4.HTTP.BAD_REQUEST,
|
|
653
|
+
"SUBSCRIBER_REQUIRED",
|
|
654
|
+
"Subscriber context required"
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
if (typeof metric !== "string") {
|
|
658
|
+
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "metric is required");
|
|
659
|
+
}
|
|
660
|
+
await usage.record({
|
|
661
|
+
subscriberType: subscriber.type,
|
|
662
|
+
subscriberId: subscriber.id,
|
|
663
|
+
metric,
|
|
664
|
+
quantity: typeof quantity === "number" ? quantity : 1
|
|
665
|
+
});
|
|
666
|
+
return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "USAGE_RECORDED", "Usage recorded successfully.");
|
|
667
|
+
},
|
|
668
|
+
async get(ctx) {
|
|
669
|
+
const params = ctx.meta["params"];
|
|
670
|
+
const metric = params?.["metric"];
|
|
671
|
+
const subscriber = resolveSubscriber(ctx);
|
|
672
|
+
if (!subscriber || !metric) {
|
|
673
|
+
return (0, import_core4.setApiResponse)(
|
|
674
|
+
import_core4.HTTP.UNPROCESSABLE,
|
|
675
|
+
"INVALID_PARAMETER",
|
|
676
|
+
"subscriber and metric are required"
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
const since = /* @__PURE__ */ new Date();
|
|
680
|
+
since.setDate(1);
|
|
681
|
+
since.setHours(0, 0, 0, 0);
|
|
682
|
+
const total = await usage.get(subscriber.type, subscriber.id, metric, since);
|
|
683
|
+
return (0, import_core4.setApiResponse)(import_core4.HTTP.OK, "USAGE_FETCHED", "Usage retrieved successfully.", {
|
|
684
|
+
metric,
|
|
685
|
+
total,
|
|
686
|
+
since
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/controllers/webhook.controller.ts
|
|
693
|
+
var import_core5 = require("@fonderie/core");
|
|
694
|
+
function webhookController(store, config) {
|
|
695
|
+
const subscriptions = new SubscriptionModel(store);
|
|
696
|
+
return {
|
|
697
|
+
async handle(ctx) {
|
|
698
|
+
if (!config.webhookSecret) {
|
|
699
|
+
return (0, import_core5.setApiResponse)(import_core5.HTTP.SERVER_ERROR, "SERVER_ERROR", "Webhook secret not configured");
|
|
700
|
+
}
|
|
701
|
+
const signature = ctx.request.headers.get("stripe-signature") ?? ctx.request.headers.get("paypal-auth-algo") ?? "";
|
|
702
|
+
if (!signature) {
|
|
703
|
+
return (0, import_core5.setApiResponse)(import_core5.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Missing webhook signature");
|
|
704
|
+
}
|
|
705
|
+
const payload = await ctx.request.text();
|
|
706
|
+
let event;
|
|
707
|
+
try {
|
|
708
|
+
event = await config.provider.constructEvent({
|
|
709
|
+
payload,
|
|
710
|
+
signature,
|
|
711
|
+
secret: config.webhookSecret
|
|
712
|
+
});
|
|
713
|
+
} catch {
|
|
714
|
+
return (0, import_core5.setApiResponse)(import_core5.HTTP.BAD_REQUEST, "INVALID_REQUEST", "Invalid webhook signature");
|
|
715
|
+
}
|
|
716
|
+
if (event.subscription) {
|
|
717
|
+
await subscriptions.upsert({
|
|
718
|
+
subscriberType: event.subscription.subscriberType,
|
|
719
|
+
subscriberId: event.subscription.subscriberId,
|
|
720
|
+
plan: event.subscription.plan,
|
|
721
|
+
status: event.subscription.status,
|
|
722
|
+
providerCustomerId: event.subscription.providerCustomerId,
|
|
723
|
+
providerSubscriptionId: event.subscription.providerSubscriptionId,
|
|
724
|
+
currentPeriodStart: event.subscription.currentPeriodStart,
|
|
725
|
+
currentPeriodEnd: event.subscription.currentPeriodEnd,
|
|
726
|
+
cancelAtPeriodEnd: event.subscription.cancelAtPeriodEnd,
|
|
727
|
+
trialEndsAt: event.subscription.trialEndsAt
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return Response.json({ received: true });
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/routes.ts
|
|
736
|
+
function buildBillingRoutes(store, config) {
|
|
737
|
+
const plan = planController(store);
|
|
738
|
+
const subscription = subscriptionController(store);
|
|
739
|
+
const checkout = checkoutController(store, config);
|
|
740
|
+
const usage = usageController(store);
|
|
741
|
+
const webhook = webhookController(store, config);
|
|
742
|
+
return [
|
|
743
|
+
// Plans — public read-only
|
|
744
|
+
["GET", "/plans", plan.list],
|
|
745
|
+
["GET", "/plans/:planId", plan.get],
|
|
746
|
+
// Plans — admin write (caller is responsible for authorization)
|
|
747
|
+
["POST", "/plans", plan.create],
|
|
748
|
+
["PUT", "/plans/:planId", plan.update],
|
|
749
|
+
["DELETE", "/plans/:planId", plan.delete],
|
|
750
|
+
// Billing — subscriber resolved from X-Workspace-ID header (workspace) or session (user)
|
|
751
|
+
// Workspace membership is verified automatically by the withBilling global middleware
|
|
752
|
+
["GET", "/billing/subscription", import_middlewares.requireAuth, subscription.get],
|
|
753
|
+
["POST", "/billing/checkout", import_middlewares.requireAuth, checkout.createSession],
|
|
754
|
+
["POST", "/billing/portal", import_middlewares.requireAuth, checkout.createPortal],
|
|
755
|
+
["POST", "/billing/usage", import_middlewares.requireAuth, usage.record],
|
|
756
|
+
["GET", "/billing/usage/:metric", import_middlewares.requireAuth, usage.get],
|
|
757
|
+
// Webhook — signature verified inside the handler
|
|
758
|
+
["POST", "/billing/webhook", webhook.handle]
|
|
759
|
+
];
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/middlewares/billing.ts
|
|
763
|
+
var import_core6 = require("@fonderie/core");
|
|
764
|
+
|
|
765
|
+
// src/config.ts
|
|
766
|
+
var MESSAGE_KEYS = {
|
|
767
|
+
limitWarning: "billing.limit-warning",
|
|
768
|
+
limitReached: "billing.limit-reached",
|
|
769
|
+
limitBlocked: "billing.limit-blocked"
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
// src/services/policy.ts
|
|
773
|
+
function buildBillingContext(opts) {
|
|
774
|
+
const { subscriber, plan, active, counters } = opts;
|
|
775
|
+
const defaults = plan.defaults ?? {};
|
|
776
|
+
const statuses = {};
|
|
777
|
+
for (const [key, entry] of Object.entries(plan.policy ?? {})) {
|
|
778
|
+
if ("enabled" in entry) {
|
|
779
|
+
statuses[key] = { type: "feature", enabled: entry.enabled };
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const { limit, buffer = defaults.buffer ?? 0, warnAt = defaults.warnAt ?? 0.8, window } = entry;
|
|
783
|
+
const used = counters[key] ?? 0;
|
|
784
|
+
const hardLimit = limit !== null ? limit + buffer : null;
|
|
785
|
+
let status = "ok";
|
|
786
|
+
if (hardLimit !== null && used >= hardLimit) status = "blocked";
|
|
787
|
+
else if (limit !== null && used >= limit) status = "over_limit";
|
|
788
|
+
else if (limit !== null && used >= limit * warnAt) status = "warning";
|
|
789
|
+
let resetsAt = null;
|
|
790
|
+
if (window) {
|
|
791
|
+
const windowMs = parseWindowMs(window);
|
|
792
|
+
const windowStart = Math.floor(Date.now() / windowMs) * windowMs;
|
|
793
|
+
resetsAt = new Date(windowStart + windowMs).toISOString();
|
|
794
|
+
}
|
|
795
|
+
statuses[key] = { type: "counter", limit, used, status, resetsAt };
|
|
796
|
+
}
|
|
797
|
+
return { subscriber, plan: plan.name, active, statuses };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/middlewares/billing.ts
|
|
801
|
+
var notified = /* @__PURE__ */ new Set();
|
|
802
|
+
function withBilling(store, config, backend) {
|
|
803
|
+
return async (ctx, next) => {
|
|
804
|
+
const subscriber = resolveSubscriber(ctx);
|
|
805
|
+
if (!subscriber) return next();
|
|
806
|
+
const subscription = await getSubscription(subscriber.type, subscriber.id, store);
|
|
807
|
+
const planName = subscription?.plan ?? config.plans[0]?.name ?? "free";
|
|
808
|
+
const active = !subscription || subscription.status === "active" || subscription.status === "trialing";
|
|
809
|
+
const plan = config.plans.find((p) => p.name === planName) ?? config.plans[0];
|
|
810
|
+
if (!plan) return next();
|
|
811
|
+
const counters = {};
|
|
812
|
+
for (const [key, entry] of Object.entries(plan.policy ?? {})) {
|
|
813
|
+
if ("enabled" in entry || !entry.window) continue;
|
|
814
|
+
const windowMs = parseWindowMs(entry.window);
|
|
815
|
+
const counterKey = `${subscriber.type}:${subscriber.id}:${key}`;
|
|
816
|
+
counters[key] = await backend.increment(counterKey, windowMs);
|
|
817
|
+
}
|
|
818
|
+
const billingCtx = buildBillingContext({ subscriber, plan, active, counters });
|
|
819
|
+
ctx.meta["billing"] = billingCtx;
|
|
820
|
+
for (const [key, status] of Object.entries(billingCtx.statuses)) {
|
|
821
|
+
if (status.type === "counter" && status.status === "blocked") {
|
|
822
|
+
return (0, import_core6.setApiResponse)(
|
|
823
|
+
import_core6.HTTP.TOO_MANY_REQUESTS,
|
|
824
|
+
"RATE_LIMIT_EXCEEDED",
|
|
825
|
+
`Limit exceeded for: ${key}`,
|
|
826
|
+
{ key, limit: status.limit, used: status.used, resetsAt: status.resetsAt }
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (config.notifications) {
|
|
831
|
+
const toNotify = [];
|
|
832
|
+
const recipient = {
|
|
833
|
+
email: ctx.user?.email ?? null,
|
|
834
|
+
phone: null,
|
|
835
|
+
deviceToken: null
|
|
836
|
+
};
|
|
837
|
+
for (const [key, status] of Object.entries(billingCtx.statuses)) {
|
|
838
|
+
if (status.type !== "counter" || status.limit === null) continue;
|
|
839
|
+
const base = `${subscriber.type}:${subscriber.id}:${key}`;
|
|
840
|
+
if (config.notifications.softHit && status.status === "over_limit") {
|
|
841
|
+
const nk = `${base}:reached`;
|
|
842
|
+
if (!notified.has(nk)) {
|
|
843
|
+
notified.add(nk);
|
|
844
|
+
toNotify.push({
|
|
845
|
+
type: MESSAGE_KEYS.limitReached,
|
|
846
|
+
recipient,
|
|
847
|
+
data: {
|
|
848
|
+
key,
|
|
849
|
+
plan: plan.name,
|
|
850
|
+
limit: status.limit,
|
|
851
|
+
used: status.used
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
} else if (config.notifications.warnAt && status.status === "warning") {
|
|
856
|
+
const nk = `${base}:warning`;
|
|
857
|
+
if (!notified.has(nk)) {
|
|
858
|
+
notified.add(nk);
|
|
859
|
+
toNotify.push({
|
|
860
|
+
type: MESSAGE_KEYS.limitWarning,
|
|
861
|
+
recipient,
|
|
862
|
+
data: {
|
|
863
|
+
key,
|
|
864
|
+
plan: plan.name,
|
|
865
|
+
limit: status.limit,
|
|
866
|
+
used: status.used
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (toNotify.length > 0) {
|
|
873
|
+
const existing = ctx.meta["messages"];
|
|
874
|
+
ctx.meta["messages"] = [...existing ?? [], ...toNotify];
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return next();
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/backends/memory.ts
|
|
882
|
+
var MemoryCounterBackend = class {
|
|
883
|
+
counters = /* @__PURE__ */ new Map();
|
|
884
|
+
async increment(key, windowMs, quantity = 1) {
|
|
885
|
+
const now = Date.now();
|
|
886
|
+
const existing = this.counters.get(key);
|
|
887
|
+
if (!existing || windowMs !== null && now - existing.windowStart >= windowMs) {
|
|
888
|
+
this.counters.set(key, { count: quantity, windowStart: now });
|
|
889
|
+
return quantity;
|
|
890
|
+
}
|
|
891
|
+
existing.count += quantity;
|
|
892
|
+
return existing.count;
|
|
893
|
+
}
|
|
894
|
+
async get(key, windowMs) {
|
|
895
|
+
const now = Date.now();
|
|
896
|
+
const existing = this.counters.get(key);
|
|
897
|
+
if (!existing) return 0;
|
|
898
|
+
if (windowMs !== null && now - existing.windowStart >= windowMs) return 0;
|
|
899
|
+
return existing.count;
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
// src/backends/db.ts
|
|
904
|
+
var DBCounterBackend = class {
|
|
905
|
+
constructor(store) {
|
|
906
|
+
this.store = store;
|
|
907
|
+
}
|
|
908
|
+
store;
|
|
909
|
+
async increment(key, windowMs, quantity = 1) {
|
|
910
|
+
const [subscriberType, subscriberId, ...rest] = key.split(":");
|
|
911
|
+
const metric = rest.join(":");
|
|
912
|
+
await this.store.query(
|
|
913
|
+
`INSERT INTO fonderie_usage_records (subscriber_type, subscriber_id, metric, quantity)
|
|
914
|
+
VALUES ($1, $2, $3, $4)`,
|
|
915
|
+
[subscriberType, subscriberId, metric, quantity]
|
|
916
|
+
);
|
|
917
|
+
return this.get(key, windowMs);
|
|
918
|
+
}
|
|
919
|
+
async get(key, windowMs) {
|
|
920
|
+
const [subscriberType, subscriberId, ...rest] = key.split(":");
|
|
921
|
+
const metric = rest.join(":");
|
|
922
|
+
const since = windowMs !== null ? new Date(Date.now() - windowMs) : /* @__PURE__ */ new Date(0);
|
|
923
|
+
const rows = await this.store.query(
|
|
924
|
+
`SELECT COALESCE(SUM(quantity), 0) AS total
|
|
925
|
+
FROM fonderie_usage_records
|
|
926
|
+
WHERE subscriber_type = $1
|
|
927
|
+
AND subscriber_id = $2
|
|
928
|
+
AND metric = $3
|
|
929
|
+
AND recorded_at >= $4`,
|
|
930
|
+
[subscriberType, subscriberId, metric, since]
|
|
931
|
+
);
|
|
932
|
+
return parseInt(rows[0]?.total ?? "0", 10);
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
// src/backends/index.ts
|
|
937
|
+
function createBackend(config, store) {
|
|
938
|
+
if (!config || config === "memory") return new MemoryCounterBackend();
|
|
939
|
+
if (config === "db") return new DBCounterBackend(store);
|
|
940
|
+
return config;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/module.ts
|
|
944
|
+
var BillingModule = class {
|
|
945
|
+
constructor(store, config) {
|
|
946
|
+
this.store = store;
|
|
947
|
+
this.config = config;
|
|
948
|
+
}
|
|
949
|
+
store;
|
|
950
|
+
config;
|
|
951
|
+
name = "@fonderie/billing";
|
|
952
|
+
deps = ["@fonderie/auth"];
|
|
953
|
+
async install(app) {
|
|
954
|
+
await syncPlansToDB(this.config, this.store);
|
|
955
|
+
const backend = createBackend(this.config.rateLimit?.backend, this.store);
|
|
956
|
+
app.use(withBilling(this.store, this.config, backend));
|
|
957
|
+
const routes = buildBillingRoutes(this.store, this.config);
|
|
958
|
+
for (const [method, path, ...handlers] of routes) {
|
|
959
|
+
app.addRoute(method, path, ...handlers);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
// src/providers/stripe.ts
|
|
965
|
+
var _client = null;
|
|
966
|
+
async function getClient(secretKey) {
|
|
967
|
+
if (_client) return _client;
|
|
968
|
+
const pkg = "stripe";
|
|
969
|
+
const mod = await import(pkg).catch(() => {
|
|
970
|
+
throw new Error("[billing:stripe] stripe is required: npm install stripe");
|
|
971
|
+
});
|
|
972
|
+
const Stripe = mod.default ?? mod;
|
|
973
|
+
_client = new Stripe(secretKey, { apiVersion: "2024-11-20.acacia" });
|
|
974
|
+
return _client;
|
|
975
|
+
}
|
|
976
|
+
function normalizeSubscription(sub) {
|
|
977
|
+
return {
|
|
978
|
+
subscriberType: sub.metadata?.["subscriberType"] ?? "workspace",
|
|
979
|
+
subscriberId: sub.metadata?.["subscriberId"] ?? "",
|
|
980
|
+
plan: sub.items.data[0]?.price.nickname ?? "unknown",
|
|
981
|
+
status: sub.status,
|
|
982
|
+
providerCustomerId: sub.customer,
|
|
983
|
+
providerSubscriptionId: sub.id,
|
|
984
|
+
currentPeriodStart: new Date(sub.current_period_start * 1e3),
|
|
985
|
+
currentPeriodEnd: new Date(sub.current_period_end * 1e3),
|
|
986
|
+
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
|
987
|
+
trialEndsAt: sub.trial_end ? new Date(sub.trial_end * 1e3) : null
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
var StripeProvider = class {
|
|
991
|
+
constructor(secretKey, webhookSecret) {
|
|
992
|
+
this.secretKey = secretKey;
|
|
993
|
+
this.webhookSecret = webhookSecret;
|
|
994
|
+
}
|
|
995
|
+
secretKey;
|
|
996
|
+
webhookSecret;
|
|
997
|
+
name = "stripe";
|
|
998
|
+
async client() {
|
|
999
|
+
return getClient(this.secretKey);
|
|
1000
|
+
}
|
|
1001
|
+
async createCustomer(opts) {
|
|
1002
|
+
const stripe = await this.client();
|
|
1003
|
+
const customer = await stripe.customers.create({
|
|
1004
|
+
email: opts.email,
|
|
1005
|
+
metadata: {
|
|
1006
|
+
subscriberType: opts.subscriberType,
|
|
1007
|
+
subscriberId: opts.subscriberId,
|
|
1008
|
+
userId: opts.userId
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
return { customerId: customer.id };
|
|
1012
|
+
}
|
|
1013
|
+
async createCheckoutSession(opts) {
|
|
1014
|
+
const stripe = await this.client();
|
|
1015
|
+
const session = await stripe.checkout.sessions.create({
|
|
1016
|
+
customer: opts.customerId,
|
|
1017
|
+
mode: "subscription",
|
|
1018
|
+
line_items: [{ price: opts.priceId, quantity: 1 }],
|
|
1019
|
+
success_url: opts.successUrl,
|
|
1020
|
+
cancel_url: opts.cancelUrl,
|
|
1021
|
+
subscription_data: {
|
|
1022
|
+
metadata: {
|
|
1023
|
+
subscriberType: opts.subscriberType,
|
|
1024
|
+
subscriberId: opts.subscriberId
|
|
1025
|
+
},
|
|
1026
|
+
...opts.trialDays && opts.trialDays > 0 ? { trial_period_days: opts.trialDays } : {}
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
return { url: session.url ?? "" };
|
|
1030
|
+
}
|
|
1031
|
+
async createPortalSession(opts) {
|
|
1032
|
+
const stripe = await this.client();
|
|
1033
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
1034
|
+
customer: opts.customerId,
|
|
1035
|
+
return_url: opts.returnUrl
|
|
1036
|
+
});
|
|
1037
|
+
return { url: session.url };
|
|
1038
|
+
}
|
|
1039
|
+
async constructEvent(opts) {
|
|
1040
|
+
const stripe = await this.client();
|
|
1041
|
+
let raw;
|
|
1042
|
+
try {
|
|
1043
|
+
raw = stripe.webhooks.constructEvent(opts.payload, opts.signature, opts.secret);
|
|
1044
|
+
} catch {
|
|
1045
|
+
throw new Error("[billing:stripe] Invalid webhook signature");
|
|
1046
|
+
}
|
|
1047
|
+
const isSubscriptionEvent = [
|
|
1048
|
+
"customer.subscription.created",
|
|
1049
|
+
"customer.subscription.updated",
|
|
1050
|
+
"customer.subscription.deleted"
|
|
1051
|
+
].includes(raw.type);
|
|
1052
|
+
if (!isSubscriptionEvent) {
|
|
1053
|
+
return { type: raw.type, subscription: null };
|
|
1054
|
+
}
|
|
1055
|
+
const sub = raw.data.object;
|
|
1056
|
+
if (raw.type === "customer.subscription.deleted") {
|
|
1057
|
+
return {
|
|
1058
|
+
type: raw.type,
|
|
1059
|
+
subscription: { ...normalizeSubscription(sub), plan: "free", status: "canceled" }
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
return { type: raw.type, subscription: normalizeSubscription(sub) };
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// src/middlewares/require-plan.ts
|
|
1067
|
+
var import_core7 = require("@fonderie/core");
|
|
1068
|
+
function makeHandler(plans, store) {
|
|
1069
|
+
const allowed = Array.isArray(plans) ? plans : [plans];
|
|
1070
|
+
return async (ctx, next) => {
|
|
1071
|
+
if (!ctx.user) {
|
|
1072
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
|
|
1073
|
+
}
|
|
1074
|
+
const subscriber = resolveSubscriber(ctx);
|
|
1075
|
+
if (!subscriber) {
|
|
1076
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "SUBSCRIBER_REQUIRED", "Subscriber context required");
|
|
1077
|
+
}
|
|
1078
|
+
const subscription = await getSubscription(subscriber.type, subscriber.id, store);
|
|
1079
|
+
if (!subscription || !allowed.includes(subscription.plan)) {
|
|
1080
|
+
return (0, import_core7.setApiResponse)(
|
|
1081
|
+
import_core7.HTTP.PAYMENT_REQUIRED,
|
|
1082
|
+
"PLAN_UPGRADE_REQUIRED",
|
|
1083
|
+
"Plan upgrade required",
|
|
1084
|
+
{ required: allowed, current: subscription?.plan ?? "none" }
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
if (subscription.status !== "active" && subscription.status !== "trialing") {
|
|
1088
|
+
return (0, import_core7.setApiResponse)(
|
|
1089
|
+
import_core7.HTTP.PAYMENT_REQUIRED,
|
|
1090
|
+
"SUBSCRIPTION_INACTIVE",
|
|
1091
|
+
"Subscription is not active",
|
|
1092
|
+
{ status: subscription.status }
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
return next();
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
function requirePlan(plans, store, ctx, next) {
|
|
1099
|
+
const handler = makeHandler(plans, store);
|
|
1100
|
+
if (ctx !== void 0 && next !== void 0) return handler(ctx, next);
|
|
1101
|
+
return handler;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/helpers.ts
|
|
1105
|
+
var import_core8 = require("@fonderie/core");
|
|
1106
|
+
function getBillingContext(ctx) {
|
|
1107
|
+
return ctx.meta["billing"] ?? null;
|
|
1108
|
+
}
|
|
1109
|
+
function hasFeature(ctx, key) {
|
|
1110
|
+
const billing = getBillingContext(ctx);
|
|
1111
|
+
if (!billing) return true;
|
|
1112
|
+
const status = billing.statuses[key];
|
|
1113
|
+
if (!status) return true;
|
|
1114
|
+
if (status.type === "feature") return status.enabled;
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
function getPlanLimit(ctx, key) {
|
|
1118
|
+
const billing = getBillingContext(ctx);
|
|
1119
|
+
if (!billing) return null;
|
|
1120
|
+
const status = billing.statuses[key];
|
|
1121
|
+
if (!status || status.type === "feature") return null;
|
|
1122
|
+
return status.limit;
|
|
1123
|
+
}
|
|
1124
|
+
function getLimitStatus(ctx, key) {
|
|
1125
|
+
const billing = getBillingContext(ctx);
|
|
1126
|
+
if (!billing) return null;
|
|
1127
|
+
return billing.statuses[key] ?? null;
|
|
1128
|
+
}
|
|
1129
|
+
function requireFeature(key) {
|
|
1130
|
+
return (ctx, next) => {
|
|
1131
|
+
if (!hasFeature(ctx, key)) {
|
|
1132
|
+
return Promise.resolve(
|
|
1133
|
+
(0, import_core8.setApiResponse)(
|
|
1134
|
+
import_core8.HTTP.PAYMENT_REQUIRED,
|
|
1135
|
+
"FEATURE_UNAVAILABLE",
|
|
1136
|
+
`Feature '${key}' is not available on your current plan`
|
|
1137
|
+
)
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
return next();
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1144
|
+
0 && (module.exports = {
|
|
1145
|
+
BillingModule,
|
|
1146
|
+
DBCounterBackend,
|
|
1147
|
+
MESSAGE_KEYS,
|
|
1148
|
+
MemoryCounterBackend,
|
|
1149
|
+
StripeProvider,
|
|
1150
|
+
createPlan,
|
|
1151
|
+
deletePlan,
|
|
1152
|
+
getDBPlans,
|
|
1153
|
+
getLimitStatus,
|
|
1154
|
+
getPlanById,
|
|
1155
|
+
getPlanByName,
|
|
1156
|
+
getPlanLimit,
|
|
1157
|
+
getPlans,
|
|
1158
|
+
getSubscription,
|
|
1159
|
+
getUsage,
|
|
1160
|
+
hasFeature,
|
|
1161
|
+
recordUsage,
|
|
1162
|
+
requireFeature,
|
|
1163
|
+
requirePlan,
|
|
1164
|
+
toPlanDTO,
|
|
1165
|
+
toSubscriptionDTO,
|
|
1166
|
+
toUsageRecordDTO,
|
|
1167
|
+
updatePlan,
|
|
1168
|
+
withBilling
|
|
1169
|
+
});
|
|
1170
|
+
//# sourceMappingURL=index.cjs.map
|