Skip to content

Commit fcd7fe2

Browse files
authored
Feat: analytics-webhoo-event-sourcing-groups/family: Subtrackr (#469)
1 parent 4123132 commit fcd7fe2

17 files changed

Lines changed: 707 additions & 6 deletions

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 = {

backend/services/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,13 @@ export {
1919
isWebhookEventAllowed,
2020
} from './webhook';
2121
export type { RegisterWebhookInput, WebhookDeliveryResult, WebhookEventInput } from './webhook';
22+
export {
23+
SubscriptionEventStore,
24+
subscriptionEventStore,
25+
} from './subscriptionEventStore';
26+
export type {
27+
SubscriptionEvent,
28+
SubscriptionEventPage,
29+
SubscriptionEventQuery,
30+
SubscriptionEventType,
31+
} from './subscriptionEventStore';

backend/services/predictionService.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ export interface UserChurnData {
2121
priceSensitivityIndex: number;
2222
}
2323

24+
export interface RevenueObservation {
25+
period: string;
26+
revenue: number;
27+
}
28+
29+
export interface ForecastPoint {
30+
period: string;
31+
expectedRevenue: number;
32+
lowerBound: number;
33+
upperBound: number;
34+
}
35+
2436
export class PredictionService {
2537
// Path for future Python bridge integration
2638
private static readonly _PYTHON_PATH = path.join(__dirname, '../ml/churnModel.py');
@@ -81,4 +93,34 @@ export class PredictionService {
8193
throw new Error('Failed to fetch risk factors');
8294
}
8395
}
96+
97+
static async forecastRevenue(
98+
observations: RevenueObservation[],
99+
horizon = 3
100+
): Promise<ForecastPoint[]> {
101+
if (observations.length === 0) return [];
102+
103+
const values = observations.map((entry) => entry.revenue);
104+
const latest = values[values.length - 1];
105+
const deltas = values.slice(1).map((value, index) => value - values[index]);
106+
const averageDelta = deltas.length
107+
? deltas.reduce((sum, delta) => sum + delta, 0) / deltas.length
108+
: 0;
109+
const variance = deltas.length
110+
? deltas.reduce((sum, delta) => sum + Math.pow(delta - averageDelta, 2), 0) / deltas.length
111+
: Math.max(latest * 0.05, 1);
112+
const deviation = Math.sqrt(variance);
113+
114+
return Array.from({ length: horizon }, (_, index) => {
115+
const step = index + 1;
116+
const expectedRevenue = Math.max(0, latest + averageDelta * step);
117+
const confidence = deviation * Math.sqrt(step) * 1.96;
118+
return {
119+
period: `forecast_${step}`,
120+
expectedRevenue: Number(expectedRevenue.toFixed(2)),
121+
lowerBound: Number(Math.max(0, expectedRevenue - confidence).toFixed(2)),
122+
upperBound: Number((expectedRevenue + confidence).toFixed(2)),
123+
};
124+
});
125+
}
84126
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
export type SubscriptionEventType =
2+
| 'subscription.created'
3+
| 'subscription.updated'
4+
| 'subscription.renewed'
5+
| 'subscription.cancelled'
6+
| 'subscription.payment_failed'
7+
| 'subscription.upgraded'
8+
| 'subscription.paused'
9+
| 'subscription.resumed';
10+
11+
export interface SubscriptionEvent<TPayload extends Record<string, unknown> = Record<string, unknown>> {
12+
id: string;
13+
subscriptionId: string;
14+
sequence: number;
15+
type: SubscriptionEventType;
16+
payload: TPayload;
17+
occurredAt: number;
18+
schemaVersion: number;
19+
archivedAt?: number;
20+
}
21+
22+
export interface SubscriptionEventQuery {
23+
subscriptionId?: string;
24+
type?: SubscriptionEventType;
25+
from?: number;
26+
to?: number;
27+
limit?: number;
28+
cursor?: number;
29+
includeArchived?: boolean;
30+
}
31+
32+
export interface SubscriptionEventPage {
33+
events: SubscriptionEvent[];
34+
nextCursor?: number;
35+
}
36+
37+
export class SubscriptionEventStore {
38+
private readonly events: SubscriptionEvent[] = [];
39+
private readonly sequenceBySubscription = new Map<string, number>();
40+
41+
append<TPayload extends Record<string, unknown> = Record<string, unknown>>(
42+
event: Omit<SubscriptionEvent<TPayload>, 'id' | 'sequence' | 'occurredAt' | 'schemaVersion'> &
43+
Partial<Pick<SubscriptionEvent, 'occurredAt' | 'schemaVersion'>>
44+
): SubscriptionEvent<TPayload> {
45+
const nextSequence = (this.sequenceBySubscription.get(event.subscriptionId) ?? 0) + 1;
46+
this.sequenceBySubscription.set(event.subscriptionId, nextSequence);
47+
48+
const record: SubscriptionEvent<TPayload> = {
49+
...event,
50+
id: `sev_${Date.now().toString(36)}_${nextSequence}`,
51+
sequence: nextSequence,
52+
occurredAt: event.occurredAt ?? Date.now(),
53+
schemaVersion: event.schemaVersion ?? 1,
54+
};
55+
this.events.push(record);
56+
return record;
57+
}
58+
59+
query(query: SubscriptionEventQuery = {}): SubscriptionEventPage {
60+
const cursor = query.cursor ?? 0;
61+
const limit = Math.max(1, query.limit ?? 50);
62+
const filtered = this.events.filter((event) => {
63+
if (!query.includeArchived && event.archivedAt) return false;
64+
if (query.subscriptionId && event.subscriptionId !== query.subscriptionId) return false;
65+
if (query.type && event.type !== query.type) return false;
66+
if (query.from && event.occurredAt < query.from) return false;
67+
if (query.to && event.occurredAt > query.to) return false;
68+
return true;
69+
});
70+
const events = filtered.slice(cursor, cursor + limit);
71+
const nextCursor = cursor + limit < filtered.length ? cursor + limit : undefined;
72+
return { events, nextCursor };
73+
}
74+
75+
reconstruct(subscriptionId: string): Record<string, unknown> {
76+
return this.query({ subscriptionId, includeArchived: true, limit: Number.MAX_SAFE_INTEGER })
77+
.events.sort((a, b) => a.sequence - b.sequence)
78+
.reduce<Record<string, unknown>>(
79+
(state, event) => ({
80+
...state,
81+
...(event.payload as Record<string, unknown>),
82+
id: subscriptionId,
83+
lastEventType: event.type,
84+
updatedAt: event.occurredAt,
85+
}),
86+
{ id: subscriptionId }
87+
);
88+
}
89+
90+
replay(subscriptionId: string, handler: (event: SubscriptionEvent) => void): void {
91+
this.query({ subscriptionId, includeArchived: true, limit: Number.MAX_SAFE_INTEGER })
92+
.events.sort((a, b) => a.sequence - b.sequence)
93+
.forEach(handler);
94+
}
95+
96+
archiveBefore(timestamp: number): number {
97+
let archived = 0;
98+
for (const event of this.events) {
99+
if (!event.archivedAt && event.occurredAt < timestamp) {
100+
event.archivedAt = Date.now();
101+
archived += 1;
102+
}
103+
}
104+
return archived;
105+
}
106+
}
107+
108+
export const subscriptionEventStore = new SubscriptionEventStore();

backend/services/webhook.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface RegisterWebhookInput {
2020
events: WebhookEventType[];
2121
secretKey: string;
2222
retryPolicy?: Partial<WebhookRetryPolicy>;
23+
rateLimitPerMinute?: number;
2324
isPaused?: boolean;
2425
}
2526

@@ -94,6 +95,7 @@ export class WebhookDeliveryService {
9495
private readonly webhooks = new Map<string, WebhookConfig>();
9596
private readonly deliveries = new Map<string, WebhookDelivery>();
9697
private readonly deliveredKeys = new Set<string>();
98+
private readonly rateLimitWindows = new Map<string, number[]>();
9799

98100
constructor(options: { fetchImpl?: FetchLike; sleepImpl?: (ms: number) => Promise<void> } = {}) {
99101
this.fetchImpl = options.fetchImpl ?? fetch;
@@ -110,6 +112,7 @@ export class WebhookDeliveryService {
110112
events: [...input.events],
111113
secretKey: input.secretKey,
112114
retryPolicy: clampRetryPolicy(input.retryPolicy),
115+
rateLimitPerMinute: input.rateLimitPerMinute,
113116
isPaused: input.isPaused ?? false,
114117
createdAt,
115118
updatedAt: createdAt,
@@ -136,6 +139,7 @@ export class WebhookDeliveryService {
136139
events: input.events ? [...input.events] : existing.events,
137140
secretKey: input.secretKey ?? existing.secretKey,
138141
retryPolicy: clampRetryPolicy(input.retryPolicy ?? existing.retryPolicy),
142+
rateLimitPerMinute: input.rateLimitPerMinute ?? existing.rateLimitPerMinute,
139143
isPaused: input.isPaused ?? existing.isPaused,
140144
updatedAt: now(),
141145
};
@@ -193,6 +197,9 @@ export class WebhookDeliveryService {
193197
const avgAttempts = totalDeliveries
194198
? deliveries.reduce((sum, d) => sum + d.attempts, 0) / totalDeliveries
195199
: 0;
200+
const latencySamples = deliveries
201+
.map((delivery) => delivery.latencyMs)
202+
.filter((latency): latency is number => typeof latency === 'number');
196203

197204
return {
198205
webhookId,
@@ -203,6 +210,9 @@ export class WebhookDeliveryService {
203210
pendingDeliveries,
204211
successRate: totalDeliveries ? successfulDeliveries / totalDeliveries : 0,
205212
avgAttempts,
213+
avgLatencyMs: latencySamples.length
214+
? latencySamples.reduce((sum, latency) => sum + latency, 0) / latencySamples.length
215+
: 0,
206216
lastSuccessAt: deliveries
207217
.filter((delivery) => delivery.status === 'delivered' && delivery.deliveredAt)
208218
.map((delivery) => delivery.deliveredAt as number)
@@ -270,6 +280,28 @@ export class WebhookDeliveryService {
270280
return { delivery };
271281
}
272282

283+
if (this.isRateLimited(webhook)) {
284+
const delivery: WebhookDelivery = {
285+
id: createId('del'),
286+
webhookId: webhook.id,
287+
eventId: payload.id,
288+
eventType: payload.eventType,
289+
url: webhook.url,
290+
payload,
291+
status: 'retrying',
292+
attempts: 0,
293+
maxAttempts: webhook.retryPolicy.maxRetries,
294+
createdAt: now(),
295+
updatedAt: now(),
296+
signature,
297+
idempotencyKey,
298+
errorMessage: 'Webhook endpoint rate limited',
299+
nextRetryAt: now() + 60_000,
300+
};
301+
this.deliveries.set(delivery.id, delivery);
302+
return { delivery };
303+
}
304+
273305
const delivery: WebhookDelivery = {
274306
id: createId('del'),
275307
webhookId: webhook.id,
@@ -375,6 +407,7 @@ export class WebhookDeliveryService {
375407
status: 'delivered',
376408
responseCode: response.status,
377409
deliveredAt: now(),
410+
latencyMs: now() - attemptAt,
378411
},
379412
response
380413
);
@@ -442,6 +475,21 @@ export class WebhookDeliveryService {
442475
const rawDelay = Math.floor(policy.initialDelayMs * Math.pow(factor, Math.max(0, attempt - 1)));
443476
return Math.min(rawDelay, policy.maxDelayMs);
444477
}
478+
479+
private isRateLimited(webhook: WebhookConfig): boolean {
480+
if (!webhook.rateLimitPerMinute || webhook.rateLimitPerMinute <= 0) return false;
481+
const windowStart = now() - 60_000;
482+
const current = (this.rateLimitWindows.get(webhook.id) ?? []).filter(
483+
(timestamp) => timestamp >= windowStart
484+
);
485+
if (current.length >= webhook.rateLimitPerMinute) {
486+
this.rateLimitWindows.set(webhook.id, current);
487+
return true;
488+
}
489+
current.push(now());
490+
this.rateLimitWindows.set(webhook.id, current);
491+
return false;
492+
}
445493
}
446494

447495
export const webhookDeliveryService = new WebhookDeliveryService();

contracts/subscription/src/events.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,68 @@
1-
use soroban_sdk::{Address, Env};
1+
use soroban_sdk::{contracttype, Address, Env, String, Vec};
22
use subtrackr_types::{
33
Plan, Subscription, SubscriptionStatus, WebhookEventPayload, WebhookEventType,
44
WebhookPlanSnapshot, WebhookSubscriptionSnapshot,
55
};
66

7+
#[contracttype]
8+
#[derive(Clone, Debug, PartialEq)]
9+
pub enum SubscriptionEventType {
10+
Created,
11+
Updated,
12+
Renewed,
13+
Cancelled,
14+
PaymentFailed,
15+
Upgraded,
16+
Paused,
17+
Resumed,
18+
RefundRequested,
19+
RefundApproved,
20+
RefundRejected,
21+
TransferRequested,
22+
TransferAccepted,
23+
}
24+
25+
#[contracttype]
26+
#[derive(Clone, Debug, PartialEq)]
27+
pub struct SubscriptionAuditEvent {
28+
pub id: u64,
29+
pub subscription_id: u64,
30+
pub sequence: u64,
31+
pub event_type: SubscriptionEventType,
32+
pub actor: Address,
33+
pub occurred_at: u64,
34+
pub schema_version: u32,
35+
pub payload_hash: String,
36+
}
37+
38+
pub(crate) fn build_audit_event(
39+
env: &Env,
40+
subscription_id: u64,
41+
sequence: u64,
42+
event_type: SubscriptionEventType,
43+
actor: &Address,
44+
payload_hash: String,
45+
) -> SubscriptionAuditEvent {
46+
SubscriptionAuditEvent {
47+
id: env.ledger().sequence() as u64,
48+
subscription_id,
49+
sequence,
50+
event_type,
51+
actor: actor.clone(),
52+
occurred_at: env.ledger().timestamp(),
53+
schema_version: 1,
54+
payload_hash,
55+
}
56+
}
57+
58+
pub(crate) fn replay_state(events: Vec<SubscriptionAuditEvent>) -> Option<SubscriptionEventType> {
59+
let mut latest: Option<SubscriptionEventType> = None;
60+
for event in events.iter() {
61+
latest = Some(event.event_type);
62+
}
63+
latest
64+
}
65+
766
pub(crate) fn subscription_snapshot(sub: &Subscription) -> WebhookSubscriptionSnapshot {
867
WebhookSubscriptionSnapshot {
968
id: sub.id,

0 commit comments

Comments
 (0)