@hasna/microservices 0.0.4 → 0.0.6

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 (57) hide show
  1. package/bin/index.js +9 -1
  2. package/bin/mcp.js +9 -1
  3. package/dist/index.js +9 -1
  4. package/microservices/microservice-ads/src/cli/index.ts +198 -0
  5. package/microservices/microservice-ads/src/db/campaigns.ts +304 -0
  6. package/microservices/microservice-ads/src/mcp/index.ts +160 -0
  7. package/microservices/microservice-company/package.json +27 -0
  8. package/microservices/microservice-company/src/cli/index.ts +1126 -0
  9. package/microservices/microservice-company/src/db/company.ts +854 -0
  10. package/microservices/microservice-company/src/db/database.ts +93 -0
  11. package/microservices/microservice-company/src/db/migrations.ts +214 -0
  12. package/microservices/microservice-company/src/db/workflow-migrations.ts +44 -0
  13. package/microservices/microservice-company/src/index.ts +60 -0
  14. package/microservices/microservice-company/src/lib/audit.ts +168 -0
  15. package/microservices/microservice-company/src/lib/finance.ts +299 -0
  16. package/microservices/microservice-company/src/lib/settings.ts +85 -0
  17. package/microservices/microservice-company/src/lib/workflows.ts +698 -0
  18. package/microservices/microservice-company/src/mcp/index.ts +991 -0
  19. package/microservices/microservice-contracts/src/cli/index.ts +410 -23
  20. package/microservices/microservice-contracts/src/db/contracts.ts +430 -1
  21. package/microservices/microservice-contracts/src/db/migrations.ts +83 -0
  22. package/microservices/microservice-contracts/src/mcp/index.ts +312 -3
  23. package/microservices/microservice-domains/src/cli/index.ts +673 -0
  24. package/microservices/microservice-domains/src/db/domains.ts +613 -0
  25. package/microservices/microservice-domains/src/index.ts +21 -0
  26. package/microservices/microservice-domains/src/lib/brandsight.ts +285 -0
  27. package/microservices/microservice-domains/src/lib/godaddy.ts +328 -0
  28. package/microservices/microservice-domains/src/lib/namecheap.ts +474 -0
  29. package/microservices/microservice-domains/src/lib/registrar.ts +355 -0
  30. package/microservices/microservice-domains/src/mcp/index.ts +413 -0
  31. package/microservices/microservice-hiring/src/cli/index.ts +318 -8
  32. package/microservices/microservice-hiring/src/db/hiring.ts +503 -0
  33. package/microservices/microservice-hiring/src/db/migrations.ts +21 -0
  34. package/microservices/microservice-hiring/src/index.ts +29 -0
  35. package/microservices/microservice-hiring/src/lib/scoring.ts +206 -0
  36. package/microservices/microservice-hiring/src/mcp/index.ts +245 -0
  37. package/microservices/microservice-payments/src/cli/index.ts +255 -3
  38. package/microservices/microservice-payments/src/db/migrations.ts +18 -0
  39. package/microservices/microservice-payments/src/db/payments.ts +552 -0
  40. package/microservices/microservice-payments/src/mcp/index.ts +223 -0
  41. package/microservices/microservice-payroll/src/cli/index.ts +269 -0
  42. package/microservices/microservice-payroll/src/db/migrations.ts +26 -0
  43. package/microservices/microservice-payroll/src/db/payroll.ts +636 -0
  44. package/microservices/microservice-payroll/src/mcp/index.ts +246 -0
  45. package/microservices/microservice-shipping/src/cli/index.ts +211 -3
  46. package/microservices/microservice-shipping/src/db/migrations.ts +8 -0
  47. package/microservices/microservice-shipping/src/db/shipping.ts +453 -3
  48. package/microservices/microservice-shipping/src/mcp/index.ts +149 -1
  49. package/microservices/microservice-social/src/cli/index.ts +244 -2
  50. package/microservices/microservice-social/src/db/migrations.ts +33 -0
  51. package/microservices/microservice-social/src/db/social.ts +378 -4
  52. package/microservices/microservice-social/src/mcp/index.ts +221 -1
  53. package/microservices/microservice-subscriptions/src/cli/index.ts +315 -0
  54. package/microservices/microservice-subscriptions/src/db/migrations.ts +68 -0
  55. package/microservices/microservice-subscriptions/src/db/subscriptions.ts +567 -3
  56. package/microservices/microservice-subscriptions/src/mcp/index.ts +267 -1
  57. package/package.json +1 -1
@@ -42,12 +42,13 @@ export interface Subscriber {
42
42
  plan_id: string;
43
43
  customer_name: string;
44
44
  customer_email: string;
45
- status: "trialing" | "active" | "past_due" | "canceled" | "expired";
45
+ status: "trialing" | "active" | "past_due" | "canceled" | "expired" | "paused";
46
46
  started_at: string;
47
47
  trial_ends_at: string | null;
48
48
  current_period_start: string;
49
49
  current_period_end: string | null;
50
50
  canceled_at: string | null;
51
+ resume_at: string | null;
51
52
  metadata: Record<string, unknown>;
52
53
  created_at: string;
53
54
  updated_at: string;
@@ -64,6 +65,7 @@ interface SubscriberRow {
64
65
  current_period_start: string;
65
66
  current_period_end: string | null;
66
67
  canceled_at: string | null;
68
+ resume_at: string | null;
67
69
  metadata: string;
68
70
  created_at: string;
69
71
  updated_at: string;
@@ -80,7 +82,7 @@ function rowToSubscriber(row: SubscriberRow): Subscriber {
80
82
  export interface SubscriptionEvent {
81
83
  id: string;
82
84
  subscriber_id: string;
83
- type: "created" | "upgraded" | "downgraded" | "canceled" | "renewed" | "payment_failed";
85
+ type: "created" | "upgraded" | "downgraded" | "canceled" | "renewed" | "payment_failed" | "paused" | "resumed" | "trial_extended";
84
86
  occurred_at: string;
85
87
  details: Record<string, unknown>;
86
88
  }
@@ -582,7 +584,7 @@ export function getMrr(): number {
582
584
  ), 0) as mrr
583
585
  FROM subscribers s
584
586
  JOIN plans p ON s.plan_id = p.id
585
- WHERE s.status IN ('active', 'trialing', 'past_due')
587
+ WHERE s.status IN ('active', 'trialing', 'past_due') AND s.status != 'paused'
586
588
  `).get() as { mrr: number };
587
589
  return Math.round(row.mrr * 100) / 100;
588
590
  }
@@ -653,6 +655,7 @@ export function getSubscriberStats(): {
653
655
  past_due: number;
654
656
  canceled: number;
655
657
  expired: number;
658
+ paused: number;
656
659
  } {
657
660
  const db = getDatabase();
658
661
  const rows = db.prepare(`
@@ -666,6 +669,7 @@ export function getSubscriberStats(): {
666
669
  past_due: 0,
667
670
  canceled: 0,
668
671
  expired: 0,
672
+ paused: 0,
669
673
  };
670
674
 
671
675
  for (const row of rows) {
@@ -690,3 +694,563 @@ export function countSubscribers(): number {
690
694
  const row = db.prepare("SELECT COUNT(*) as count FROM subscribers").get() as { count: number };
691
695
  return row.count;
692
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
+ }