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