@hasna/microservices 0.0.3 → 0.0.5

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.
Files changed (68) hide show
  1. package/bin/index.js +63 -0
  2. package/bin/mcp.js +63 -0
  3. package/dist/index.js +63 -0
  4. package/microservices/microservice-ads/package.json +27 -0
  5. package/microservices/microservice-ads/src/cli/index.ts +605 -0
  6. package/microservices/microservice-ads/src/db/campaigns.ts +797 -0
  7. package/microservices/microservice-ads/src/db/database.ts +93 -0
  8. package/microservices/microservice-ads/src/db/migrations.ts +60 -0
  9. package/microservices/microservice-ads/src/index.ts +39 -0
  10. package/microservices/microservice-ads/src/mcp/index.ts +480 -0
  11. package/microservices/microservice-contracts/package.json +27 -0
  12. package/microservices/microservice-contracts/src/cli/index.ts +770 -0
  13. package/microservices/microservice-contracts/src/db/contracts.ts +925 -0
  14. package/microservices/microservice-contracts/src/db/database.ts +93 -0
  15. package/microservices/microservice-contracts/src/db/migrations.ts +141 -0
  16. package/microservices/microservice-contracts/src/index.ts +43 -0
  17. package/microservices/microservice-contracts/src/mcp/index.ts +617 -0
  18. package/microservices/microservice-domains/package.json +27 -0
  19. package/microservices/microservice-domains/src/cli/index.ts +691 -0
  20. package/microservices/microservice-domains/src/db/database.ts +93 -0
  21. package/microservices/microservice-domains/src/db/domains.ts +1164 -0
  22. package/microservices/microservice-domains/src/db/migrations.ts +60 -0
  23. package/microservices/microservice-domains/src/index.ts +65 -0
  24. package/microservices/microservice-domains/src/mcp/index.ts +536 -0
  25. package/microservices/microservice-hiring/package.json +27 -0
  26. package/microservices/microservice-hiring/src/cli/index.ts +741 -0
  27. package/microservices/microservice-hiring/src/db/database.ts +93 -0
  28. package/microservices/microservice-hiring/src/db/hiring.ts +1085 -0
  29. package/microservices/microservice-hiring/src/db/migrations.ts +89 -0
  30. package/microservices/microservice-hiring/src/index.ts +80 -0
  31. package/microservices/microservice-hiring/src/lib/scoring.ts +206 -0
  32. package/microservices/microservice-hiring/src/mcp/index.ts +709 -0
  33. package/microservices/microservice-payments/package.json +27 -0
  34. package/microservices/microservice-payments/src/cli/index.ts +609 -0
  35. package/microservices/microservice-payments/src/db/database.ts +93 -0
  36. package/microservices/microservice-payments/src/db/migrations.ts +81 -0
  37. package/microservices/microservice-payments/src/db/payments.ts +1204 -0
  38. package/microservices/microservice-payments/src/index.ts +51 -0
  39. package/microservices/microservice-payments/src/mcp/index.ts +683 -0
  40. package/microservices/microservice-payroll/package.json +27 -0
  41. package/microservices/microservice-payroll/src/cli/index.ts +643 -0
  42. package/microservices/microservice-payroll/src/db/database.ts +93 -0
  43. package/microservices/microservice-payroll/src/db/migrations.ts +95 -0
  44. package/microservices/microservice-payroll/src/db/payroll.ts +1377 -0
  45. package/microservices/microservice-payroll/src/index.ts +48 -0
  46. package/microservices/microservice-payroll/src/mcp/index.ts +666 -0
  47. package/microservices/microservice-shipping/package.json +27 -0
  48. package/microservices/microservice-shipping/src/cli/index.ts +606 -0
  49. package/microservices/microservice-shipping/src/db/database.ts +93 -0
  50. package/microservices/microservice-shipping/src/db/migrations.ts +69 -0
  51. package/microservices/microservice-shipping/src/db/shipping.ts +1093 -0
  52. package/microservices/microservice-shipping/src/index.ts +53 -0
  53. package/microservices/microservice-shipping/src/mcp/index.ts +533 -0
  54. package/microservices/microservice-social/package.json +27 -0
  55. package/microservices/microservice-social/src/cli/index.ts +689 -0
  56. package/microservices/microservice-social/src/db/database.ts +93 -0
  57. package/microservices/microservice-social/src/db/migrations.ts +88 -0
  58. package/microservices/microservice-social/src/db/social.ts +1046 -0
  59. package/microservices/microservice-social/src/index.ts +46 -0
  60. package/microservices/microservice-social/src/mcp/index.ts +655 -0
  61. package/microservices/microservice-subscriptions/package.json +27 -0
  62. package/microservices/microservice-subscriptions/src/cli/index.ts +715 -0
  63. package/microservices/microservice-subscriptions/src/db/database.ts +93 -0
  64. package/microservices/microservice-subscriptions/src/db/migrations.ts +125 -0
  65. package/microservices/microservice-subscriptions/src/db/subscriptions.ts +1256 -0
  66. package/microservices/microservice-subscriptions/src/index.ts +41 -0
  67. package/microservices/microservice-subscriptions/src/mcp/index.ts +631 -0
  68. package/package.json +1 -1
@@ -0,0 +1,1256 @@
1
+ /**
2
+ * Subscription CRUD operations and analytics
3
+ */
4
+
5
+ import { getDatabase } from "./database.js";
6
+
7
+ // --- Types ---
8
+
9
+ export interface Plan {
10
+ id: string;
11
+ name: string;
12
+ price: number;
13
+ interval: "monthly" | "yearly" | "lifetime";
14
+ features: string[];
15
+ active: boolean;
16
+ created_at: string;
17
+ updated_at: string;
18
+ }
19
+
20
+ interface PlanRow {
21
+ id: string;
22
+ name: string;
23
+ price: number;
24
+ interval: string;
25
+ features: string;
26
+ active: number;
27
+ created_at: string;
28
+ updated_at: string;
29
+ }
30
+
31
+ function rowToPlan(row: PlanRow): Plan {
32
+ return {
33
+ ...row,
34
+ interval: row.interval as Plan["interval"],
35
+ features: JSON.parse(row.features || "[]"),
36
+ active: row.active === 1,
37
+ };
38
+ }
39
+
40
+ export interface Subscriber {
41
+ id: string;
42
+ plan_id: string;
43
+ customer_name: string;
44
+ customer_email: string;
45
+ status: "trialing" | "active" | "past_due" | "canceled" | "expired" | "paused";
46
+ started_at: string;
47
+ trial_ends_at: string | null;
48
+ current_period_start: string;
49
+ current_period_end: string | null;
50
+ canceled_at: string | null;
51
+ resume_at: string | null;
52
+ metadata: Record<string, unknown>;
53
+ created_at: string;
54
+ updated_at: string;
55
+ }
56
+
57
+ interface SubscriberRow {
58
+ id: string;
59
+ plan_id: string;
60
+ customer_name: string;
61
+ customer_email: string;
62
+ status: string;
63
+ started_at: string;
64
+ trial_ends_at: string | null;
65
+ current_period_start: string;
66
+ current_period_end: string | null;
67
+ canceled_at: string | null;
68
+ resume_at: string | null;
69
+ metadata: string;
70
+ created_at: string;
71
+ updated_at: string;
72
+ }
73
+
74
+ function rowToSubscriber(row: SubscriberRow): Subscriber {
75
+ return {
76
+ ...row,
77
+ status: row.status as Subscriber["status"],
78
+ metadata: JSON.parse(row.metadata || "{}"),
79
+ };
80
+ }
81
+
82
+ export interface SubscriptionEvent {
83
+ id: string;
84
+ subscriber_id: string;
85
+ type: "created" | "upgraded" | "downgraded" | "canceled" | "renewed" | "payment_failed" | "paused" | "resumed" | "trial_extended";
86
+ occurred_at: string;
87
+ details: Record<string, unknown>;
88
+ }
89
+
90
+ interface EventRow {
91
+ id: string;
92
+ subscriber_id: string;
93
+ type: string;
94
+ occurred_at: string;
95
+ details: string;
96
+ }
97
+
98
+ function rowToEvent(row: EventRow): SubscriptionEvent {
99
+ return {
100
+ ...row,
101
+ type: row.type as SubscriptionEvent["type"],
102
+ details: JSON.parse(row.details || "{}"),
103
+ };
104
+ }
105
+
106
+ // --- Plans CRUD ---
107
+
108
+ export interface CreatePlanInput {
109
+ name: string;
110
+ price: number;
111
+ interval: "monthly" | "yearly" | "lifetime";
112
+ features?: string[];
113
+ active?: boolean;
114
+ }
115
+
116
+ export function createPlan(input: CreatePlanInput): Plan {
117
+ const db = getDatabase();
118
+ const id = crypto.randomUUID();
119
+ const features = JSON.stringify(input.features || []);
120
+ const active = input.active !== undefined ? (input.active ? 1 : 0) : 1;
121
+
122
+ db.prepare(
123
+ `INSERT INTO plans (id, name, price, interval, features, active)
124
+ VALUES (?, ?, ?, ?, ?, ?)`
125
+ ).run(id, input.name, input.price, input.interval, features, active);
126
+
127
+ return getPlan(id)!;
128
+ }
129
+
130
+ export function getPlan(id: string): Plan | null {
131
+ const db = getDatabase();
132
+ const row = db.prepare("SELECT * FROM plans WHERE id = ?").get(id) as PlanRow | null;
133
+ return row ? rowToPlan(row) : null;
134
+ }
135
+
136
+ export interface ListPlansOptions {
137
+ active_only?: boolean;
138
+ interval?: string;
139
+ limit?: number;
140
+ offset?: number;
141
+ }
142
+
143
+ export function listPlans(options: ListPlansOptions = {}): Plan[] {
144
+ const db = getDatabase();
145
+ const conditions: string[] = [];
146
+ const params: unknown[] = [];
147
+
148
+ if (options.active_only) {
149
+ conditions.push("active = 1");
150
+ }
151
+
152
+ if (options.interval) {
153
+ conditions.push("interval = ?");
154
+ params.push(options.interval);
155
+ }
156
+
157
+ let sql = "SELECT * FROM plans";
158
+ if (conditions.length > 0) {
159
+ sql += " WHERE " + conditions.join(" AND ");
160
+ }
161
+ sql += " ORDER BY name";
162
+
163
+ if (options.limit) {
164
+ sql += " LIMIT ?";
165
+ params.push(options.limit);
166
+ }
167
+ if (options.offset) {
168
+ sql += " OFFSET ?";
169
+ params.push(options.offset);
170
+ }
171
+
172
+ const rows = db.prepare(sql).all(...params) as PlanRow[];
173
+ return rows.map(rowToPlan);
174
+ }
175
+
176
+ export interface UpdatePlanInput {
177
+ name?: string;
178
+ price?: number;
179
+ interval?: "monthly" | "yearly" | "lifetime";
180
+ features?: string[];
181
+ active?: boolean;
182
+ }
183
+
184
+ export function updatePlan(id: string, input: UpdatePlanInput): Plan | null {
185
+ const db = getDatabase();
186
+ const existing = getPlan(id);
187
+ if (!existing) return null;
188
+
189
+ const sets: string[] = [];
190
+ const params: unknown[] = [];
191
+
192
+ if (input.name !== undefined) {
193
+ sets.push("name = ?");
194
+ params.push(input.name);
195
+ }
196
+ if (input.price !== undefined) {
197
+ sets.push("price = ?");
198
+ params.push(input.price);
199
+ }
200
+ if (input.interval !== undefined) {
201
+ sets.push("interval = ?");
202
+ params.push(input.interval);
203
+ }
204
+ if (input.features !== undefined) {
205
+ sets.push("features = ?");
206
+ params.push(JSON.stringify(input.features));
207
+ }
208
+ if (input.active !== undefined) {
209
+ sets.push("active = ?");
210
+ params.push(input.active ? 1 : 0);
211
+ }
212
+
213
+ if (sets.length === 0) return existing;
214
+
215
+ sets.push("updated_at = datetime('now')");
216
+ params.push(id);
217
+
218
+ db.prepare(
219
+ `UPDATE plans SET ${sets.join(", ")} WHERE id = ?`
220
+ ).run(...params);
221
+
222
+ return getPlan(id);
223
+ }
224
+
225
+ export function deletePlan(id: string): boolean {
226
+ const db = getDatabase();
227
+ const result = db.prepare("DELETE FROM plans WHERE id = ?").run(id);
228
+ return result.changes > 0;
229
+ }
230
+
231
+ // --- Subscribers CRUD ---
232
+
233
+ export interface CreateSubscriberInput {
234
+ plan_id: string;
235
+ customer_name: string;
236
+ customer_email: string;
237
+ status?: Subscriber["status"];
238
+ trial_ends_at?: string;
239
+ current_period_end?: string;
240
+ metadata?: Record<string, unknown>;
241
+ }
242
+
243
+ export function createSubscriber(input: CreateSubscriberInput): Subscriber {
244
+ const db = getDatabase();
245
+ const id = crypto.randomUUID();
246
+ const metadata = JSON.stringify(input.metadata || {});
247
+ const status = input.status || "active";
248
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "").split(".")[0];
249
+
250
+ // Calculate period end if not provided
251
+ let periodEnd = input.current_period_end || null;
252
+ if (!periodEnd) {
253
+ const plan = getPlan(input.plan_id);
254
+ if (plan) {
255
+ const start = new Date();
256
+ if (plan.interval === "monthly") {
257
+ start.setMonth(start.getMonth() + 1);
258
+ } else if (plan.interval === "yearly") {
259
+ start.setFullYear(start.getFullYear() + 1);
260
+ }
261
+ // lifetime has no period end
262
+ if (plan.interval !== "lifetime") {
263
+ periodEnd = start.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
264
+ }
265
+ }
266
+ }
267
+
268
+ db.prepare(
269
+ `INSERT INTO subscribers (id, plan_id, customer_name, customer_email, status, started_at, trial_ends_at, current_period_start, current_period_end, metadata)
270
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
271
+ ).run(
272
+ id,
273
+ input.plan_id,
274
+ input.customer_name,
275
+ input.customer_email,
276
+ status,
277
+ now,
278
+ input.trial_ends_at || null,
279
+ now,
280
+ periodEnd,
281
+ metadata
282
+ );
283
+
284
+ // Record creation event
285
+ recordEvent(id, "created", { plan_id: input.plan_id });
286
+
287
+ return getSubscriber(id)!;
288
+ }
289
+
290
+ export function getSubscriber(id: string): Subscriber | null {
291
+ const db = getDatabase();
292
+ const row = db.prepare("SELECT * FROM subscribers WHERE id = ?").get(id) as SubscriberRow | null;
293
+ return row ? rowToSubscriber(row) : null;
294
+ }
295
+
296
+ export interface ListSubscribersOptions {
297
+ plan_id?: string;
298
+ status?: string;
299
+ search?: string;
300
+ limit?: number;
301
+ offset?: number;
302
+ }
303
+
304
+ export function listSubscribers(options: ListSubscribersOptions = {}): Subscriber[] {
305
+ const db = getDatabase();
306
+ const conditions: string[] = [];
307
+ const params: unknown[] = [];
308
+
309
+ if (options.plan_id) {
310
+ conditions.push("plan_id = ?");
311
+ params.push(options.plan_id);
312
+ }
313
+
314
+ if (options.status) {
315
+ conditions.push("status = ?");
316
+ params.push(options.status);
317
+ }
318
+
319
+ if (options.search) {
320
+ conditions.push("(customer_name LIKE ? OR customer_email LIKE ?)");
321
+ const q = `%${options.search}%`;
322
+ params.push(q, q);
323
+ }
324
+
325
+ let sql = "SELECT * FROM subscribers";
326
+ if (conditions.length > 0) {
327
+ sql += " WHERE " + conditions.join(" AND ");
328
+ }
329
+ sql += " ORDER BY created_at DESC";
330
+
331
+ if (options.limit) {
332
+ sql += " LIMIT ?";
333
+ params.push(options.limit);
334
+ }
335
+ if (options.offset) {
336
+ sql += " OFFSET ?";
337
+ params.push(options.offset);
338
+ }
339
+
340
+ const rows = db.prepare(sql).all(...params) as SubscriberRow[];
341
+ return rows.map(rowToSubscriber);
342
+ }
343
+
344
+ export interface UpdateSubscriberInput {
345
+ customer_name?: string;
346
+ customer_email?: string;
347
+ status?: Subscriber["status"];
348
+ trial_ends_at?: string | null;
349
+ current_period_start?: string;
350
+ current_period_end?: string | null;
351
+ canceled_at?: string | null;
352
+ metadata?: Record<string, unknown>;
353
+ }
354
+
355
+ export function updateSubscriber(id: string, input: UpdateSubscriberInput): Subscriber | null {
356
+ const db = getDatabase();
357
+ const existing = getSubscriber(id);
358
+ if (!existing) return null;
359
+
360
+ const sets: string[] = [];
361
+ const params: unknown[] = [];
362
+
363
+ if (input.customer_name !== undefined) {
364
+ sets.push("customer_name = ?");
365
+ params.push(input.customer_name);
366
+ }
367
+ if (input.customer_email !== undefined) {
368
+ sets.push("customer_email = ?");
369
+ params.push(input.customer_email);
370
+ }
371
+ if (input.status !== undefined) {
372
+ sets.push("status = ?");
373
+ params.push(input.status);
374
+ }
375
+ if (input.trial_ends_at !== undefined) {
376
+ sets.push("trial_ends_at = ?");
377
+ params.push(input.trial_ends_at);
378
+ }
379
+ if (input.current_period_start !== undefined) {
380
+ sets.push("current_period_start = ?");
381
+ params.push(input.current_period_start);
382
+ }
383
+ if (input.current_period_end !== undefined) {
384
+ sets.push("current_period_end = ?");
385
+ params.push(input.current_period_end);
386
+ }
387
+ if (input.canceled_at !== undefined) {
388
+ sets.push("canceled_at = ?");
389
+ params.push(input.canceled_at);
390
+ }
391
+ if (input.metadata !== undefined) {
392
+ sets.push("metadata = ?");
393
+ params.push(JSON.stringify(input.metadata));
394
+ }
395
+
396
+ if (sets.length === 0) return existing;
397
+
398
+ sets.push("updated_at = datetime('now')");
399
+ params.push(id);
400
+
401
+ db.prepare(
402
+ `UPDATE subscribers SET ${sets.join(", ")} WHERE id = ?`
403
+ ).run(...params);
404
+
405
+ return getSubscriber(id);
406
+ }
407
+
408
+ export function deleteSubscriber(id: string): boolean {
409
+ const db = getDatabase();
410
+ const result = db.prepare("DELETE FROM subscribers WHERE id = ?").run(id);
411
+ return result.changes > 0;
412
+ }
413
+
414
+ // --- Events ---
415
+
416
+ export function recordEvent(
417
+ subscriberId: string,
418
+ type: SubscriptionEvent["type"],
419
+ details: Record<string, unknown> = {}
420
+ ): SubscriptionEvent {
421
+ const db = getDatabase();
422
+ const id = crypto.randomUUID();
423
+ db.prepare(
424
+ `INSERT INTO events (id, subscriber_id, type, details) VALUES (?, ?, ?, ?)`
425
+ ).run(id, subscriberId, type, JSON.stringify(details));
426
+
427
+ return getEvent(id)!;
428
+ }
429
+
430
+ export function getEvent(id: string): SubscriptionEvent | null {
431
+ const db = getDatabase();
432
+ const row = db.prepare("SELECT * FROM events WHERE id = ?").get(id) as EventRow | null;
433
+ return row ? rowToEvent(row) : null;
434
+ }
435
+
436
+ export interface ListEventsOptions {
437
+ subscriber_id?: string;
438
+ type?: string;
439
+ limit?: number;
440
+ offset?: number;
441
+ }
442
+
443
+ export function listEvents(options: ListEventsOptions = {}): SubscriptionEvent[] {
444
+ const db = getDatabase();
445
+ const conditions: string[] = [];
446
+ const params: unknown[] = [];
447
+
448
+ if (options.subscriber_id) {
449
+ conditions.push("subscriber_id = ?");
450
+ params.push(options.subscriber_id);
451
+ }
452
+
453
+ if (options.type) {
454
+ conditions.push("type = ?");
455
+ params.push(options.type);
456
+ }
457
+
458
+ let sql = "SELECT * FROM events";
459
+ if (conditions.length > 0) {
460
+ sql += " WHERE " + conditions.join(" AND ");
461
+ }
462
+ sql += " ORDER BY occurred_at DESC";
463
+
464
+ if (options.limit) {
465
+ sql += " LIMIT ?";
466
+ params.push(options.limit);
467
+ }
468
+ if (options.offset) {
469
+ sql += " OFFSET ?";
470
+ params.push(options.offset);
471
+ }
472
+
473
+ const rows = db.prepare(sql).all(...params) as EventRow[];
474
+ return rows.map(rowToEvent);
475
+ }
476
+
477
+ // --- Subscription Actions ---
478
+
479
+ export function upgradeSubscriber(subscriberId: string, newPlanId: string): Subscriber | null {
480
+ const db = getDatabase();
481
+ const subscriber = getSubscriber(subscriberId);
482
+ if (!subscriber) return null;
483
+
484
+ const newPlan = getPlan(newPlanId);
485
+ if (!newPlan) return null;
486
+
487
+ const oldPlanId = subscriber.plan_id;
488
+
489
+ // Calculate new period end
490
+ const now = new Date();
491
+ let periodEnd: string | null = null;
492
+ if (newPlan.interval === "monthly") {
493
+ const end = new Date(now);
494
+ end.setMonth(end.getMonth() + 1);
495
+ periodEnd = end.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
496
+ } else if (newPlan.interval === "yearly") {
497
+ const end = new Date(now);
498
+ end.setFullYear(end.getFullYear() + 1);
499
+ periodEnd = end.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
500
+ }
501
+
502
+ const nowStr = now.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
503
+
504
+ db.prepare(
505
+ `UPDATE subscribers SET plan_id = ?, status = 'active', current_period_start = ?, current_period_end = ?, updated_at = datetime('now') WHERE id = ?`
506
+ ).run(newPlanId, nowStr, periodEnd, subscriberId);
507
+
508
+ recordEvent(subscriberId, "upgraded", {
509
+ old_plan_id: oldPlanId,
510
+ new_plan_id: newPlanId,
511
+ });
512
+
513
+ return getSubscriber(subscriberId);
514
+ }
515
+
516
+ export function downgradeSubscriber(subscriberId: string, newPlanId: string): Subscriber | null {
517
+ const db = getDatabase();
518
+ const subscriber = getSubscriber(subscriberId);
519
+ if (!subscriber) return null;
520
+
521
+ const newPlan = getPlan(newPlanId);
522
+ if (!newPlan) return null;
523
+
524
+ const oldPlanId = subscriber.plan_id;
525
+
526
+ // Calculate new period end
527
+ const now = new Date();
528
+ let periodEnd: string | null = null;
529
+ if (newPlan.interval === "monthly") {
530
+ const end = new Date(now);
531
+ end.setMonth(end.getMonth() + 1);
532
+ periodEnd = end.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
533
+ } else if (newPlan.interval === "yearly") {
534
+ const end = new Date(now);
535
+ end.setFullYear(end.getFullYear() + 1);
536
+ periodEnd = end.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
537
+ }
538
+
539
+ const nowStr = now.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
540
+
541
+ db.prepare(
542
+ `UPDATE subscribers SET plan_id = ?, status = 'active', current_period_start = ?, current_period_end = ?, updated_at = datetime('now') WHERE id = ?`
543
+ ).run(newPlanId, nowStr, periodEnd, subscriberId);
544
+
545
+ recordEvent(subscriberId, "downgraded", {
546
+ old_plan_id: oldPlanId,
547
+ new_plan_id: newPlanId,
548
+ });
549
+
550
+ return getSubscriber(subscriberId);
551
+ }
552
+
553
+ export function cancelSubscriber(subscriberId: string): Subscriber | null {
554
+ const db = getDatabase();
555
+ const subscriber = getSubscriber(subscriberId);
556
+ if (!subscriber) return null;
557
+
558
+ const now = new Date().toISOString().replace("T", " ").replace("Z", "").split(".")[0];
559
+
560
+ db.prepare(
561
+ `UPDATE subscribers SET status = 'canceled', canceled_at = ?, updated_at = datetime('now') WHERE id = ?`
562
+ ).run(now, subscriberId);
563
+
564
+ recordEvent(subscriberId, "canceled", {});
565
+
566
+ return getSubscriber(subscriberId);
567
+ }
568
+
569
+ // --- Analytics ---
570
+
571
+ /**
572
+ * Monthly Recurring Revenue — sum of all active monthly-equivalent revenue.
573
+ * Yearly plans are divided by 12. Lifetime plans are excluded.
574
+ */
575
+ export function getMrr(): number {
576
+ const db = getDatabase();
577
+ const row = db.prepare(`
578
+ SELECT COALESCE(SUM(
579
+ CASE
580
+ WHEN p.interval = 'monthly' THEN p.price
581
+ WHEN p.interval = 'yearly' THEN p.price / 12.0
582
+ ELSE 0
583
+ END
584
+ ), 0) as mrr
585
+ FROM subscribers s
586
+ JOIN plans p ON s.plan_id = p.id
587
+ WHERE s.status IN ('active', 'trialing', 'past_due') AND s.status != 'paused'
588
+ `).get() as { mrr: number };
589
+ return Math.round(row.mrr * 100) / 100;
590
+ }
591
+
592
+ /**
593
+ * Annual Recurring Revenue — MRR * 12
594
+ */
595
+ export function getArr(): number {
596
+ return Math.round(getMrr() * 12 * 100) / 100;
597
+ }
598
+
599
+ /**
600
+ * Churn rate for a period (in days).
601
+ * Calculated as: (canceled in period) / (active at start of period) * 100
602
+ */
603
+ export function getChurnRate(periodDays: number = 30): number {
604
+ const db = getDatabase();
605
+ const now = new Date();
606
+ const periodStart = new Date(now.getTime() - periodDays * 24 * 60 * 60 * 1000);
607
+ const periodStartStr = periodStart.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
608
+
609
+ // Count subscribers that were canceled in this period
610
+ const canceledRow = db.prepare(`
611
+ SELECT COUNT(*) as count FROM subscribers
612
+ WHERE status = 'canceled' AND canceled_at >= ?
613
+ `).get(periodStartStr) as { count: number };
614
+
615
+ // Count subscribers that were active at the start of the period (active + those canceled during the period)
616
+ const activeAtStartRow = db.prepare(`
617
+ SELECT COUNT(*) as count FROM subscribers
618
+ WHERE (status IN ('active', 'trialing', 'past_due'))
619
+ OR (status = 'canceled' AND canceled_at >= ?)
620
+ `).get(periodStartStr) as { count: number };
621
+
622
+ if (activeAtStartRow.count === 0) return 0;
623
+ return Math.round((canceledRow.count / activeAtStartRow.count) * 100 * 100) / 100;
624
+ }
625
+
626
+ /**
627
+ * List subscribers whose current period ends within the given number of days.
628
+ */
629
+ export function listExpiring(days: number = 7): Subscriber[] {
630
+ const db = getDatabase();
631
+ const now = new Date();
632
+ const futureDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
633
+ const nowStr = now.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
634
+ const futureStr = futureDate.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
635
+
636
+ const rows = db.prepare(`
637
+ SELECT * FROM subscribers
638
+ WHERE status IN ('active', 'trialing', 'past_due')
639
+ AND current_period_end IS NOT NULL
640
+ AND current_period_end >= ?
641
+ AND current_period_end <= ?
642
+ ORDER BY current_period_end ASC
643
+ `).all(nowStr, futureStr) as SubscriberRow[];
644
+
645
+ return rows.map(rowToSubscriber);
646
+ }
647
+
648
+ /**
649
+ * Get subscriber statistics.
650
+ */
651
+ export function getSubscriberStats(): {
652
+ total: number;
653
+ active: number;
654
+ trialing: number;
655
+ past_due: number;
656
+ canceled: number;
657
+ expired: number;
658
+ paused: number;
659
+ } {
660
+ const db = getDatabase();
661
+ const rows = db.prepare(`
662
+ SELECT status, COUNT(*) as count FROM subscribers GROUP BY status
663
+ `).all() as { status: string; count: number }[];
664
+
665
+ const stats = {
666
+ total: 0,
667
+ active: 0,
668
+ trialing: 0,
669
+ past_due: 0,
670
+ canceled: 0,
671
+ expired: 0,
672
+ paused: 0,
673
+ };
674
+
675
+ for (const row of rows) {
676
+ const key = row.status as keyof typeof stats;
677
+ if (key in stats && key !== "total") {
678
+ stats[key] = row.count;
679
+ }
680
+ stats.total += row.count;
681
+ }
682
+
683
+ return stats;
684
+ }
685
+
686
+ export function countPlans(): number {
687
+ const db = getDatabase();
688
+ const row = db.prepare("SELECT COUNT(*) as count FROM plans").get() as { count: number };
689
+ return row.count;
690
+ }
691
+
692
+ export function countSubscribers(): number {
693
+ const db = getDatabase();
694
+ const row = db.prepare("SELECT COUNT(*) as count FROM subscribers").get() as { count: number };
695
+ return row.count;
696
+ }
697
+
698
+ // --- Subscription Pause/Resume ---
699
+
700
+ export function pauseSubscriber(id: string, resumeDate?: string): Subscriber | null {
701
+ const db = getDatabase();
702
+ const subscriber = getSubscriber(id);
703
+ if (!subscriber) return null;
704
+ if (subscriber.status === "canceled" || subscriber.status === "expired") return null;
705
+
706
+ const resumeAt = resumeDate || null;
707
+
708
+ db.prepare(
709
+ `UPDATE subscribers SET status = 'paused', resume_at = ?, updated_at = datetime('now') WHERE id = ?`
710
+ ).run(resumeAt, id);
711
+
712
+ recordEvent(id, "paused", { resume_at: resumeAt });
713
+
714
+ return getSubscriber(id);
715
+ }
716
+
717
+ export function resumeSubscriber(id: string): Subscriber | null {
718
+ const db = getDatabase();
719
+ const subscriber = getSubscriber(id);
720
+ if (!subscriber) return null;
721
+ if (subscriber.status !== "paused") return null;
722
+
723
+ db.prepare(
724
+ `UPDATE subscribers SET status = 'active', resume_at = NULL, updated_at = datetime('now') WHERE id = ?`
725
+ ).run(id);
726
+
727
+ recordEvent(id, "resumed", {});
728
+
729
+ return getSubscriber(id);
730
+ }
731
+
732
+ // --- Trial Extension ---
733
+
734
+ export function extendTrial(id: string, days: number): Subscriber | null {
735
+ const db = getDatabase();
736
+ const subscriber = getSubscriber(id);
737
+ if (!subscriber) return null;
738
+
739
+ let baseDate: Date;
740
+ if (subscriber.trial_ends_at) {
741
+ baseDate = new Date(subscriber.trial_ends_at.replace(" ", "T") + "Z");
742
+ } else {
743
+ baseDate = new Date();
744
+ }
745
+
746
+ baseDate.setDate(baseDate.getDate() + days);
747
+ const newTrialEnd = baseDate.toISOString().replace("T", " ").replace("Z", "").split(".")[0];
748
+
749
+ db.prepare(
750
+ `UPDATE subscribers SET trial_ends_at = ?, status = 'trialing', updated_at = datetime('now') WHERE id = ?`
751
+ ).run(newTrialEnd, id);
752
+
753
+ recordEvent(id, "trial_extended", { days, new_trial_ends_at: newTrialEnd });
754
+
755
+ return getSubscriber(id);
756
+ }
757
+
758
+ // --- Dunning ---
759
+
760
+ export interface DunningAttempt {
761
+ id: string;
762
+ subscriber_id: string;
763
+ attempt_number: number;
764
+ status: "pending" | "retrying" | "failed" | "recovered";
765
+ next_retry_at: string | null;
766
+ created_at: string;
767
+ }
768
+
769
+ interface DunningRow {
770
+ id: string;
771
+ subscriber_id: string;
772
+ attempt_number: number;
773
+ status: string;
774
+ next_retry_at: string | null;
775
+ created_at: string;
776
+ }
777
+
778
+ function rowToDunning(row: DunningRow): DunningAttempt {
779
+ return {
780
+ ...row,
781
+ status: row.status as DunningAttempt["status"],
782
+ };
783
+ }
784
+
785
+ export interface CreateDunningInput {
786
+ subscriber_id: string;
787
+ attempt_number?: number;
788
+ status?: DunningAttempt["status"];
789
+ next_retry_at?: string;
790
+ }
791
+
792
+ export function createDunning(input: CreateDunningInput): DunningAttempt {
793
+ const db = getDatabase();
794
+ const id = crypto.randomUUID();
795
+ const attemptNumber = input.attempt_number || 1;
796
+ const status = input.status || "pending";
797
+ const nextRetryAt = input.next_retry_at || null;
798
+
799
+ db.prepare(
800
+ `INSERT INTO dunning_attempts (id, subscriber_id, attempt_number, status, next_retry_at)
801
+ VALUES (?, ?, ?, ?, ?)`
802
+ ).run(id, input.subscriber_id, attemptNumber, status, nextRetryAt);
803
+
804
+ return getDunning(id)!;
805
+ }
806
+
807
+ export function getDunning(id: string): DunningAttempt | null {
808
+ const db = getDatabase();
809
+ const row = db.prepare("SELECT * FROM dunning_attempts WHERE id = ?").get(id) as DunningRow | null;
810
+ return row ? rowToDunning(row) : null;
811
+ }
812
+
813
+ export interface ListDunningOptions {
814
+ subscriber_id?: string;
815
+ status?: string;
816
+ limit?: number;
817
+ offset?: number;
818
+ }
819
+
820
+ export function listDunning(options: ListDunningOptions = {}): DunningAttempt[] {
821
+ const db = getDatabase();
822
+ const conditions: string[] = [];
823
+ const params: unknown[] = [];
824
+
825
+ if (options.subscriber_id) {
826
+ conditions.push("subscriber_id = ?");
827
+ params.push(options.subscriber_id);
828
+ }
829
+
830
+ if (options.status) {
831
+ conditions.push("status = ?");
832
+ params.push(options.status);
833
+ }
834
+
835
+ let sql = "SELECT * FROM dunning_attempts";
836
+ if (conditions.length > 0) {
837
+ sql += " WHERE " + conditions.join(" AND ");
838
+ }
839
+ sql += " ORDER BY created_at DESC";
840
+
841
+ if (options.limit) {
842
+ sql += " LIMIT ?";
843
+ params.push(options.limit);
844
+ }
845
+ if (options.offset) {
846
+ sql += " OFFSET ?";
847
+ params.push(options.offset);
848
+ }
849
+
850
+ const rows = db.prepare(sql).all(...params) as DunningRow[];
851
+ return rows.map(rowToDunning);
852
+ }
853
+
854
+ export interface UpdateDunningInput {
855
+ status?: DunningAttempt["status"];
856
+ next_retry_at?: string | null;
857
+ }
858
+
859
+ export function updateDunning(id: string, input: UpdateDunningInput): DunningAttempt | null {
860
+ const db = getDatabase();
861
+ const existing = getDunning(id);
862
+ if (!existing) return null;
863
+
864
+ const sets: string[] = [];
865
+ const params: unknown[] = [];
866
+
867
+ if (input.status !== undefined) {
868
+ sets.push("status = ?");
869
+ params.push(input.status);
870
+ }
871
+ if (input.next_retry_at !== undefined) {
872
+ sets.push("next_retry_at = ?");
873
+ params.push(input.next_retry_at);
874
+ }
875
+
876
+ if (sets.length === 0) return existing;
877
+
878
+ params.push(id);
879
+
880
+ db.prepare(
881
+ `UPDATE dunning_attempts SET ${sets.join(", ")} WHERE id = ?`
882
+ ).run(...params);
883
+
884
+ return getDunning(id);
885
+ }
886
+
887
+ // --- Bulk Import/Export ---
888
+
889
+ export interface BulkImportSubscriberInput {
890
+ plan_id: string;
891
+ customer_name: string;
892
+ customer_email: string;
893
+ status?: Subscriber["status"];
894
+ trial_ends_at?: string;
895
+ current_period_end?: string;
896
+ metadata?: Record<string, unknown>;
897
+ }
898
+
899
+ export function bulkImportSubscribers(data: BulkImportSubscriberInput[]): Subscriber[] {
900
+ const results: Subscriber[] = [];
901
+ for (const item of data) {
902
+ const subscriber = createSubscriber(item);
903
+ results.push(subscriber);
904
+ }
905
+ return results;
906
+ }
907
+
908
+ export function exportSubscribers(format: "csv" | "json" = "json"): string {
909
+ const subscribers = listSubscribers();
910
+
911
+ if (format === "json") {
912
+ return JSON.stringify(subscribers, null, 2);
913
+ }
914
+
915
+ // CSV format
916
+ if (subscribers.length === 0) return "";
917
+
918
+ const headers = [
919
+ "id", "plan_id", "customer_name", "customer_email", "status",
920
+ "started_at", "trial_ends_at", "current_period_start", "current_period_end",
921
+ "canceled_at", "resume_at", "created_at", "updated_at",
922
+ ];
923
+
924
+ const csvRows = [headers.join(",")];
925
+ for (const sub of subscribers) {
926
+ const row = headers.map((h) => {
927
+ const val = sub[h as keyof Subscriber];
928
+ if (val === null || val === undefined) return "";
929
+ if (typeof val === "object") return JSON.stringify(val).replace(/,/g, ";");
930
+ return String(val).includes(",") ? `"${String(val)}"` : String(val);
931
+ });
932
+ csvRows.push(row.join(","));
933
+ }
934
+ return csvRows.join("\n");
935
+ }
936
+
937
+ export function parseImportCsv(csvContent: string): BulkImportSubscriberInput[] {
938
+ const lines = csvContent.trim().split("\n");
939
+ if (lines.length < 2) return [];
940
+
941
+ const headers = lines[0].split(",").map((h) => h.trim());
942
+ const results: BulkImportSubscriberInput[] = [];
943
+
944
+ for (let i = 1; i < lines.length; i++) {
945
+ const values = lines[i].split(",").map((v) => v.trim().replace(/^"|"$/g, ""));
946
+ const record: Record<string, string> = {};
947
+ for (let j = 0; j < headers.length; j++) {
948
+ record[headers[j]] = values[j] || "";
949
+ }
950
+
951
+ if (!record["plan_id"] || !record["customer_name"] || !record["customer_email"]) continue;
952
+
953
+ results.push({
954
+ plan_id: record["plan_id"],
955
+ customer_name: record["customer_name"],
956
+ customer_email: record["customer_email"],
957
+ status: (record["status"] as Subscriber["status"]) || undefined,
958
+ trial_ends_at: record["trial_ends_at"] || undefined,
959
+ current_period_end: record["current_period_end"] || undefined,
960
+ });
961
+ }
962
+
963
+ return results;
964
+ }
965
+
966
+ // --- LTV Calculation ---
967
+
968
+ export interface LtvResult {
969
+ subscriber_id: string;
970
+ customer_name: string;
971
+ customer_email: string;
972
+ plan_name: string;
973
+ plan_price: number;
974
+ plan_interval: string;
975
+ months_active: number;
976
+ ltv: number;
977
+ }
978
+
979
+ export function getLtv(): { subscribers: LtvResult[]; average_ltv: number } {
980
+ const db = getDatabase();
981
+ const rows = db.prepare(`
982
+ SELECT
983
+ s.id as subscriber_id,
984
+ s.customer_name,
985
+ s.customer_email,
986
+ s.started_at,
987
+ s.canceled_at,
988
+ s.status,
989
+ p.name as plan_name,
990
+ p.price as plan_price,
991
+ p.interval as plan_interval
992
+ FROM subscribers s
993
+ JOIN plans p ON s.plan_id = p.id
994
+ ORDER BY s.customer_name
995
+ `).all() as {
996
+ subscriber_id: string;
997
+ customer_name: string;
998
+ customer_email: string;
999
+ started_at: string;
1000
+ canceled_at: string | null;
1001
+ status: string;
1002
+ plan_name: string;
1003
+ plan_price: number;
1004
+ plan_interval: string;
1005
+ }[];
1006
+
1007
+ const results: LtvResult[] = [];
1008
+ let totalLtv = 0;
1009
+
1010
+ for (const row of rows) {
1011
+ const startDate = new Date(row.started_at.replace(" ", "T") + "Z");
1012
+ const endDate = row.canceled_at
1013
+ ? new Date(row.canceled_at.replace(" ", "T") + "Z")
1014
+ : new Date();
1015
+
1016
+ const monthsDiff = Math.max(
1017
+ 1,
1018
+ (endDate.getFullYear() - startDate.getFullYear()) * 12 +
1019
+ (endDate.getMonth() - startDate.getMonth())
1020
+ );
1021
+
1022
+ let monthlyPrice: number;
1023
+ if (row.plan_interval === "monthly") {
1024
+ monthlyPrice = row.plan_price;
1025
+ } else if (row.plan_interval === "yearly") {
1026
+ monthlyPrice = row.plan_price / 12;
1027
+ } else {
1028
+ // lifetime — one-time payment
1029
+ monthlyPrice = 0;
1030
+ }
1031
+
1032
+ const ltv = row.plan_interval === "lifetime"
1033
+ ? row.plan_price
1034
+ : Math.round(monthlyPrice * monthsDiff * 100) / 100;
1035
+
1036
+ results.push({
1037
+ subscriber_id: row.subscriber_id,
1038
+ customer_name: row.customer_name,
1039
+ customer_email: row.customer_email,
1040
+ plan_name: row.plan_name,
1041
+ plan_price: row.plan_price,
1042
+ plan_interval: row.plan_interval,
1043
+ months_active: monthsDiff,
1044
+ ltv,
1045
+ });
1046
+
1047
+ totalLtv += ltv;
1048
+ }
1049
+
1050
+ const averageLtv = results.length > 0
1051
+ ? Math.round((totalLtv / results.length) * 100) / 100
1052
+ : 0;
1053
+
1054
+ return { subscribers: results, average_ltv: averageLtv };
1055
+ }
1056
+
1057
+ // --- NRR (Net Revenue Retention) ---
1058
+
1059
+ export interface NrrResult {
1060
+ month: string;
1061
+ start_mrr: number;
1062
+ expansion: number;
1063
+ contraction: number;
1064
+ churn: number;
1065
+ nrr: number;
1066
+ }
1067
+
1068
+ export function getNrr(month: string): NrrResult {
1069
+ const db = getDatabase();
1070
+
1071
+ // Parse the month (YYYY-MM format)
1072
+ const [year, mon] = month.split("-").map(Number);
1073
+ const monthStart = `${year}-${String(mon).padStart(2, "0")}-01 00:00:00`;
1074
+ const nextMonth = mon === 12 ? `${year + 1}-01-01 00:00:00` : `${year}-${String(mon + 1).padStart(2, "0")}-01 00:00:00`;
1075
+
1076
+ // Start MRR: sum of active subscribers at start of month (those created before month start and not canceled before it)
1077
+ const startMrrRow = db.prepare(`
1078
+ SELECT COALESCE(SUM(
1079
+ CASE
1080
+ WHEN p.interval = 'monthly' THEN p.price
1081
+ WHEN p.interval = 'yearly' THEN p.price / 12.0
1082
+ ELSE 0
1083
+ END
1084
+ ), 0) as mrr
1085
+ FROM subscribers s
1086
+ JOIN plans p ON s.plan_id = p.id
1087
+ WHERE s.started_at < ?
1088
+ AND (s.canceled_at IS NULL OR s.canceled_at >= ?)
1089
+ AND s.status != 'paused'
1090
+ `).get(monthStart, monthStart) as { mrr: number };
1091
+ const startMrr = Math.round(startMrrRow.mrr * 100) / 100;
1092
+
1093
+ // Expansion: MRR from upgrades during this month
1094
+ const expansionRow = db.prepare(`
1095
+ SELECT COALESCE(SUM(
1096
+ CASE
1097
+ WHEN new_p.interval = 'monthly' THEN new_p.price - CASE WHEN old_p.interval = 'monthly' THEN old_p.price WHEN old_p.interval = 'yearly' THEN old_p.price / 12.0 ELSE 0 END
1098
+ WHEN new_p.interval = 'yearly' THEN new_p.price / 12.0 - CASE WHEN old_p.interval = 'monthly' THEN old_p.price WHEN old_p.interval = 'yearly' THEN old_p.price / 12.0 ELSE 0 END
1099
+ ELSE 0
1100
+ END
1101
+ ), 0) as expansion
1102
+ FROM events e
1103
+ JOIN subscribers s ON e.subscriber_id = s.id
1104
+ JOIN plans new_p ON json_extract(e.details, '$.new_plan_id') = new_p.id
1105
+ JOIN plans old_p ON json_extract(e.details, '$.old_plan_id') = old_p.id
1106
+ WHERE e.type = 'upgraded'
1107
+ AND e.occurred_at >= ? AND e.occurred_at < ?
1108
+ `).get(monthStart, nextMonth) as { expansion: number };
1109
+ const expansion = Math.max(0, Math.round(expansionRow.expansion * 100) / 100);
1110
+
1111
+ // Contraction: MRR lost from downgrades during this month
1112
+ const contractionRow = db.prepare(`
1113
+ SELECT COALESCE(SUM(
1114
+ CASE
1115
+ WHEN old_p.interval = 'monthly' THEN old_p.price - CASE WHEN new_p.interval = 'monthly' THEN new_p.price WHEN new_p.interval = 'yearly' THEN new_p.price / 12.0 ELSE 0 END
1116
+ WHEN old_p.interval = 'yearly' THEN old_p.price / 12.0 - CASE WHEN new_p.interval = 'monthly' THEN new_p.price WHEN new_p.interval = 'yearly' THEN new_p.price / 12.0 ELSE 0 END
1117
+ ELSE 0
1118
+ END
1119
+ ), 0) as contraction
1120
+ FROM events e
1121
+ JOIN subscribers s ON e.subscriber_id = s.id
1122
+ JOIN plans new_p ON json_extract(e.details, '$.new_plan_id') = new_p.id
1123
+ JOIN plans old_p ON json_extract(e.details, '$.old_plan_id') = old_p.id
1124
+ WHERE e.type = 'downgraded'
1125
+ AND e.occurred_at >= ? AND e.occurred_at < ?
1126
+ `).get(monthStart, nextMonth) as { contraction: number };
1127
+ const contraction = Math.max(0, Math.round(contractionRow.contraction * 100) / 100);
1128
+
1129
+ // Churn: MRR lost from cancellations during this month
1130
+ const churnRow = db.prepare(`
1131
+ SELECT COALESCE(SUM(
1132
+ CASE
1133
+ WHEN p.interval = 'monthly' THEN p.price
1134
+ WHEN p.interval = 'yearly' THEN p.price / 12.0
1135
+ ELSE 0
1136
+ END
1137
+ ), 0) as churn
1138
+ FROM subscribers s
1139
+ JOIN plans p ON s.plan_id = p.id
1140
+ WHERE s.status = 'canceled'
1141
+ AND s.canceled_at >= ? AND s.canceled_at < ?
1142
+ `).get(monthStart, nextMonth) as { churn: number };
1143
+ const churnMrr = Math.round(churnRow.churn * 100) / 100;
1144
+
1145
+ // NRR = (start_mrr + expansion - contraction - churn) / start_mrr * 100
1146
+ const nrr = startMrr > 0
1147
+ ? Math.round(((startMrr + expansion - contraction - churnMrr) / startMrr) * 100 * 100) / 100
1148
+ : 0;
1149
+
1150
+ return {
1151
+ month,
1152
+ start_mrr: startMrr,
1153
+ expansion,
1154
+ contraction,
1155
+ churn: churnMrr,
1156
+ nrr,
1157
+ };
1158
+ }
1159
+
1160
+ // --- Cohort Analysis ---
1161
+
1162
+ export interface CohortRow {
1163
+ cohort: string;
1164
+ total: number;
1165
+ retained: number;
1166
+ retention_rate: number;
1167
+ }
1168
+
1169
+ export function getCohortReport(months: number = 6): CohortRow[] {
1170
+ const db = getDatabase();
1171
+ const now = new Date();
1172
+ const results: CohortRow[] = [];
1173
+
1174
+ for (let i = months - 1; i >= 0; i--) {
1175
+ const cohortDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
1176
+ const cohortStart = `${cohortDate.getFullYear()}-${String(cohortDate.getMonth() + 1).padStart(2, "0")}-01 00:00:00`;
1177
+ const cohortEnd = cohortDate.getMonth() === 11
1178
+ ? `${cohortDate.getFullYear() + 1}-01-01 00:00:00`
1179
+ : `${cohortDate.getFullYear()}-${String(cohortDate.getMonth() + 2).padStart(2, "0")}-01 00:00:00`;
1180
+ const cohortLabel = `${cohortDate.getFullYear()}-${String(cohortDate.getMonth() + 1).padStart(2, "0")}`;
1181
+
1182
+ // Total subscribers who signed up in this cohort month
1183
+ const totalRow = db.prepare(`
1184
+ SELECT COUNT(*) as count FROM subscribers
1185
+ WHERE started_at >= ? AND started_at < ?
1186
+ `).get(cohortStart, cohortEnd) as { count: number };
1187
+
1188
+ // Retained: those from this cohort who are still active/trialing/past_due (not canceled/expired)
1189
+ const retainedRow = db.prepare(`
1190
+ SELECT COUNT(*) as count FROM subscribers
1191
+ WHERE started_at >= ? AND started_at < ?
1192
+ AND status IN ('active', 'trialing', 'past_due', 'paused')
1193
+ `).get(cohortStart, cohortEnd) as { count: number };
1194
+
1195
+ const retentionRate = totalRow.count > 0
1196
+ ? Math.round((retainedRow.count / totalRow.count) * 100 * 100) / 100
1197
+ : 0;
1198
+
1199
+ results.push({
1200
+ cohort: cohortLabel,
1201
+ total: totalRow.count,
1202
+ retained: retainedRow.count,
1203
+ retention_rate: retentionRate,
1204
+ });
1205
+ }
1206
+
1207
+ return results;
1208
+ }
1209
+
1210
+ // --- Plan Comparison ---
1211
+
1212
+ export interface PlanComparison {
1213
+ plan1: Plan;
1214
+ plan2: Plan;
1215
+ price_diff: number;
1216
+ price_diff_pct: number;
1217
+ features_only_in_plan1: string[];
1218
+ features_only_in_plan2: string[];
1219
+ common_features: string[];
1220
+ interval_match: boolean;
1221
+ }
1222
+
1223
+ export function comparePlans(id1: string, id2: string): PlanComparison | null {
1224
+ const plan1 = getPlan(id1);
1225
+ const plan2 = getPlan(id2);
1226
+ if (!plan1 || !plan2) return null;
1227
+
1228
+ const features1 = new Set(plan1.features);
1229
+ const features2 = new Set(plan2.features);
1230
+
1231
+ const commonFeatures = plan1.features.filter((f) => features2.has(f));
1232
+ const onlyIn1 = plan1.features.filter((f) => !features2.has(f));
1233
+ const onlyIn2 = plan2.features.filter((f) => !features1.has(f));
1234
+
1235
+ const priceDiff = Math.round((plan2.price - plan1.price) * 100) / 100;
1236
+ const priceDiffPct = plan1.price > 0
1237
+ ? Math.round(((plan2.price - plan1.price) / plan1.price) * 100 * 100) / 100
1238
+ : 0;
1239
+
1240
+ return {
1241
+ plan1,
1242
+ plan2,
1243
+ price_diff: priceDiff,
1244
+ price_diff_pct: priceDiffPct,
1245
+ features_only_in_plan1: onlyIn1,
1246
+ features_only_in_plan2: onlyIn2,
1247
+ common_features: commonFeatures,
1248
+ interval_match: plan1.interval === plan2.interval,
1249
+ };
1250
+ }
1251
+
1252
+ // --- Expiring Renewals (alias for listExpiring with explicit name) ---
1253
+
1254
+ export function getExpiringRenewals(days: number = 7): Subscriber[] {
1255
+ return listExpiring(days);
1256
+ }