Skip to content

Commit 94be796

Browse files
authored
Merge branch 'main' into feat/pii-encryption
2 parents b45b7f7 + b71611b commit 94be796

61 files changed

Lines changed: 6223 additions & 1071 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deploy.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Deployment Pipeline
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
workflow_dispatch:
8+
inputs:
9+
environment:
10+
description: 'Environment to deploy to'
11+
required: true
12+
default: 'development'
13+
type: choice
14+
options:
15+
- development
16+
- staging
17+
- production
18+
19+
jobs:
20+
deploy-dev:
21+
name: Deploy to Development
22+
runs-on: ubuntu-latest
23+
environment:
24+
name: development
25+
steps:
26+
- uses: actions/checkout@v3
27+
- name: Deploy
28+
run: echo "Deploying to development environment..."
29+
30+
deploy-staging:
31+
name: Deploy to Staging
32+
needs: deploy-dev
33+
runs-on: ubuntu-latest
34+
environment:
35+
name: staging
36+
url: https://staging.subtrackr.app
37+
steps:
38+
- uses: actions/checkout@v3
39+
- name: Deploy
40+
run: echo "Deploying to staging environment with manual approval gate..."
41+
42+
deploy-prod:
43+
name: Deploy to Production
44+
needs: deploy-staging
45+
runs-on: ubuntu-latest
46+
environment:
47+
name: production
48+
url: https://subtrackr.app
49+
steps:
50+
- uses: actions/checkout@v3
51+
- name: Deploy
52+
run: echo "Deploying to production environment with strict manual approval gate..."

backend/ml/churnModel.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,35 @@ def _get_recommended_action(self, risk_level: str, top_factors: List[Dict]) -> s
8585
else:
8686
return "Offer a 1-month free subscription to retain user."
8787

88+
89+
class RevenueForecastModel:
90+
def forecast(self, observations: List[Dict], horizon: int = 3) -> List[Dict]:
91+
values = [float(item.get("revenue", 0)) for item in observations]
92+
if not values:
93+
return []
94+
95+
latest = values[-1]
96+
deltas = [values[index] - values[index - 1] for index in range(1, len(values))]
97+
average_delta = sum(deltas) / len(deltas) if deltas else 0
98+
variance = (
99+
sum((delta - average_delta) ** 2 for delta in deltas) / len(deltas)
100+
if deltas
101+
else max(latest * 0.05, 1)
102+
)
103+
deviation = math.sqrt(variance)
104+
105+
forecast = []
106+
for step in range(1, horizon + 1):
107+
expected = max(0, latest + average_delta * step)
108+
confidence = deviation * math.sqrt(step) * 1.96
109+
forecast.append({
110+
"period": f"forecast_{step}",
111+
"expected_revenue": round(expected, 2),
112+
"lower_bound": round(max(0, expected - confidence), 2),
113+
"upper_bound": round(expected + confidence, 2),
114+
})
115+
return forecast
116+
88117
if __name__ == "__main__":
89118
model = ChurnPredictionModel()
90119
test_data = {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export interface UsageMetric {
2+
userId: string;
3+
metricType: 'api' | 'compute' | 'storage';
4+
amount: number;
5+
timestamp: Date;
6+
}
7+
8+
export class MeteringService {
9+
private thresholdAlerts = [0.8, 1.0, 1.2]; // 80%, 100%, 120%
10+
11+
async recordUsage(metric: UsageMetric): Promise<void> {
12+
// Low-latency metering pipeline integration
13+
console.log(`Recorded ${metric.amount} for ${metric.metricType}`);
14+
15+
await this.checkThresholds(metric.userId);
16+
}
17+
18+
async checkThresholds(userId: string): Promise<void> {
19+
// Check usage against thresholds and trigger alerts
20+
console.log(`Checked thresholds for ${userId}`);
21+
}
22+
23+
async calculateOverage(userId: string): Promise<number> {
24+
// Tiered overage calculation
25+
return 0;
26+
}
27+
}

backend/services/dunningService.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import type {
2+
DunningAnalytics,
3+
DunningCommunication,
4+
DunningCommunicationTemplate,
5+
DunningConfiguration,
6+
DunningEntry,
7+
DunningStage,
8+
DunningStageConfig,
9+
} from '../../src/types/dunning';
10+
import { DEFAULT_DUNNING_STAGES, DUNNING_TEMPLATES } from '../../src/types/dunning';
11+
12+
const ONE_HOUR_MS = 3_600_000;
13+
14+
const now = (): number => Date.now();
15+
16+
const createId = (prefix: string): string =>
17+
`${prefix}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
18+
19+
export class DunningService {
20+
private entries = new Map<string, DunningEntry>();
21+
private configurations = new Map<string, DunningConfiguration>();
22+
private communicationLog = new Map<string, DunningCommunication[]>();
23+
24+
configurePlan(planId: string, config: Partial<DunningConfiguration>): DunningConfiguration {
25+
const existing = this.configurations.get(planId);
26+
const merged: DunningConfiguration = {
27+
planId,
28+
stages: config.stages ?? existing?.stages ?? DEFAULT_DUNNING_STAGES,
29+
maxRetries: config.maxRetries ?? existing?.maxRetries ?? 3,
30+
retryIntervalHours: config.retryIntervalHours ?? existing?.retryIntervalHours ?? 1,
31+
warnAfterFailures: config.warnAfterFailures ?? existing?.warnAfterFailures ?? 3,
32+
suspendAfterDays: config.suspendAfterDays ?? existing?.suspendAfterDays ?? 3,
33+
cancelAfterDays: config.cancelAfterDays ?? existing?.cancelAfterDays ?? 7,
34+
communicationChannels: config.communicationChannels ?? existing?.communicationChannels ?? ['email', 'push'],
35+
};
36+
this.configurations.set(planId, merged);
37+
return merged;
38+
}
39+
40+
getConfiguration(planId: string): DunningConfiguration | undefined {
41+
return this.configurations.get(planId);
42+
}
43+
44+
startDunning(
45+
subscriptionId: string,
46+
subscriberId: string,
47+
merchantId: string,
48+
planId: string,
49+
): DunningEntry {
50+
const existing = this.entries.get(subscriptionId);
51+
if (existing) {
52+
return existing;
53+
}
54+
55+
const config = this.configurations.get(planId);
56+
const firstStage = config?.stages[0] ?? DEFAULT_DUNNING_STAGES[0];
57+
const now_ts = now();
58+
59+
const entry: DunningEntry = {
60+
id: createId('dun'),
61+
subscriptionId,
62+
subscriberId,
63+
merchantId,
64+
planId,
65+
currentStage: firstStage.stage,
66+
failedAttempts: 0,
67+
totalFailedCharges: 0,
68+
firstFailureAt: now_ts,
69+
lastFailureAt: now_ts,
70+
lastAttemptAt: now_ts,
71+
nextActionAt: now_ts + firstStage.delayHours * ONE_HOUR_MS,
72+
isPaused: false,
73+
communicationLog: [],
74+
createdAt: now_ts,
75+
updatedAt: now_ts,
76+
};
77+
78+
this.entries.set(subscriptionId, entry);
79+
this.communicationLog.set(subscriptionId, []);
80+
return entry;
81+
}
82+
83+
recordFailedCharge(subscriptionId: string): DunningEntry | null {
84+
const entry = this.entries.get(subscriptionId);
85+
if (!entry || entry.isPaused) return null;
86+
87+
const config = this.configurations.get(entry.planId);
88+
const now_ts = now();
89+
90+
entry.failedAttempts += 1;
91+
entry.totalFailedCharges += 1;
92+
entry.lastFailureAt = now_ts;
93+
entry.lastAttemptAt = now_ts;
94+
entry.updatedAt = now_ts;
95+
96+
const currentStageIndex = config
97+
? config.stages.findIndex((s) => s.stage === entry.currentStage)
98+
: -1;
99+
100+
const shouldAdvanceStage = (): boolean => {
101+
if (currentStageIndex < 0) return false;
102+
if (!config) return false;
103+
const stageConfig = config.stages[currentStageIndex];
104+
return entry.failedAttempts >= stageConfig.maxAttempts;
105+
};
106+
107+
if (shouldAdvanceStage() && config) {
108+
const nextStageIndex = currentStageIndex + 1;
109+
if (nextStageIndex < config.stages.length) {
110+
const nextStage = config.stages[nextStageIndex];
111+
entry.currentStage = nextStage.stage;
112+
entry.failedAttempts = 0;
113+
entry.nextActionAt = now_ts + nextStage.delayHours * ONE_HOUR_MS;
114+
this.sendCommunication(entry, nextStage);
115+
} else {
116+
entry.currentStage = 'cancel';
117+
entry.nextActionAt = now_ts + 24 * ONE_HOUR_MS;
118+
}
119+
} else {
120+
const retryDelay = config?.retryIntervalHours ?? 1;
121+
entry.nextActionAt = now_ts + retryDelay * ONE_HOUR_MS;
122+
}
123+
124+
this.entries.set(subscriptionId, entry);
125+
return entry;
126+
}
127+
128+
recordSuccessfulCharge(subscriptionId: string): void {
129+
const entry = this.entries.get(subscriptionId);
130+
if (!entry) return;
131+
132+
this.entries.delete(subscriptionId);
133+
this.communicationLog.delete(subscriptionId);
134+
}
135+
136+
getDunningEntry(subscriptionId: string): DunningEntry | undefined {
137+
return this.entries.get(subscriptionId);
138+
}
139+
140+
listActiveDunning(merchantId?: string): DunningEntry[] {
141+
const all = Array.from(this.entries.values());
142+
if (merchantId) {
143+
return all.filter((e) => e.merchantId === merchantId);
144+
}
145+
return all;
146+
}
147+
148+
pauseDunning(subscriptionId: string): DunningEntry | null {
149+
const entry = this.entries.get(subscriptionId);
150+
if (!entry) return null;
151+
entry.isPaused = true;
152+
entry.updatedAt = now();
153+
this.entries.set(subscriptionId, entry);
154+
return entry;
155+
}
156+
157+
resumeDunning(subscriptionId: string): DunningEntry | null {
158+
const entry = this.entries.get(subscriptionId);
159+
if (!entry) return null;
160+
161+
const config = this.configurations.get(entry.planId);
162+
const stageConfig = config?.stages.find((s) => s.stage === entry.currentStage);
163+
entry.isPaused = false;
164+
entry.nextActionAt = now() + (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS;
165+
entry.updatedAt = now();
166+
this.entries.set(subscriptionId, entry);
167+
return entry;
168+
}
169+
170+
overrideStage(subscriptionId: string, stage: DunningStage): DunningEntry | null {
171+
const entry = this.entries.get(subscriptionId);
172+
if (!entry) return null;
173+
174+
const config = this.configurations.get(entry.planId);
175+
const stageConfig = config?.stages.find((s) => s.stage === stage);
176+
entry.currentStage = stage;
177+
entry.failedAttempts = 0;
178+
entry.nextActionAt = now() + (stageConfig?.delayHours ?? 24) * ONE_HOUR_MS;
179+
entry.updatedAt = now();
180+
this.entries.set(subscriptionId, entry);
181+
return entry;
182+
}
183+
184+
getCommunications(subscriptionId: string): DunningCommunication[] {
185+
return this.communicationLog.get(subscriptionId) ?? [];
186+
}
187+
188+
getAnalytics(merchantId?: string): DunningAnalytics {
189+
const allEntries = this.listActiveDunning(merchantId);
190+
const stageBreakdown: Record<DunningStage, number> = {
191+
retry: 0,
192+
warn: 0,
193+
suspend: 0,
194+
cancel: 0,
195+
};
196+
197+
for (const entry of allEntries) {
198+
stageBreakdown[entry.currentStage] = (stageBreakdown[entry.currentStage] ?? 0) + 1;
199+
}
200+
201+
const totalRecovered = Array.from(this.entries.values()).filter(
202+
(e) => e.totalFailedCharges === 0
203+
).length;
204+
205+
return {
206+
totalActiveDunning: allEntries.length,
207+
stageBreakdown,
208+
recoveryRate: 0,
209+
totalRecovered,
210+
totalLost: stageBreakdown.cancel,
211+
averageDaysToRecovery: 0,
212+
stageSuccessRates: {
213+
retry: 0,
214+
warn: 0,
215+
suspend: 0,
216+
cancel: 0,
217+
},
218+
};
219+
}
220+
221+
private sendCommunication(entry: DunningEntry, stageConfig: DunningStageConfig): DunningCommunication {
222+
const template = DUNNING_TEMPLATES.find((t) => t.id === stageConfig.templateId);
223+
const comm: DunningCommunication = {
224+
id: createId('dcom'),
225+
stage: stageConfig.stage,
226+
channel: 'push',
227+
templateId: stageConfig.templateId,
228+
sentAt: now(),
229+
status: 'sent',
230+
metadata: {
231+
subscription_id: entry.subscriptionId,
232+
template_subject: template?.subject ?? '',
233+
},
234+
};
235+
236+
const log = this.communicationLog.get(entry.subscriptionId) ?? [];
237+
log.push(comm);
238+
this.communicationLog.set(entry.subscriptionId, log);
239+
entry.communicationLog.push(comm);
240+
241+
return comm;
242+
}
243+
244+
getProcessableEntries(): DunningEntry[] {
245+
const now_ts = now();
246+
return Array.from(this.entries.values()).filter(
247+
(e) => !e.isPaused && e.nextActionAt <= now_ts
248+
);
249+
}
250+
}
251+
252+
export const dunningService = new DunningService();

0 commit comments

Comments
 (0)