@hasna/microservices 0.0.4 → 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.
- package/microservices/microservice-ads/src/cli/index.ts +198 -0
- package/microservices/microservice-ads/src/db/campaigns.ts +304 -0
- package/microservices/microservice-ads/src/mcp/index.ts +160 -0
- package/microservices/microservice-contracts/src/cli/index.ts +410 -23
- package/microservices/microservice-contracts/src/db/contracts.ts +430 -1
- package/microservices/microservice-contracts/src/db/migrations.ts +83 -0
- package/microservices/microservice-contracts/src/mcp/index.ts +312 -3
- package/microservices/microservice-domains/src/cli/index.ts +253 -0
- package/microservices/microservice-domains/src/db/domains.ts +613 -0
- package/microservices/microservice-domains/src/index.ts +21 -0
- package/microservices/microservice-domains/src/mcp/index.ts +168 -0
- package/microservices/microservice-hiring/src/cli/index.ts +318 -8
- package/microservices/microservice-hiring/src/db/hiring.ts +503 -0
- package/microservices/microservice-hiring/src/db/migrations.ts +21 -0
- package/microservices/microservice-hiring/src/index.ts +29 -0
- package/microservices/microservice-hiring/src/lib/scoring.ts +206 -0
- package/microservices/microservice-hiring/src/mcp/index.ts +245 -0
- package/microservices/microservice-payments/src/cli/index.ts +255 -3
- package/microservices/microservice-payments/src/db/migrations.ts +18 -0
- package/microservices/microservice-payments/src/db/payments.ts +552 -0
- package/microservices/microservice-payments/src/mcp/index.ts +223 -0
- package/microservices/microservice-payroll/src/cli/index.ts +269 -0
- package/microservices/microservice-payroll/src/db/migrations.ts +26 -0
- package/microservices/microservice-payroll/src/db/payroll.ts +636 -0
- package/microservices/microservice-payroll/src/mcp/index.ts +246 -0
- package/microservices/microservice-shipping/src/cli/index.ts +211 -3
- package/microservices/microservice-shipping/src/db/migrations.ts +8 -0
- package/microservices/microservice-shipping/src/db/shipping.ts +453 -3
- package/microservices/microservice-shipping/src/mcp/index.ts +149 -1
- package/microservices/microservice-social/src/cli/index.ts +244 -2
- package/microservices/microservice-social/src/db/migrations.ts +33 -0
- package/microservices/microservice-social/src/db/social.ts +378 -4
- package/microservices/microservice-social/src/mcp/index.ts +221 -1
- package/microservices/microservice-subscriptions/src/cli/index.ts +315 -0
- package/microservices/microservice-subscriptions/src/db/migrations.ts +68 -0
- package/microservices/microservice-subscriptions/src/db/subscriptions.ts +567 -3
- package/microservices/microservice-subscriptions/src/mcp/index.ts +267 -1
- 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
|
+
}
|