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
4 changes: 4 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ module.exports = {
resetMocks: true,
restoreMocks: true,
setupFiles: ['<rootDir>/tests/setup.ts'],
moduleNameMapper: {
'^@syncro/shared$': '<rootDir>/../shared/src',
'^@syncro/shared/(.*)$': '<rootDir>/../shared/src/$1',
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
diagnostics: false,
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"audit:routes": "node scripts/validate-route-validation.js"
},
"dependencies": {
"@syncro/shared": "*",
"@syncro/shared": "file:../shared",
"@sentry/node": "^10.50.0",
"@sentry/profiling-node": "^10.50.0",
"@stellar/stellar-sdk": "^14.5.0",
Expand Down
17 changes: 1 addition & 16 deletions backend/src/routes/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SUPPORTED_CURRENCIES } from '../constants/currencies';
import logger from '../config/logger';
import { BadRequestError } from '../errors';
import { validateRequest } from '../utils/validation';
import { cursorPaginationSchema } from '../schemas/common';
import { cursorPaginationSchema, safeUrlSchema } from '../schemas/common';

const router = Router();

Expand All @@ -30,21 +30,6 @@ const upload = multer({

// ── Zod schemas ───────────────────────────────────────────────────────────────

const safeUrlSchema = z
.string()
.url('Must be a valid URL')
.refine(
(val) => {
try {
const { protocol } = new URL(val);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
},
{ message: 'URL must use http or https protocol' }
);

const createSubscriptionSchema = z.object({
name: z.string().min(1),
price: z.number().min(0),
Expand Down
10 changes: 2 additions & 8 deletions backend/src/schemas/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { isSafeHttpUrl } from '@syncro/shared/security';

// ─── Reusable URL schema ────────────────────────────────────────────────────
/** Validates a URL string, requiring http or https protocol. */
Expand All @@ -7,14 +8,7 @@ export const safeUrlSchema = z
.max(2000, 'URL must not exceed 2000 characters')
.url('Must be a valid URL')
.refine(
(val) => {
try {
const { protocol } = new URL(val);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
},
(val) => isSafeHttpUrl(val),
{ message: 'URL must use http or https protocol' },
);

Expand Down
21 changes: 3 additions & 18 deletions backend/src/schemas/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { safeUrlSchema } from './common';

const webhookEventSchema = z.enum([
'subscription.renewal_due',
Expand All @@ -9,24 +10,8 @@ const webhookEventSchema = z.enum([
'reminder.sent',
]);

const webhookUrlSchema = z
.string()
.max(2000, 'URL must not exceed 2000 characters')
.url('Must be a valid URL')
.refine(
(val) => {
try {
const { protocol } = new URL(val);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
},
{ message: 'URL must use http or https protocol' },
);

export const createWebhookSchema = z.object({
url: webhookUrlSchema,
url: safeUrlSchema,
events: z
.array(webhookEventSchema)
.min(1, 'At least one event type is required')
Expand All @@ -38,7 +23,7 @@ export const createWebhookSchema = z.object({
});

export const updateWebhookSchema = z.object({
url: webhookUrlSchema.optional(),
url: safeUrlSchema.optional(),
events: z
.array(webhookEventSchema)
.min(1, 'At least one event type is required')
Expand Down
146 changes: 38 additions & 108 deletions backend/src/services/analytics-service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { supabase } from '../config/database';
import logger from '../config/logger';
import {
buildCategoryMonthlySpend,
buildPastMonthlySpendTrend,
calculateMonthlySpend,
countUpcomingRenewals,
getTopMonthlySpendSubscriptions,
normalizeToMonthlyAmount,
roundMoney,
} from '@syncro/shared/subscription-math';
import { AnalyticsSummary, MonthlySpend, CategorySpend, SubscriptionSpend, Budget } from '../types/analytics';
import { Subscription } from '../types/reminder';

Expand Down Expand Up @@ -30,9 +39,9 @@ export class AnalyticsService {
const typedBudgets = (budgets || []) as Budget[];

// 3. Calculate metrics
const totalMonthlySpend = this.calculateTotalMonthlySpend(typedSubs);
const categoryBreakdown = this.calculateCategoryBreakdown(typedSubs, totalMonthlySpend);
const topSubscriptions = this.getTopSubscriptions(typedSubs);
const totalMonthlySpend = calculateMonthlySpend(typedSubs);
const categoryBreakdown = this.formatCategoryBreakdown(typedSubs);
const topSubscriptions = this.formatTopSubscriptions(typedSubs);
const monthlyTrend = await this.getMonthlyTrend(userId, typedSubs);

const overallBudget = typedBudgets.find(b => b.category === null);
Expand All @@ -42,14 +51,7 @@ export class AnalyticsService {
percentage: overallBudget ? (totalMonthlySpend / overallBudget.budget_limit) * 100 : 0
};

// 4. Upcoming renewals count (next 7 days)
const next7Days = new Date();
next7Days.setDate(next7Days.getDate() + 7);
const upcomingRenewalsCount = typedSubs.filter(sub => {
if (!sub.next_billing_date) return false;
const renewalDate = new Date(sub.next_billing_date);
return renewalDate <= next7Days && renewalDate >= new Date();
}).length;
const upcomingRenewalsCount = countUpcomingRenewals(typedSubs, 7);

return {
total_monthly_spend: totalMonthlySpend,
Expand All @@ -66,72 +68,23 @@ export class AnalyticsService {
}
}

/**
* Calculate monthly normalized spend
*/
private calculateTotalMonthlySpend(subscriptions: Subscription[]): number {
return subscriptions.reduce((total, sub) => {
return total + this.normalizeToMonthly(sub.price, sub.billing_cycle);
}, 0);
}

/**
* Normalize price to monthly
*/
private normalizeToMonthly(price: number, cycle: string): number {
switch (cycle.toLowerCase()) {
case 'annual':
case 'yearly':
return price / 12;
case 'monthly':
return price;
case 'weekly':
return price * (365 / 7 / 12); // Average weeks in a month
case 'quarterly':
return price / 3;
case 'semiannual':
return price / 6;
default:
return price;
}
}

/**
* Calculate spend by category
*/
private calculateCategoryBreakdown(subscriptions: Subscription[], totalSpend: number): CategorySpend[] {
const categories: Record<string, { total: number, count: number }> = {};

subscriptions.forEach(sub => {
const category = sub.category || 'Other';
if (!categories[category]) {
categories[category] = { total: 0, count: 0 };
}
categories[category].total += this.normalizeToMonthly(sub.price, sub.billing_cycle);
categories[category].count += 1;
});

return Object.entries(categories).map(([name, data]) => ({
category: name,
total_spend: parseFloat(data.total.toFixed(2)),
percentage: totalSpend > 0 ? (data.total / totalSpend) * 100 : 0,
count: data.count
})).sort((a, b) => b.total_spend - a.total_spend);
private formatCategoryBreakdown(subscriptions: Subscription[]): CategorySpend[] {
return buildCategoryMonthlySpend(subscriptions).map((category) => ({
category: category.category,
total_spend: category.totalMonthlySpend,
percentage: category.percentage,
count: category.count,
}));
}

/**
* Get top 5 expensive subscriptions (monthly normalized)
*/
private getTopSubscriptions(subscriptions: Subscription[]): SubscriptionSpend[] {
return subscriptions.map(sub => ({
id: sub.id,
name: sub.name,
price: sub.price,
billing_cycle: sub.billing_cycle,
monthly_normalized_price: this.normalizeToMonthly(sub.price, sub.billing_cycle)
}))
.sort((a, b) => b.monthly_normalized_price - a.monthly_normalized_price)
.slice(0, 5);
private formatTopSubscriptions(subscriptions: Subscription[]): SubscriptionSpend[] {
return getTopMonthlySpendSubscriptions(subscriptions).map((subscription) => ({
id: subscription.id ? String(subscription.id) : '',
name: subscription.name ?? '',
price: subscription.price,
billing_cycle: subscription.billing_cycle,
monthly_normalized_price: subscription.monthlyNormalizedPrice,
}));
}

/**
Expand All @@ -140,31 +93,11 @@ export class AnalyticsService {
private async getMonthlyTrend(userId: string, currentSubs: Subscription[]): Promise<MonthlySpend[]> {
// In a real app, this would query historical data or logs.
// For now, we'll project the trend based on current subscriptions and created_at dates
const trend: MonthlySpend[] = [];
const now = new Date();

for (let i = 5; i >= 0; i--) {
const targetDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthStr = targetDate.toISOString().substring(0, 7);

// Filter subs that existed in this month
const subsAtTime = currentSubs.filter(sub => {
const createdAt = new Date(sub.created_at);
return createdAt <= new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0);
});

const monthlyTotal = subsAtTime.reduce((total, sub) => {
return total + this.normalizeToMonthly(sub.price, sub.billing_cycle);
}, 0);

trend.push({
month: monthStr,
total_spend: parseFloat(monthlyTotal.toFixed(2)),
count: subsAtTime.length
});
}

return trend;
return buildPastMonthlySpendTrend(currentSubs).map((point) => ({
month: point.month,
total_spend: point.totalMonthlySpend,
count: point.count,
}));
}

/**
Expand Down Expand Up @@ -260,10 +193,10 @@ export class AnalyticsService {

const typedSubs = (subscriptions || []) as Subscription[];
const monthlyTrend = await this.getMonthlyTrend(userId, typedSubs);
const categoryBreakdown = this.calculateCategoryBreakdown(typedSubs, this.calculateTotalMonthlySpend(typedSubs));
const categoryBreakdown = this.formatCategoryBreakdown(typedSubs);

return {
current_month_spend: this.calculateTotalMonthlySpend(typedSubs),
current_month_spend: calculateMonthlySpend(typedSubs),
monthly_trend: monthlyTrend,
category_breakdown: categoryBreakdown,
active_subscriptions: typedSubs.length
Expand Down Expand Up @@ -306,17 +239,14 @@ export class AnalyticsService {

// Check if subscription will be active in this month
if (createdAt <= new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0)) {
// Check if the subscription isn't cancelled or will be cancelled before this month
if (!sub.cancelled_at || new Date(sub.cancelled_at) > new Date(targetDate.getFullYear(), targetDate.getMonth(), 1)) {
monthlyTotal += this.normalizeToMonthly(sub.price, sub.billing_cycle);
count++;
}
monthlyTotal += normalizeToMonthlyAmount(sub.price, sub.billing_cycle);
count++;
}
}

forecast.push({
month: monthStr,
total_spend: parseFloat(monthlyTotal.toFixed(2)),
total_spend: roundMoney(monthlyTotal),
count: count
});
}
Expand Down
29 changes: 3 additions & 26 deletions backend/src/services/budget-alert-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { supabase } from '../config/database';
import logger from '../config/logger';
import { sendSlackAlert } from './slack-service';
import { calculateMonthlySpend } from '@syncro/shared/subscription-math';

function currentMonth(): string {
return new Date().toISOString().substring(0, 7); // YYYY-MM
Expand Down Expand Up @@ -70,20 +71,7 @@ export async function checkBudgetAlerts(userId: string): Promise<void> {
.eq('user_id', userId)
.eq('status', 'active');

const monthlyTotal = (subs ?? []).reduce((sum, sub) => {
const price = Number(sub.price);
switch ((sub.billing_cycle ?? '').toLowerCase()) {
case 'yearly':
case 'annual':
return sum + price / 12;
case 'quarterly':
return sum + price / 3;
case 'weekly':
return sum + price * (365 / 7 / 12);
default:
return sum + price;
}
}, 0);
const monthlyTotal = calculateMonthlySpend(subs ?? []);

const budget = Number(profile.monthly_budget);
const percentage = (monthlyTotal / budget) * 100;
Expand Down Expand Up @@ -145,18 +133,7 @@ export async function wouldExceedBudget(
.eq('user_id', userId)
.eq('status', 'active');

const currentTotal = (subs ?? []).reduce((sum, sub) => {
const price = Number(sub.price);
switch ((sub.billing_cycle ?? '').toLowerCase()) {
case 'yearly':
case 'annual':
return sum + price / 12;
case 'quarterly':
return sum + price / 3;
default:
return sum + price;
}
}, 0);
const currentTotal = calculateMonthlySpend(subs ?? []);

const budget = Number(profile.monthly_budget);
const newTotal = currentTotal + newMonthlyAmount;
Expand Down
6 changes: 2 additions & 4 deletions backend/src/services/monitoring-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { supabase, monitorPool, PoolMetrics } from '../config/database';
import logger from '../config/logger';
import { ExternalServiceClient, ServiceMetrics } from '../utils/external-service-client';
import { normalizeToMonthlyAmount } from '@syncro/shared/subscription-math';

// ─── Existing interfaces ────────────────────────────────────────────────────

Expand Down Expand Up @@ -192,10 +193,7 @@ export class MonitoringService {
for (const sub of subs) {
metrics.category_distribution[sub.category] = (metrics.category_distribution[sub.category] || 0) + 1;
if (sub.status === 'active') {
let monthlyPrice = sub.price;
if (sub.billing_cycle === 'yearly') monthlyPrice = sub.price / 12;
else if (sub.billing_cycle === 'weekly') monthlyPrice = sub.price * 4;
metrics.total_monthly_revenue += monthlyPrice;
metrics.total_monthly_revenue += normalizeToMonthlyAmount(sub.price, sub.billing_cycle);
}
}
}
Expand Down
Loading