Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions backend/services/analytics/interventionService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { PredictionService } from './predictionService';
import { useSubscriptionStore } from '../../../src/store/subscriptionStore';
import { useSupportStore } from '../../../src/store/supportStore';

export class InterventionService {
/**
* Evaluates all active subscriptions and triggers interventions for high-risk users.
*/
static async runAutomatedInterventions(): Promise<any> {
const subs = useSubscriptionStore.getState().subscriptions.filter(s => s.isActive);

const batchSize = 10;
const interventions = [];

for (let i = 0; i < subs.length; i += batchSize) {
const batch = subs.slice(i, i + batchSize);

const payload = batch.map(s => ({
subscriberAddress: s.id,
userData: {
recentPaymentFailures: s.chargeCount ? (s.chargeCount % 2) : 0,
baselineLoginsPerMonth: 20,
recentLogins: 5, // Simulate lower engagement to force some high risk
openSupportTickets: 0,
priceSensitivityIndex: 0.8
}
}));

try {
const predictions = await PredictionService.predictChurnBatch(payload);

for (const pred of predictions) {
if (pred.riskLevel === 'High') {
const sub = batch.find(s => s.id === pred.subscriber);
if (sub) {
const result = await this.triggerDiscount(sub, pred.recommendedAction);
interventions.push({
subscriber: sub.id,
action: pred.recommendedAction,
status: result ? 'Applied' : 'Failed'
});
}
}
}
} catch (err) {
console.error('Failed prediction batch', err);
}
}

return {
interventionsTriggered: interventions.length,
details: interventions
};
}

private static async triggerDiscount(subscription: any, reason: string): Promise<boolean> {
try {
const supportStore = useSupportStore.getState();
const discountAmount = subscription.price * 0.10;
console.log(`Applying discount of ${discountAmount} to ${subscription.id} for: ${reason}`);

supportStore.createTicket({
subscriptionId: subscription.id,
issueType: 'other',
message: `Automated Churn Intervention: ${reason}`,
occurredAt: new Date(),
context: {
subscriptionName: subscription.name,
planName: subscription.name,
planTier: subscription.category,
billingCycle: subscription.billingCycle,
status: 'active',
amount: subscription.price,
currency: subscription.currency,
createdAt: new Date().toISOString(),
nextBillingDate: new Date().toISOString(),
failedPayments: 0,
chargeCount: 0,
history: []
},
dedupeKey: `intervention-${subscription.id}-${Date.now()}`,
actorId: 'system'
});
return true;
} catch (e) {
console.error(e);
return false;
}
}
}
89 changes: 89 additions & 0 deletions ml-service/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Dict, Optional
from models import ChurnPredictionModel, RevenueForecastModel
from model_registry import registry

app = FastAPI(title="SubTrackr ML Service", version="1.0.0")

class UserData(BaseModel):
recent_payment_failures: float
baseline_logins_per_month: float
recent_logins: float
open_support_tickets: float
price_sensitivity_index: float

class PredictRequest(BaseModel):
subscriber: str
user_data: UserData

class BatchPredictItem(BaseModel):
subscriber: str
user_data: UserData

class BatchPredictRequest(BaseModel):
items: List[BatchPredictItem]

class Observation(BaseModel):
period: str
revenue: float

class ForecastRequest(BaseModel):
observations: List[Observation]
horizon: int = 3

churn_model = ChurnPredictionModel()
forecast_model = RevenueForecastModel()

# Try to load a customized model from registry if available
custom_weights = registry.load_model("v1.1")
if custom_weights and "feature_weights" in custom_weights:
churn_model.feature_weights = custom_weights["feature_weights"]

@app.post("/v1/churn/predict")
async def predict_churn(req: PredictRequest):
try:
prediction = churn_model.predict_churn(req.subscriber, req.user_data.model_dump())
prediction["model_version"] = "v1.1" if custom_weights else "v1.0"
return prediction
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

@app.post("/v1/churn/predict/batch")
async def predict_churn_batch(req: BatchPredictRequest):
results = []
for item in req.items:
try:
pred = churn_model.predict_churn(item.subscriber, item.user_data.model_dump())
pred["ok"] = True
results.append(pred)
except Exception as e:
results.append({"subscriber": item.subscriber, "ok": False, "error": str(e)})

return {
"model_version": "v1.1" if custom_weights else "v1.0",
"results": results
}

@app.post("/v1/churn/forecast")
async def forecast_revenue(req: ForecastRequest):
try:
observations = [obs.model_dump() for obs in req.observations]
forecast = forecast_model.forecast(observations, req.horizon)
return forecast
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

@app.post("/v1/models/retrain")
async def retrain_model():
"""Trigger the retraining pipeline"""
new_version = registry.retrain_model([])
# Hot reload the weights
new_weights = registry.load_model(new_version)
if new_weights:
churn_model.feature_weights = new_weights["feature_weights"]
return {"status": "success", "new_version": new_version}

if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
37 changes: 37 additions & 0 deletions ml-service/model_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import os
import json
from typing import Dict, Any

class ModelRegistry:
def __init__(self, storage_dir: str = "./models"):
self.storage_dir = storage_dir
os.makedirs(self.storage_dir, exist_ok=True)

def save_model(self, model_id: str, model_data: Dict[str, Any]):
file_path = os.path.join(self.storage_dir, f"{model_id}.json")
with open(file_path, "w") as f:
json.dump(model_data, f)

def load_model(self, model_id: str) -> Dict[str, Any]:
file_path = os.path.join(self.storage_dir, f"{model_id}.json")
if not os.path.exists(file_path):
return None
with open(file_path, "r") as f:
return json.load(f)

def retrain_model(self, new_data: list):
"""Simulate a retraining pipeline updating feature weights"""
new_version = "v1.1"
self.save_model(new_version, {
"version": new_version,
"feature_weights": {
"payment_failures": 0.45,
"login_frequency_drop": 0.2,
"support_tickets": 0.15,
"app_crashes": 0.1,
"price_sensitivity": 0.1
}
})
return new_version

registry = ModelRegistry()
127 changes: 127 additions & 0 deletions ml-service/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import math
import random
from typing import Dict, List, Optional

class ChurnPredictionModel:
def __init__(self):
# Weights for different feature importance
self.feature_weights = {
"payment_failures": 0.4,
"login_frequency_drop": 0.25,
"support_tickets": 0.15,
"app_crashes": 0.1,
"price_sensitivity": 0.1
}

def _extract_features(self, user_data: Dict) -> Dict:
"""
Extract normalized features from raw user data.
"""
features = {}
# Normalize payment failures (0 to 1)
features["payment_failures"] = min(user_data.get("recent_payment_failures", 0) / 3.0, 1.0)

# Normalize login frequency drop (e.g., 50% drop -> 0.5)
baseline_logins = max(user_data.get("baseline_logins_per_month", 1), 1)
recent_logins = user_data.get("recent_logins", baseline_logins)
drop = max(0, (baseline_logins - recent_logins) / baseline_logins)
features["login_frequency_drop"] = drop

# Normalize support tickets
features["support_tickets"] = min(user_data.get("open_support_tickets", 0) / 2.0, 1.0)

# Add random noise for simulation
features["app_crashes"] = random.uniform(0, 0.2)
features["price_sensitivity"] = user_data.get("price_sensitivity_index", 0.5)

return features

def predict_churn(self, subscriber_address: str, user_data: Dict) -> Dict:
"""
Predict churn probability and return risk scoring.
"""
features = self._extract_features(user_data)

# Calculate risk score (0.0 to 1.0)
risk_score = 0.0
for feature, value in features.items():
risk_score += value * self.feature_weights.get(feature, 0.0)

# Determine risk level
if risk_score >= 0.7:
risk_level = "High"
elif risk_score >= 0.4:
risk_level = "Medium"
else:
risk_level = "Low"

# Extract top risk factors for explainability
sorted_factors = sorted(features.items(), key=lambda x: x[1] * self.feature_weights.get(x[0], 0), reverse=True)
top_factors = [
{"factor": factor[0], "impact": round(factor[1] * self.feature_weights.get(factor[0], 0), 2)}
for factor in sorted_factors if factor[1] > 0.1
]

return {
"subscriber": subscriber_address,
"churn_probability": round(risk_score, 4),
"risk_level": risk_level,
"risk_factors": top_factors,
"recommended_action": self._get_recommended_action(risk_level, top_factors)
}

def _get_recommended_action(self, risk_level: str, top_factors: List[Dict]) -> str:
if risk_level == "Low":
return "No action needed. Monitor normal activity."

primary_factor = top_factors[0]["factor"] if top_factors else "unknown"

if primary_factor == "payment_failures":
return "Send payment method update reminder with a 5% discount offer."
elif primary_factor == "login_frequency_drop":
return "Send re-engagement email highlighting new features."
elif primary_factor == "support_tickets":
return "Prioritize open support tickets for immediate resolution."
else:
return "Offer a 1-month free subscription to retain user."


class RevenueForecastModel:
def forecast(self, observations: List[Dict], horizon: int = 3) -> List[Dict]:
values = [float(item.get("revenue", 0)) for item in observations]
if not values:
return []

latest = values[-1]
deltas = [values[index] - values[index - 1] for index in range(1, len(values))]
average_delta = sum(deltas) / len(deltas) if deltas else 0
variance = (
sum((delta - average_delta) ** 2 for delta in deltas) / len(deltas)
if deltas
else max(latest * 0.05, 1)
)
deviation = math.sqrt(variance)

forecast = []
for step in range(1, horizon + 1):
expected = max(0, latest + average_delta * step)
confidence = deviation * math.sqrt(step) * 1.96
forecast.append({
"period": f"forecast_{step}",
"expected_revenue": round(expected, 2),
"lower_bound": round(max(0, expected - confidence), 2),
"upper_bound": round(expected + confidence, 2),
})
return forecast

if __name__ == "__main__":
model = ChurnPredictionModel()
test_data = {
"recent_payment_failures": 2,
"baseline_logins_per_month": 20,
"recent_logins": 5,
"open_support_tickets": 1,
"price_sensitivity_index": 0.8
}
prediction = model.predict_churn("0xDEF456", test_data)
print(f"Churn Prediction: {prediction}")
4 changes: 4 additions & 0 deletions ml-service/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fastapi
uvicorn
pydantic
scikit-learn
6 changes: 6 additions & 0 deletions src/navigation/AppNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,11 @@
);
const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsDashboard'));
const TrialDetailsScreen = lazyScreen(() => import('../screens/TrialDetailsScreen'));
const ChurnPredictionScreen = lazyScreen(() => import('../screens/ChurnPredictionScreen'));

Check failure on line 100 in src/navigation/AppNavigator.tsx

View workflow job for this annotation

GitHub Actions / Type Check

Cannot redeclare block-scoped variable 'ChurnPredictionScreen'.

// Issue #547: GDPR
const PrivacyCenterScreen = lazyScreen(() => import('../screens/PrivacyCenterScreen'));
const ChurnPredictionScreen = lazyScreen(() => import('../../app/screens/ChurnPredictionScreen'));

Check failure on line 104 in src/navigation/AppNavigator.tsx

View workflow job for this annotation

GitHub Actions / Type Check

Cannot redeclare block-scoped variable 'ChurnPredictionScreen'.

const InvoiceCustomizationScreen = lazyScreen(() =>
import('../../app/screens/InvoiceCustomizationScreen').then((m) => ({
Expand Down Expand Up @@ -438,6 +439,11 @@
component={TrialDetailsScreen}
options={{ title: 'Trial Details', headerShown: true }}
/>
<Stack.Screen
name="ChurnPrediction"
component={ChurnPredictionScreen}
options={{ title: 'Churn Analytics', headerShown: true }}
/>
</Stack.Navigator>
);

Expand Down
Loading
Loading