diff --git a/backend/jest.config.js b/backend/jest.config.js index 5bac959..25eab9b 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -8,6 +8,10 @@ module.exports = { resetMocks: true, restoreMocks: true, setupFiles: ['/tests/setup.ts'], + moduleNameMapper: { + '^@syncro/shared$': '/../shared/src', + '^@syncro/shared/(.*)$': '/../shared/src/$1', + }, transform: { '^.+\\.tsx?$': ['ts-jest', { diagnostics: false, diff --git a/backend/package.json b/backend/package.json index fba0619..dd7958d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/routes/subscriptions.ts b/backend/src/routes/subscriptions.ts index c4a9355..ca6d2e3 100644 --- a/backend/src/routes/subscriptions.ts +++ b/backend/src/routes/subscriptions.ts @@ -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(); @@ -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), diff --git a/backend/src/schemas/common.ts b/backend/src/schemas/common.ts index b9a00a1..9913fba 100644 --- a/backend/src/schemas/common.ts +++ b/backend/src/schemas/common.ts @@ -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. */ @@ -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' }, ); diff --git a/backend/src/schemas/webhook.ts b/backend/src/schemas/webhook.ts index 9d6c11b..3868c08 100644 --- a/backend/src/schemas/webhook.ts +++ b/backend/src/schemas/webhook.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { safeUrlSchema } from './common'; const webhookEventSchema = z.enum([ 'subscription.renewal_due', @@ -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') @@ -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') diff --git a/backend/src/services/analytics-service.ts b/backend/src/services/analytics-service.ts index 47046e3..e21b63c 100644 --- a/backend/src/services/analytics-service.ts +++ b/backend/src/services/analytics-service.ts @@ -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'; @@ -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); @@ -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, @@ -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 = {}; - - 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, + })); } /** @@ -140,31 +93,11 @@ export class AnalyticsService { private async getMonthlyTrend(userId: string, currentSubs: Subscription[]): Promise { // 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, + })); } /** @@ -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 @@ -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 }); } diff --git a/backend/src/services/budget-alert-service.ts b/backend/src/services/budget-alert-service.ts index 9268fcf..c0194d2 100644 --- a/backend/src/services/budget-alert-service.ts +++ b/backend/src/services/budget-alert-service.ts @@ -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 @@ -70,20 +71,7 @@ export async function checkBudgetAlerts(userId: string): Promise { .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; @@ -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; diff --git a/backend/src/services/monitoring-service.ts b/backend/src/services/monitoring-service.ts index 3ea60a1..46750df 100644 --- a/backend/src/services/monitoring-service.ts +++ b/backend/src/services/monitoring-service.ts @@ -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 ──────────────────────────────────────────────────── @@ -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); } } } diff --git a/backend/src/services/telegram-command-service.ts b/backend/src/services/telegram-command-service.ts index 1f28924..e5e9a2d 100644 --- a/backend/src/services/telegram-command-service.ts +++ b/backend/src/services/telegram-command-service.ts @@ -6,6 +6,7 @@ import { Subscription } from '../types/subscription'; import { UserRole } from '../middleware/auth'; import { ROLE_PERMISSIONS } from '../middleware/rbac'; import { roleService } from './role-service'; +import { normalizeToMonthlyAmount } from '@syncro/shared/subscription-math'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -15,14 +16,8 @@ const RENEWAL_CONTEXT_TTL_MS = 5 * 60 * 1_000; // 5 minutes // ─── Monthly Cost Normalisation ─────────────────────────────────────────────── -const CYCLE_DIVISOR: Record = { - monthly: 1, - quarterly: 3, - yearly: 12, -}; - export function toMonthlyAmount(price: number, cycle: Subscription['billing_cycle']): number { - return price / CYCLE_DIVISOR[cycle]; + return normalizeToMonthlyAmount(price, cycle); } // ─── Formatting ─────────────────────────────────────────────────────────────── @@ -563,4 +558,4 @@ export { toMonthlyAmount, getUserIdByChatId, getActiveSubscriptions, -}; \ No newline at end of file +}; diff --git a/backend/src/utils/sanitize-url.ts b/backend/src/utils/sanitize-url.ts index 08ab14f..1fc1b1e 100644 --- a/backend/src/utils/sanitize-url.ts +++ b/backend/src/utils/sanitize-url.ts @@ -1,3 +1,5 @@ +import { sanitizeUrl as sanitizeSharedUrl } from '@syncro/shared/security'; + /** * Sanitizes a URL to ensure it only uses safe protocols (http or https). * @@ -8,19 +10,5 @@ * @returns The original URL string if it is a valid http/https URL, otherwise '#'. */ export function sanitizeUrl(url: string | null | undefined): string { - if (!url || url.trim() === '') { - return '#'; - } - - try { - const parsed = new URL(url); - // Only allow http and https protocols - if (!['http:', 'https:'].includes(parsed.protocol)) { - return '#'; - } - return parsed.toString(); - } catch { - // URL constructor throws on malformed input - return '#'; - } + return sanitizeSharedUrl(url); } diff --git a/backend/tests/shared-business-logic.test.ts b/backend/tests/shared-business-logic.test.ts new file mode 100644 index 0000000..79051da --- /dev/null +++ b/backend/tests/shared-business-logic.test.ts @@ -0,0 +1,113 @@ +import { + buildCategoryMonthlySpend, + buildPastMonthlySpendTrend, + calculateMonthlySpend, + countUpcomingRenewals, + getTopMonthlySpendSubscriptions, + normalizeToMonthlyAmount, +} from '@syncro/shared/subscription-math'; +import { + isSafeHttpUrl, + maskApiKey, + sanitizeUrl, +} from '@syncro/shared/security'; + +describe('shared subscription math', () => { + const subscriptions = [ + { + id: 'monthly', + name: 'Monthly App', + price: 30, + billing_cycle: 'monthly', + category: 'productivity', + created_at: '2026-01-10T00:00:00Z', + next_billing_date: '2026-05-31T00:00:00Z', + }, + { + id: 'yearly', + name: 'Yearly App', + price: 120, + billing_cycle: 'yearly', + category: 'productivity', + created_at: '2026-02-01T00:00:00Z', + next_billing_date: '2026-06-10T00:00:00Z', + }, + { + id: 'weekly', + name: 'Weekly App', + price: 7, + billing_cycle: 'weekly', + category: 'streaming', + created_at: '2026-05-01T00:00:00Z', + next_billing_date: '2026-06-01T00:00:00Z', + }, + ]; + + it('normalizes supported billing cycles to monthly amounts', () => { + expect(normalizeToMonthlyAmount(120, 'annual')).toBe(10); + expect(normalizeToMonthlyAmount(120, 'yearly')).toBe(10); + expect(normalizeToMonthlyAmount(90, 'quarterly')).toBe(30); + expect(normalizeToMonthlyAmount(60, 'semiannual')).toBe(10); + expect(normalizeToMonthlyAmount(100, 'lifetime')).toBe(0); + }); + + it('calculates total monthly spend once for shared callers', () => { + expect(calculateMonthlySpend(subscriptions)).toBeCloseTo(70.42, 2); + }); + + it('builds category breakdowns using normalized monthly spend', () => { + const breakdown = buildCategoryMonthlySpend(subscriptions); + + expect(breakdown[0]).toMatchObject({ + category: 'productivity', + totalMonthlySpend: 40, + count: 2, + }); + expect(breakdown[1]).toMatchObject({ + category: 'streaming', + totalMonthlySpend: 30.42, + count: 1, + }); + }); + + it('sorts top subscriptions by monthly-normalized price', () => { + expect(getTopMonthlySpendSubscriptions(subscriptions, 2).map((sub) => sub.id)).toEqual([ + 'weekly', + 'monthly', + ]); + }); + + it('counts renewals within an inclusive future window', () => { + expect(countUpcomingRenewals(subscriptions, 7, new Date('2026-05-30T00:00:00Z'))).toBe(2); + }); + + it('projects historical monthly trend from creation dates', () => { + const trend = buildPastMonthlySpendTrend(subscriptions, 2, new Date('2026-05-30T00:00:00Z')); + + expect(trend).toEqual([ + { month: '2026-04', totalMonthlySpend: 40, count: 2 }, + { month: '2026-05', totalMonthlySpend: 70.42, count: 3 }, + ]); + }); +}); + +describe('shared security helpers', () => { + it('accepts only http and https URLs', () => { + expect(isSafeHttpUrl('https://example.com/path')).toBe(true); + expect(isSafeHttpUrl('http://example.com/path')).toBe(true); + expect(isSafeHttpUrl('javascript:alert(1)')).toBe(false); + expect(isSafeHttpUrl('not a url')).toBe(false); + }); + + it('sanitizes unsafe URLs to the fallback', () => { + expect(sanitizeUrl('https://example.com/path')).toBe('https://example.com/path'); + expect(sanitizeUrl('data:text/html,hello')).toBe('#'); + }); + + it('masks API keys with configurable visible sections', () => { + expect(maskApiKey('sk-ant-validkey123456')).toBe('sk-ant-...3456'); + expect(maskApiKey('short')).toBe('••••••••'); + expect(maskApiKey('abcd1234efgh', { visiblePrefix: 4, visibleSuffix: 4, shortMask: '***' })) + .toBe('abcd...efgh'); + }); +}); diff --git a/client/app/api/analytics/route.ts b/client/app/api/analytics/route.ts index 82e7a6f..7a7e348 100644 --- a/client/app/api/analytics/route.ts +++ b/client/app/api/analytics/route.ts @@ -2,6 +2,7 @@ import { type NextRequest } from "next/server" import { HttpStatus } from "@/lib/api/types" import { createClient } from "@/lib/supabase/server" import { createAuthenticatedApiRoute, createSuccessResponse, RateLimiters, ApiErrors } from "@/lib/api/index" +import { buildCategoryMonthlySpend, calculateMonthlySpend } from "@syncro/shared/subscription-math" export const GET = createAuthenticatedApiRoute( async (request: NextRequest, context, user) => { @@ -9,7 +10,7 @@ export const GET = createAuthenticatedApiRoute( const { data: subscriptions, error } = await supabase .from("subscriptions") - .select("price, category, status, created_at") + .select("price, billing_cycle, category, status, created_at") .eq("user_id", user.id) .eq("status", "active") @@ -17,25 +18,20 @@ export const GET = createAuthenticatedApiRoute( throw ApiErrors.internalError(`Failed to fetch analytics: ${error.message}`) } - const totalSpend = - subscriptions?.reduce((sum, sub) => sum + (sub.price || 0), 0) || 0 - + const totalSpend = calculateMonthlySpend(subscriptions ?? []) const monthlySpend = totalSpend - const categoryMap = new Map() - subscriptions?.forEach((sub) => { - const category = sub.category || "Uncategorized" - categoryMap.set(category, (categoryMap.get(category) || 0) + (sub.price || 0)) - }) - - const categoryBreakdown = Array.from(categoryMap.entries()).map( - ([category, spend]) => ({ + const categoryBreakdown = buildCategoryMonthlySpend( + subscriptions ?? [], + "Uncategorized", + ).map(({ category, totalMonthlySpend, percentage }) => { + const spend = totalMonthlySpend + return { category, spend, - percentage: - totalSpend > 0 ? Math.round((spend / totalSpend) * 100) : 0, - }) - ) + percentage: Math.round(percentage), + } + }) const spendTrend = [ { month: "Jan", spend: Math.round(totalSpend * 0.8) }, diff --git a/client/app/api/subscriptions/import/route.ts b/client/app/api/subscriptions/import/route.ts index 8d2cfbb..6933c3a 100644 --- a/client/app/api/subscriptions/import/route.ts +++ b/client/app/api/subscriptions/import/route.ts @@ -12,6 +12,7 @@ import { z } from "zod" import { ApiErrors, RateLimiters, createErrorResponse, getAuthenticatedUser, validateCsrfToken } from "@/lib/api/index" import { ApiException } from "@/lib/api/errors" import { applyRateLimitHeaders, type RateLimitHeaders } from "@/lib/api/rate-limit" +import { isSafeHttpUrl } from "@syncro/shared/security" function importJsonResponse( body: unknown, @@ -81,12 +82,7 @@ const rowSchema = z.object({ .transform((v) => v?.trim() || null) .refine((v) => { if (!v) return true - try { - const { protocol } = new URL(v) - return protocol === "http:" || protocol === "https:" - } catch { - return false - } + return isSafeHttpUrl(v) }, "renewal_url must be a valid http/https URL or empty"), }) diff --git a/client/lib/pdf-report.tsx b/client/lib/pdf-report.tsx index 568e434..5f63abe 100644 --- a/client/lib/pdf-report.tsx +++ b/client/lib/pdf-report.tsx @@ -19,6 +19,7 @@ import { StyleSheet, pdf, } from "@react-pdf/renderer" +import { calculateMonthlySpend } from "@syncro/shared/subscription-math" import { formatCurrency } from "./currency-utils" import { formatDate, addDays } from "./timezone-utils" @@ -333,7 +334,7 @@ export async function downloadSubscriptionPDF( subscriptions: ReportSubscription[], ): Promise { const active = subscriptions.filter((s) => s.status === "active") - const monthlyTotal = active.reduce((sum, s) => sum + (s.price ?? 0), 0) + const monthlyTotal = calculateMonthlySpend(active) const summary: ReportSummary = { activeCount: active.length, diff --git a/client/lib/security-utils.ts b/client/lib/security-utils.ts index bebcb39..87ce031 100644 --- a/client/lib/security-utils.ts +++ b/client/lib/security-utils.ts @@ -1,6 +1,12 @@ "use client" export { secureStorage } from "./security" +import { + isSafeHttpUrl, + maskApiKey, + maskEmail as maskSharedEmail, + validateEmail as validateSharedEmail, +} from "@syncro/shared/security" // Security utilities for input sanitization and validation @@ -19,17 +25,11 @@ export function sanitizeHTML(html: string): string { } export function validateEmail(email: string): boolean { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - return emailRegex.test(email) + return validateSharedEmail(email) } export function validateURL(url: string): boolean { - try { - const parsed = new URL(url) - return parsed.protocol === "http:" || parsed.protocol === "https:" - } catch { - return false - } + return isSafeHttpUrl(url) } export function validateAPIKey(key: string): boolean { @@ -39,14 +39,11 @@ export function validateAPIKey(key: string): boolean { // Mask sensitive data for display export function maskAPIKey(key: string): string { - if (key.length <= 8) return "***" - return `${key.slice(0, 4)}...${key.slice(-4)}` + return maskApiKey(key, { visiblePrefix: 4, visibleSuffix: 4, shortMask: "***" }) } export function maskEmail(email: string): string { - const [local, domain] = email.split("@") - if (!local || !domain) return email - return `${local.slice(0, 2)}***@${domain}` + return maskSharedEmail(email) } // Rate limiting @@ -168,4 +165,3 @@ export class SessionManager { return Math.max(0, this.timeout - elapsed) } } - diff --git a/client/lib/validation.ts b/client/lib/validation.ts index 04ba147..c68fee7 100644 --- a/client/lib/validation.ts +++ b/client/lib/validation.ts @@ -1,3 +1,5 @@ +import { isSafeHttpUrl, maskApiKey, validateEmail } from "@syncro/shared/security" + /** * Validates subscription creation input before sending to backend. * Runs BEFORE any Axios request to catch errors early. @@ -293,8 +295,7 @@ export const validateSubscriptionData = (data: any) => { // Email validation if (data.email) { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(data.email)) { + if (!validateEmail(data.email)) { errors.email = "Invalid email format" } } @@ -345,8 +346,7 @@ export const validateAPIKey = (provider: string, apiKey: string) => { } export const maskAPIKey = (apiKey: string) => { - if (!apiKey || apiKey.length < 8) return "••••••••" - return `${apiKey.slice(0, 7)}...${apiKey.slice(-4)}` + return maskApiKey(apiKey) } // ─── Helper Functions ─────────────────────────────────────────────────────── @@ -357,16 +357,7 @@ export const maskAPIKey = (apiKey: string) => { * @returns true if valid, false otherwise */ function isValidUrl(url: string): boolean { - if (typeof url !== 'string' || url.length > 2000) { - return false - } - - try { - const parsed = new URL(url) - return parsed.protocol === 'http:' || parsed.protocol === 'https:' - } catch { - return false - } + return isSafeHttpUrl(url) } /** diff --git a/client/next.config.mjs b/client/next.config.mjs index 8222df5..6ec2c6d 100644 --- a/client/next.config.mjs +++ b/client/next.config.mjs @@ -2,6 +2,7 @@ import { withSentryConfig } from '@sentry/nextjs'; /** @type {import('next').NextConfig} */ const nextConfig = { + transpilePackages: ['@syncro/shared'], eslint: { ignoreDuringBuilds: true, }, diff --git a/client/package.json b/client/package.json index 1a66bc4..6ffeb34 100644 --- a/client/package.json +++ b/client/package.json @@ -28,7 +28,7 @@ "preinstall": "npm run lint:deps" }, "dependencies": { - "@syncro/shared": "*", + "@syncro/shared": "file:../shared", "@tremor/react": "^3.17.4", "@hookform/resolvers": "^5.4.0", "@radix-ui/react-accordion": "1.2.12", diff --git a/docs/shared-business-logic.md b/docs/shared-business-logic.md new file mode 100644 index 0000000..1e74395 --- /dev/null +++ b/docs/shared-business-logic.md @@ -0,0 +1,18 @@ +# Shared Business Logic + +Issue #602 moved repeated pure business rules into `@syncro/shared`. + +Use `shared/` for logic that has no database client, browser API, Express request, Next.js request, secret, or queue dependency. Good candidates include billing-cycle math, category totals, renewal windows, URL safety checks, and display masking rules. + +Keep layer-specific work in its owner: + +- Next.js routes handle user-scoped requests with Supabase SSR auth and RLS. +- The Express backend handles jobs, admin paths, service-role work, queues, and integrations. +- UI-only helpers stay in `client/lib`. + +Current shared helpers: + +- `normalizeToMonthlyAmount`, `calculateMonthlySpend`, `buildCategoryMonthlySpend`, `getTopMonthlySpendSubscriptions`, `countUpcomingRenewals`, and `buildPastMonthlySpendTrend` in `shared/src/subscription-math.ts`. +- `isSafeHttpUrl`, `sanitizeUrl`, `validateEmail`, `maskEmail`, and `maskApiKey` in `shared/src/security.ts`. + +When adding a new helper, keep it deterministic and pass all inputs as plain values. If the function needs Supabase, Redis, `window`, `crypto.getRandomValues`, logging, or request headers, put the side effect in the client or backend and call a smaller shared function from there. diff --git a/shared/package.json b/shared/package.json index 34f93bb..d884a5c 100644 --- a/shared/package.json +++ b/shared/package.json @@ -3,7 +3,31 @@ "version": "1.0.0", "description": "Shared domain models and types for SYNCRO", "main": "dist/index.js", - "types": "dist/index.d.ts", + "types": "src/index.ts", + "typesVersions": { + "*": { + "subscription-math": ["src/subscription-math.ts"], + "security": ["src/security.ts"], + "*": ["src/*"] + } + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "require": "./dist/index.js" + }, + "./subscription-math": { + "types": "./src/subscription-math.ts", + "import": "./src/subscription-math.ts", + "require": "./dist/subscription-math.js" + }, + "./security": { + "types": "./src/security.ts", + "import": "./src/security.ts", + "require": "./dist/security.js" + } + }, "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", diff --git a/shared/src/index.ts b/shared/src/index.ts index 49c7794..b091fdf 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -23,6 +23,12 @@ export * from './user'; // Analytics models export * from './analytics'; +// Shared subscription calculations +export * from './subscription-math'; + +// Shared security helpers +export * from './security'; + // Common utilities export * from './common'; diff --git a/shared/src/security.ts b/shared/src/security.ts new file mode 100644 index 0000000..b2c5cba --- /dev/null +++ b/shared/src/security.ts @@ -0,0 +1,46 @@ +const SAFE_URL_PROTOCOLS = new Set(['http:', 'https:']); + +export function isSafeHttpUrl(value: string | null | undefined, maxLength = 2000): boolean { + if (typeof value !== 'string' || value.trim() === '' || value.length > maxLength) { + return false; + } + + try { + return SAFE_URL_PROTOCOLS.has(new URL(value).protocol); + } catch { + return false; + } +} + +export function sanitizeUrl(value: string | null | undefined, fallback = '#'): string { + if (!isSafeHttpUrl(value)) { + return fallback; + } + + return new URL(value as string).toString(); +} + +export function validateEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +} + +export function maskEmail(value: string): string { + const [local, domain] = value.split('@'); + if (!local || !domain) return value; + return `${local.slice(0, 2)}***@${domain}`; +} + +export function maskApiKey( + value: string, + options: { visiblePrefix?: number; visibleSuffix?: number; shortMask?: string } = {}, +): string { + const visiblePrefix = options.visiblePrefix ?? 7; + const visibleSuffix = options.visibleSuffix ?? 4; + const shortMask = options.shortMask ?? '••••••••'; + + if (!value || value.length < visiblePrefix + visibleSuffix) { + return shortMask; + } + + return `${value.slice(0, visiblePrefix)}...${value.slice(-visibleSuffix)}`; +} diff --git a/shared/src/subscription-math.ts b/shared/src/subscription-math.ts new file mode 100644 index 0000000..6d8c106 --- /dev/null +++ b/shared/src/subscription-math.ts @@ -0,0 +1,166 @@ +export interface MonthlyPricedSubscription { + id?: string | number; + name?: string; + price: number | string | null | undefined; + billing_cycle?: string | null; + billingCycle?: string | null; + category?: string | null; + created_at?: string | null; + createdAt?: string | null; + next_billing_date?: string | null; + cancelled_at?: string | null; +} + +export interface CategoryMonthlySpend { + category: string; + totalMonthlySpend: number; + count: number; + percentage: number; +} + +export interface TopMonthlySubscription { + id?: string | number; + name?: string; + price: number; + billing_cycle: string; + monthlyNormalizedPrice: number; +} + +export interface MonthlySpendPoint { + month: string; + totalMonthlySpend: number; + count: number; +} + +const AVERAGE_MONTHS_PER_WEEK = 365 / 7 / 12; + +function toNumber(value: number | string | null | undefined): number { + const parsed = typeof value === 'number' ? value : Number.parseFloat(String(value ?? 0)); + return Number.isFinite(parsed) ? parsed : 0; +} + +function formatMonthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; +} + +export function roundMoney(value: number): number { + return Number.parseFloat(value.toFixed(2)); +} + +export function normalizeToMonthlyAmount( + price: number | string | null | undefined, + billingCycle: string | null | undefined, +): number { + const amount = toNumber(price); + + switch ((billingCycle ?? 'monthly').toLowerCase()) { + case 'annual': + case 'yearly': + return amount / 12; + case 'quarterly': + return amount / 3; + case 'weekly': + return amount * AVERAGE_MONTHS_PER_WEEK; + case 'semiannual': + case 'semi-annual': + return amount / 6; + case 'lifetime': + return 0; + case 'monthly': + default: + return amount; + } +} + +export function calculateMonthlySpend(subscriptions: MonthlyPricedSubscription[]): number { + return subscriptions.reduce( + (total, sub) => total + normalizeToMonthlyAmount(sub.price, sub.billing_cycle ?? sub.billingCycle), + 0, + ); +} + +export function buildCategoryMonthlySpend( + subscriptions: MonthlyPricedSubscription[], + fallbackCategory = 'Other', +): CategoryMonthlySpend[] { + const totalMonthlySpend = calculateMonthlySpend(subscriptions); + const categories = new Map(); + + for (const sub of subscriptions) { + const category = sub.category || fallbackCategory; + const current = categories.get(category) ?? { total: 0, count: 0 }; + current.total += normalizeToMonthlyAmount(sub.price, sub.billing_cycle ?? sub.billingCycle); + current.count += 1; + categories.set(category, current); + } + + return Array.from(categories.entries()) + .map(([category, data]) => ({ + category, + totalMonthlySpend: roundMoney(data.total), + count: data.count, + percentage: totalMonthlySpend > 0 ? (data.total / totalMonthlySpend) * 100 : 0, + })) + .sort((a, b) => b.totalMonthlySpend - a.totalMonthlySpend); +} + +export function getTopMonthlySpendSubscriptions( + subscriptions: MonthlyPricedSubscription[], + limit = 5, +): TopMonthlySubscription[] { + return subscriptions + .map((sub) => { + const billingCycle = sub.billing_cycle ?? sub.billingCycle ?? 'monthly'; + + return { + id: sub.id, + name: sub.name, + price: toNumber(sub.price), + billing_cycle: billingCycle, + monthlyNormalizedPrice: normalizeToMonthlyAmount(sub.price, billingCycle), + }; + }) + .sort((a, b) => b.monthlyNormalizedPrice - a.monthlyNormalizedPrice) + .slice(0, limit); +} + +export function countUpcomingRenewals( + subscriptions: MonthlyPricedSubscription[], + daysAhead: number, + now = new Date(), +): number { + const windowEnd = new Date(now); + windowEnd.setDate(windowEnd.getDate() + daysAhead); + + return subscriptions.filter((sub) => { + if (!sub.next_billing_date) return false; + const renewalDate = new Date(sub.next_billing_date); + return renewalDate <= windowEnd && renewalDate >= now; + }).length; +} + +export function buildPastMonthlySpendTrend( + subscriptions: MonthlyPricedSubscription[], + months = 6, + now = new Date(), +): MonthlySpendPoint[] { + const trend: MonthlySpendPoint[] = []; + + for (let index = months - 1; index >= 0; index--) { + const targetDate = new Date(now.getFullYear(), now.getMonth() - index, 1); + const monthEnd = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0); + const subsAtTime = subscriptions.filter((sub) => { + const createdAt = sub.created_at ?? sub.createdAt; + if (!createdAt) return true; + return new Date(createdAt) <= monthEnd; + }); + + trend.push({ + month: formatMonthKey(targetDate), + totalMonthlySpend: roundMoney(calculateMonthlySpend(subsAtTime)), + count: subsAtTime.length, + }); + } + + return trend; +}