From 566aac1ad3140b38861e52527f62e4fdcaf94f5f Mon Sep 17 00:00:00 2001 From: Okey Amy Date: Mon, 19 Oct 2026 18:10:22 +0100 Subject: [PATCH 1/4] feat(cancellation): per-plan cancellation reasons and reporting (#1728) Plans can register their own cancellation-reason vocabulary and opt in to requiring a reason; unconfigured plans keep the default list and stay optional. Analytics aggregates cancellations by plan and reason and can be filtered to a single plan via GET /cancellation/analytics?planId=. Closes #1728 --- .../__tests__/cancellationService.test.ts | 167 +++++++++++++++++- .../controller/cancellationController.ts | 46 ++++- .../domain/cancellationService.ts | 120 ++++++++++++- .../subscription/router/cancellationRouter.ts | 14 +- 4 files changed, 339 insertions(+), 8 deletions(-) diff --git a/backend/subscription/__tests__/cancellationService.test.ts b/backend/subscription/__tests__/cancellationService.test.ts index fc427462..486fca0d 100644 --- a/backend/subscription/__tests__/cancellationService.test.ts +++ b/backend/subscription/__tests__/cancellationService.test.ts @@ -4,7 +4,10 @@ * @see https://github.com/Smartdevs17/SubTrackr/issues/1119 */ -import { CancellationService } from '../domain/cancellationService'; +import { + CancellationService, + DEFAULT_CANCELLATION_REASONS, +} from '../domain/cancellationService'; describe('CancellationService', () => { let service: CancellationService; @@ -238,4 +241,166 @@ describe('CancellationService', () => { expect(analytics.averageTimeToCancel).toBeGreaterThan(0); }); }); + + describe('per-plan cancellation reasons', () => { + it('falls back to the default vocabulary when a plan is not configured', () => { + expect(service.getPlanReasons()).toEqual(DEFAULT_CANCELLATION_REASONS); + expect(service.getPlanReasons('unconfigured')).toEqual(DEFAULT_CANCELLATION_REASONS); + expect(service.getPlanReasonConfig('unconfigured')).toBeNull(); + }); + + it('stores a plan-specific reason list', () => { + const config = service.configurePlanReasons({ + planId: 'plan_pro', + reasons: ['too_expensive', 'missing_features', 'integration_limits'], + }); + + expect(config.reasons).toEqual([ + 'too_expensive', + 'missing_features', + 'integration_limits', + ]); + expect(config.requireReason).toBe(false); + expect(service.getPlanReasons('plan_pro')).toEqual(config.reasons); + expect(service.listPlanReasonConfigs()).toHaveLength(1); + }); + + it('deduplicates and trims configured reasons', () => { + const config = service.configurePlanReasons({ + planId: 'plan_pro', + reasons: [' too_expensive ', 'too_expensive', 'not_using'], + }); + + expect(config.reasons).toEqual(['too_expensive', 'not_using']); + }); + + it('rejects an empty reason list', () => { + expect(() => service.configurePlanReasons({ planId: 'plan_pro', reasons: [] })).toThrow( + 'At least one cancellation reason is required', + ); + }); + + it('keeps the reason optional by default', () => { + service.configurePlanReasons({ + planId: 'plan_pro', + reasons: ['too_expensive', 'not_using'], + }); + + const { cancellation, retentionOffer } = service.initiate({ + subscriptionId: 'sub_optional', + userId: 'user_001', + planId: 'plan_pro', + mode: 'end_of_period', + }); + + expect(cancellation.reason).toBeUndefined(); + expect(cancellation.planId).toBe('plan_pro'); + expect(retentionOffer).toBeNull(); + }); + + it('can require a reason for a plan', () => { + service.configurePlanReasons({ + planId: 'plan_enterprise', + reasons: ['too_expensive', 'found_alternative'], + requireReason: true, + }); + + expect(() => + service.initiate({ + subscriptionId: 'sub_required', + userId: 'user_001', + planId: 'plan_enterprise', + mode: 'end_of_period', + }), + ).toThrow('Cancellation reason is required'); + }); + + it('rejects a reason that is not configured for the plan', () => { + service.configurePlanReasons({ + planId: 'plan_pro', + reasons: ['too_expensive', 'not_using'], + }); + + expect(() => + service.initiate({ + subscriptionId: 'sub_invalid', + userId: 'user_001', + planId: 'plan_pro', + mode: 'end_of_period', + reason: 'found_alternative', + }), + ).toThrow('is not configured for plan'); + }); + + it('accepts a custom reason configured by the plan', () => { + service.configurePlanReasons({ + planId: 'plan_pro', + reasons: ['integration_limits'], + }); + + const { cancellation } = service.initiate({ + subscriptionId: 'sub_custom', + userId: 'user_001', + planId: 'plan_pro', + mode: 'end_of_period', + reason: 'integration_limits', + }); + + expect(cancellation.reason).toBe('integration_limits'); + }); + + it('aggregates cancellation counts by plan and reason', () => { + service.initiate({ + subscriptionId: 'sub_1', + userId: 'user_001', + planId: 'plan_pro', + mode: 'end_of_period', + reason: 'too_expensive', + }); + service.initiate({ + subscriptionId: 'sub_2', + userId: 'user_002', + planId: 'plan_pro', + mode: 'immediate', + reason: 'too_expensive', + }); + service.initiate({ + subscriptionId: 'sub_3', + userId: 'user_003', + planId: 'plan_basic', + mode: 'end_of_period', + reason: 'not_using', + }); + + const analytics = service.getAnalytics(); + + expect(analytics.byPlan['plan_pro']).toBe(2); + expect(analytics.byPlan['plan_basic']).toBe(1); + expect(analytics.byPlanReason['plan_pro']['too_expensive']).toBe(2); + expect(analytics.byPlanReason['plan_basic']['not_using']).toBe(1); + }); + + it('filters analytics to a single plan', () => { + service.initiate({ + subscriptionId: 'sub_1', + userId: 'user_001', + planId: 'plan_pro', + mode: 'end_of_period', + reason: 'too_expensive', + }); + service.initiate({ + subscriptionId: 'sub_2', + userId: 'user_002', + planId: 'plan_basic', + mode: 'end_of_period', + reason: 'not_using', + }); + + const analytics = service.getAnalytics({ planId: 'plan_pro' }); + + expect(analytics.totalCancellations).toBe(1); + expect(analytics.byReason).toEqual({ too_expensive: 1 }); + expect(analytics.byPlan).toEqual({ plan_pro: 1 }); + }); + }); }); diff --git a/backend/subscription/controller/cancellationController.ts b/backend/subscription/controller/cancellationController.ts index 28365cd6..df07f1ab 100644 --- a/backend/subscription/controller/cancellationController.ts +++ b/backend/subscription/controller/cancellationController.ts @@ -33,6 +33,7 @@ export class CancellationController { const subscriptionId = req.params.id; const body = req.body as { userId: string; + planId?: string; mode?: CancellationMode; reason?: CancellationReason; nextBillingDate?: string; @@ -47,6 +48,7 @@ export class CancellationController { const result = this.service.initiate({ subscriptionId, userId: body.userId, + planId: body.planId, mode: body.mode ?? 'end_of_period', reason: body.reason, nextBillingDate: body.nextBillingDate, @@ -154,10 +156,50 @@ export class CancellationController { send(res, ok({ cancellations, feedback }, requestId)); } - /** GET /cancellation/analytics */ + /** PUT /cancellation/plans/:planId/reasons */ + configureReasons(req: Request, res: Response): void { + const requestId = requestIdFrom(req); + const planId = req.params.planId; + const body = req.body as { + reasons?: CancellationReason[]; + requireReason?: boolean; + }; + + try { + const config = this.service.configurePlanReasons({ + planId, + reasons: body.reasons ?? [], + requireReason: body.requireReason, + }); + send(res, ok(config, requestId)); + } catch (err) { + send(res, fail('VALIDATION_ERROR', (err as Error).message, requestId)); + } + } + + /** GET /cancellation/plans/:planId/reasons */ + reasons(req: Request, res: Response): void { + const requestId = requestIdFrom(req); + const planId = req.params.planId; + + send( + res, + ok( + { + planId, + reasons: this.service.getPlanReasons(planId), + config: this.service.getPlanReasonConfig(planId), + }, + requestId, + ), + ); + } + + /** GET /cancellation/analytics?planId=... */ analytics(req: Request, res: Response): void { const requestId = requestIdFrom(req); - const report = this.service.getAnalytics(); + const planId = typeof req.query['planId'] === 'string' ? req.query['planId'] : undefined; + const report = this.service.getAnalytics({ planId }); send(res, ok(report, requestId)); } } diff --git a/backend/subscription/domain/cancellationService.ts b/backend/subscription/domain/cancellationService.ts index 64b29aff..5275dac0 100644 --- a/backend/subscription/domain/cancellationService.ts +++ b/backend/subscription/domain/cancellationService.ts @@ -10,7 +10,7 @@ export type CancellationMode = 'immediate' | 'end_of_period'; -export type CancellationReason = +export type KnownCancellationReason = | 'too_expensive' | 'not_using' | 'missing_features' @@ -19,6 +19,35 @@ export type CancellationReason = | 'technical_issues' | 'other'; +/** + * Plans may register their own reason vocabulary. The union keeps the + * built-in reasons discoverable while the `(string & {})` arm allows a plan + * to add wording specific to its product without a type change. + */ +export type CancellationReason = KnownCancellationReason | (string & {}); + +/** Reasons offered when a plan has not configured its own list. */ +export const DEFAULT_CANCELLATION_REASONS: KnownCancellationReason[] = [ + 'too_expensive', + 'not_using', + 'missing_features', + 'found_alternative', + 'poor_experience', + 'technical_issues', + 'other', +]; + +export interface PlanCancellationReasonConfig { + planId: string; + /** Reasons shown on the cancellation form for this plan. */ + reasons: CancellationReason[]; + /** + * When true a reason must be supplied for this plan. Defaults to false, so + * the reason stays optional unless a plan explicitly opts in. + */ + requireReason?: boolean; +} + export type RetentionOfferType = | 'discount' | 'plan_downgrade' @@ -69,6 +98,8 @@ export interface CancellationRecord { id: string; subscriptionId: string; userId: string; + /** Plan the subscription was on when the cancellation was requested. */ + planId?: string; mode: CancellationMode; status: CancellationStatus; reason?: CancellationReason; @@ -89,6 +120,10 @@ export interface CancellationAnalytics { offerAcceptanceRate: number; byReason: Record; byMode: Record; + /** Cancellation count per plan. Records without a plan are omitted. */ + byPlan: Record; + /** Reason counts per plan, for per-plan reporting. */ + byPlanReason: Record>; averageTimeToCancel: number; // days from creation to cancellation request } @@ -140,10 +175,62 @@ export class CancellationService { private offers = new Map(); private feedback = new Map(); private subscriptionCreatedAt = new Map(); + private planReasonConfigs = new Map(); /** Default retention offer expiry: 7 days */ private offerExpiryDays = 7; + /** + * Configure the cancellation reasons offered for a plan. + * Replaces any previous configuration for the same plan. + */ + configurePlanReasons(config: PlanCancellationReasonConfig): PlanCancellationReasonConfig { + if (!config.planId) { + throw new Error('planId is required'); + } + if (!Array.isArray(config.reasons) || config.reasons.length === 0) { + throw new Error('At least one cancellation reason is required'); + } + + const reasons = Array.from( + new Set(config.reasons.map((reason) => reason.trim()).filter(Boolean)), + ); + if (reasons.length === 0) { + throw new Error('At least one non-empty cancellation reason is required'); + } + + const normalized: PlanCancellationReasonConfig = { + planId: config.planId, + reasons, + requireReason: config.requireReason ?? false, + }; + this.planReasonConfigs.set(config.planId, normalized); + return { ...normalized, reasons: [...reasons] }; + } + + /** Get the reason configuration for a plan, or null when not configured. */ + getPlanReasonConfig(planId: string): PlanCancellationReasonConfig | null { + const config = this.planReasonConfigs.get(planId); + return config ? { ...config, reasons: [...config.reasons] } : null; + } + + /** List the reasons a plan accepts. Falls back to the default vocabulary. */ + getPlanReasons(planId?: string): CancellationReason[] { + if (planId) { + const config = this.planReasonConfigs.get(planId); + if (config) return [...config.reasons]; + } + return [...DEFAULT_CANCELLATION_REASONS]; + } + + /** All explicitly configured plan reason lists. */ + listPlanReasonConfigs(): PlanCancellationReasonConfig[] { + return Array.from(this.planReasonConfigs.values()).map((config) => ({ + ...config, + reasons: [...config.reasons], + })); + } + /** * Initiate a cancellation request. * If retention offers are applicable, one is generated and returned alongside @@ -153,6 +240,7 @@ export class CancellationService { initiate(params: { subscriptionId: string; userId: string; + planId?: string; mode: CancellationMode; reason?: CancellationReason; nextBillingDate?: number | string; @@ -167,6 +255,16 @@ export class CancellationService { ); } + const planConfig = params.planId ? this.planReasonConfigs.get(params.planId) : undefined; + if (params.reason && planConfig && !planConfig.reasons.includes(params.reason)) { + throw new Error( + `Reason "${params.reason}" is not configured for plan "${params.planId}"`, + ); + } + if (!params.reason && planConfig?.requireReason) { + throw new Error(`Cancellation reason is required for plan "${params.planId}"`); + } + const effectiveAt = params.mode === 'immediate' ? now @@ -180,6 +278,7 @@ export class CancellationService { id: generateId('cancel'), subscriptionId: params.subscriptionId, userId: params.userId, + planId: params.planId, mode: params.mode, status: params.mode === 'immediate' ? 'active' : 'pending', reason: params.reason, @@ -384,9 +483,11 @@ export class CancellationService { this.subscriptionCreatedAt.set(subscriptionId, createdAt); } - /** Build cancellation analytics. */ - getAnalytics(): CancellationAnalytics { - const all = Array.from(this.cancellations.values()); + /** Build cancellation analytics, optionally restricted to one plan. */ + getAnalytics(filter: { planId?: string } = {}): CancellationAnalytics { + const all = Array.from(this.cancellations.values()).filter( + (cancellation) => !filter.planId || cancellation.planId === filter.planId, + ); const total = all.length; const active = all.filter((c) => c.status === 'active').length; const pending = all.filter((c) => c.status === 'pending').length; @@ -402,14 +503,23 @@ export class CancellationService { const byReason: Record = {}; const byMode: Record = {}; + const byPlan: Record = {}; + const byPlanReason: Record> = {}; let timeToCancelSum = 0; let timeToCancelCount = 0; for (const c of all) { if (c.reason) { byReason[c.reason] = (byReason[c.reason] ?? 0) + 1; + if (c.planId) { + byPlanReason[c.planId] = byPlanReason[c.planId] ?? {}; + byPlanReason[c.planId][c.reason] = (byPlanReason[c.planId][c.reason] ?? 0) + 1; + } } byMode[c.mode] = (byMode[c.mode] ?? 0) + 1; + if (c.planId) { + byPlan[c.planId] = (byPlan[c.planId] ?? 0) + 1; + } const created = this.subscriptionCreatedAt.get(c.subscriptionId); if (created) { @@ -427,6 +537,8 @@ export class CancellationService { offerAcceptanceRate: Math.round(offerAcceptanceRate * 100) / 100, byReason, byMode, + byPlan, + byPlanReason, averageTimeToCancel: timeToCancelCount > 0 ? Math.round((timeToCancelSum / timeToCancelCount) * 100) / 100 : 0, }; diff --git a/backend/subscription/router/cancellationRouter.ts b/backend/subscription/router/cancellationRouter.ts index 4e611925..c3ebe7c8 100644 --- a/backend/subscription/router/cancellationRouter.ts +++ b/backend/subscription/router/cancellationRouter.ts @@ -9,7 +9,9 @@ * POST /retention/:offerId/decline – decline retention offer * GET /subscriptions/:id/cancel/status – get cancellation status * GET /subscriptions/:id/cancel/history – cancellation history - * GET /cancellation/analytics – aggregated analytics + * GET /cancellation/analytics – aggregated analytics (?planId=) + * GET /cancellation/plans/:planId/reasons – plan's reason vocabulary + * PUT /cancellation/plans/:planId/reasons – configure plan reasons * * @see https://github.com/Smartdevs17/SubTrackr/issues/1119 */ @@ -77,6 +79,16 @@ export function createCancellationRouter( wrap((req, res) => controller.analytics(req, res)), ); + router.get( + '/cancellation/plans/:planId/reasons', + wrap((req, res) => controller.reasons(req, res)), + ); + + router.put( + '/cancellation/plans/:planId/reasons', + wrap((req, res) => controller.configureReasons(req, res)), + ); + return router; } From 22ff7ab49f482cde101bf95066ff387914616ce4 Mon Sep 17 00:00:00 2001 From: Okey Amy Date: Mon, 19 Oct 2026 18:11:14 +0100 Subject: [PATCH 2/4] feat(calendar): configurable renewal reminder intervals and preferences (#1730) Reminder lead times (default 30/7/1 days) are now readable and updatable at runtime through GET/PUT /api/v1/calendar/reminders/config. A per-subscription notificationsEnabled flag and an isReminderEnabled(userId, eventType) hook let account-level notification preferences suppress individual reminder types. Closes #1730 --- .../RenewalReminderScheduler.test.ts | 101 ++++++++++++++++++ .../controller/renewalReminderController.ts | 30 ++++++ .../domain/RenewalReminderScheduler.ts | 99 +++++++++++++++-- backend/server/createApiServer.ts | 9 ++ 4 files changed, 233 insertions(+), 6 deletions(-) diff --git a/backend/calendar/__tests__/RenewalReminderScheduler.test.ts b/backend/calendar/__tests__/RenewalReminderScheduler.test.ts index 52d21b87..ba7db052 100644 --- a/backend/calendar/__tests__/RenewalReminderScheduler.test.ts +++ b/backend/calendar/__tests__/RenewalReminderScheduler.test.ts @@ -326,3 +326,104 @@ describe('RenewalReminderScheduler — default config uses DEFAULT_LEAD_TIMES', expect(result.created).toBe(DEFAULT_LEAD_TIMES.length); }); }); + +describe('RenewalReminderScheduler — configurable intervals', () => { + it('exposes the default lead times via getConfig', () => { + const scheduler = new RenewalReminderScheduler(makeService()); + expect(scheduler.getConfig().leadTimes).toEqual(DEFAULT_LEAD_TIMES); + }); + + it('applies custom intervals set at construction time', () => { + const svc = makeService(); + connectCalendar(svc, 'user_001'); + + const scheduler = new RenewalReminderScheduler(svc, { + leadTimes: [{ daysBeforeRenewal: 14, eventType: 'renewal', label: 'Two Weeks Out' }], + }); + + const result = scheduler.scheduleRenewals([makeSubscription({ nextBillingDate: isoDateIn(21) })], new Date()); + expect(result.created).toBe(1); + expect(scheduler.getRemindersForSubscription('sub_001')[0]?.eventType).toBe('renewal'); + }); + + it('updates intervals at runtime via configure', () => { + const svc = makeService(); + connectCalendar(svc, 'user_001'); + + const scheduler = new RenewalReminderScheduler(svc, { + leadTimes: [{ daysBeforeRenewal: 7, eventType: 'renewal', label: 'Upcoming' }], + }); + + const updated = scheduler.configure({ + leadTimes: [ + { daysBeforeRenewal: 10, eventType: 'renewal', label: 'Ten Days Out' }, + { daysBeforeRenewal: 2, eventType: 'payment_due', label: 'Two Days Out' }, + ], + }); + + expect(updated.leadTimes).toHaveLength(2); + + // Bill 21 days out so both 10-day and 2-day reminders are still ahead. + const result = scheduler.scheduleRenewals( + [makeSubscription({ nextBillingDate: isoDateIn(21) })], + new Date(), + ); + expect(result.created).toBe(2); + }); + + it('rejects invalid intervals', () => { + const scheduler = new RenewalReminderScheduler(makeService()); + + expect(() => scheduler.configure({ leadTimes: [] })).toThrow(); + expect(() => + scheduler.configure({ + leadTimes: [{ daysBeforeRenewal: 0, eventType: 'renewal', label: 'Nope' }], + }), + ).toThrow(); + expect(() => + scheduler.configure({ + leadTimes: [{ daysBeforeRenewal: 7, eventType: 'renewal', label: '' }], + }), + ).toThrow(); + }); +}); + +describe('RenewalReminderScheduler — notification preferences', () => { + it('skips reminder types that the account preference hook disables', () => { + const svc = makeService(); + connectCalendar(svc, 'user_001'); + + const scheduler = new RenewalReminderScheduler(svc, { + leadTimes: [ + { daysBeforeRenewal: 7, eventType: 'renewal', label: 'Upcoming' }, + { daysBeforeRenewal: 1, eventType: 'payment_due', label: 'Tomorrow' }, + ], + isReminderEnabled: (_userId, eventType) => eventType !== 'payment_due', + }); + + const result = scheduler.scheduleRenewals([makeSubscription()], new Date()); + + expect(result.created).toBe(1); + expect(result.skipped).toBe(1); + expect(scheduler.getRemindersForSubscription('sub_001')[0]?.eventType).toBe('renewal'); + }); + + it('cancels and suppresses reminders when notificationsEnabled is false', () => { + const svc = makeService(); + connectCalendar(svc, 'user_001'); + + const scheduler = new RenewalReminderScheduler(svc, { + leadTimes: [{ daysBeforeRenewal: 7, eventType: 'renewal', label: 'Upcoming' }], + }); + + scheduler.scheduleRenewals([makeSubscription()], new Date()); + expect(scheduler.getRemindersForSubscription('sub_001')).toHaveLength(1); + + const disabled = makeSubscription({ notificationsEnabled: false }); + const result = scheduler.scheduleRenewals([disabled], new Date()); + + expect(result.deleted).toBe(1); + expect(result.created).toBe(0); + expect(scheduler.getRemindersForSubscription('sub_001')).toHaveLength(0); + }); +}); diff --git a/backend/calendar/controller/renewalReminderController.ts b/backend/calendar/controller/renewalReminderController.ts index 2f6f0a47..1a7c8e12 100644 --- a/backend/calendar/controller/renewalReminderController.ts +++ b/backend/calendar/controller/renewalReminderController.ts @@ -11,10 +11,14 @@ * GET /user/:userId — get reminders for a user * GET /upcoming — get reminders firing within N days * POST /process — run the full reminder sweep (cron endpoint) + * GET /config — get the reminder lead-time configuration + * PUT /config — update the reminder lead-time configuration */ import type { RenewalReminderScheduler, + ReminderLeadTime, + ReminderSchedulerConfig, SubscriptionForReminder, ScheduleRenewalsResult, ReminderRecord, @@ -137,5 +141,31 @@ export function createRenewalReminderController(deps: { const result = scheduler.processReminders(body.subscriptions, now); return { success: true, data: result }; }, + + /** + * GET /reminders/config + * Returns the configurable reminder intervals (and other scheduler options). + */ + getConfig(): ControllerResult { + return { success: true, data: scheduler.getConfig() }; + }, + + /** + * PUT /reminders/config + * Body: Partial + * Updates the reminder intervals at runtime, e.g. `{ leadTimes: [...] }`. + */ + updateConfig(body: Partial): ControllerResult { + try { + const config = scheduler.configure(body ?? {}); + return { success: true, data: config }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Invalid reminder configuration', + status: 400, + }; + } + }, }; } diff --git a/backend/calendar/domain/RenewalReminderScheduler.ts b/backend/calendar/domain/RenewalReminderScheduler.ts index 9709d077..b1105e0b 100644 --- a/backend/calendar/domain/RenewalReminderScheduler.ts +++ b/backend/calendar/domain/RenewalReminderScheduler.ts @@ -39,6 +39,11 @@ export interface SubscriptionForReminder { status: 'active' | 'paused' | 'trialing' | 'cancelled'; /** ISO date string when the trial ends (if trialing) */ trialEndDate?: string; + /** + * Per-subscription kill switch. When false, existing reminders are removed + * and no new ones are created. + */ + notificationsEnabled?: boolean; } export interface ReminderLeadTime { @@ -51,12 +56,18 @@ export interface ReminderLeadTime { } export interface ReminderSchedulerConfig { - /** Lead-time windows to create. Default: 7-day and 1-day warnings. */ + /** Lead-time windows to create. Default: 30-day, 7-day and 1-day warnings. */ leadTimes: ReminderLeadTime[]; /** Event duration in minutes. Default: 60. */ eventDurationMinutes: number; /** Whether to update existing events when billing dates change. Default: true. */ updateExistingEvents: boolean; + /** + * Optional hook consulted before a reminder is created, so account-level + * notification preferences can suppress a reminder type. Return false to + * skip that type for the user. + */ + isReminderEnabled?: (userId: string, eventType: CalendarEventType) => boolean; } export interface ScheduleRenewalsResult { @@ -118,14 +129,64 @@ export class RenewalReminderScheduler { ) { this.calendarService = calendarService; this.config = { - leadTimes: config.leadTimes ?? DEFAULT_SCHEDULER_CONFIG.leadTimes, + leadTimes: (config.leadTimes ?? DEFAULT_SCHEDULER_CONFIG.leadTimes).map((leadTime) => ({ + ...leadTime, + })), eventDurationMinutes: config.eventDurationMinutes ?? DEFAULT_SCHEDULER_CONFIG.eventDurationMinutes, updateExistingEvents: config.updateExistingEvents ?? DEFAULT_SCHEDULER_CONFIG.updateExistingEvents, + isReminderEnabled: config.isReminderEnabled, + }; + } + + // ── Configuration ───────────────────────────────────────────────────────── + + /** Current scheduler configuration (defensive copy). */ + getConfig(): ReminderSchedulerConfig { + return { + ...this.config, + leadTimes: this.config.leadTimes.map((leadTime) => ({ ...leadTime })), }; } + /** + * Update the lead-time windows and other scheduling options at runtime. + * Validates that every window is a positive, whole number of days. + */ + configure(config: Partial): ReminderSchedulerConfig { + if (config.leadTimes) { + if (!Array.isArray(config.leadTimes) || config.leadTimes.length === 0) { + throw new Error('leadTimes must contain at least one reminder window'); + } + for (const leadTime of config.leadTimes) { + if ( + !Number.isInteger(leadTime.daysBeforeRenewal) || + leadTime.daysBeforeRenewal < 1 + ) { + throw new Error('daysBeforeRenewal must be a positive integer'); + } + if (!leadTime.eventType || !leadTime.label) { + throw new Error('Each lead time requires an eventType and label'); + } + } + this.config.leadTimes = config.leadTimes.map((leadTime) => ({ ...leadTime })); + } + if (config.eventDurationMinutes !== undefined) { + if (!Number.isFinite(config.eventDurationMinutes) || config.eventDurationMinutes <= 0) { + throw new Error('eventDurationMinutes must be a positive number'); + } + this.config.eventDurationMinutes = config.eventDurationMinutes; + } + if (config.updateExistingEvents !== undefined) { + this.config.updateExistingEvents = config.updateExistingEvents; + } + if (config.isReminderEnabled !== undefined) { + this.config.isReminderEnabled = config.isReminderEnabled; + } + return this.getConfig(); + } + // ── Public API ───────────────────────────────────────────────────────────── /** @@ -267,8 +328,9 @@ export class RenewalReminderScheduler { errors: [], }; - // Skip non-active subscriptions; clean up their events - if (subscription.status === 'cancelled') { + // Skip non-active subscriptions and subscriptions with reminders switched + // off; clean up their events in both cases. + if (subscription.status === 'cancelled' || subscription.notificationsEnabled === false) { result.deleted += this.cancelRenewalReminders(subscription.id); return result; } @@ -291,6 +353,12 @@ export class RenewalReminderScheduler { // Skip if reminder date is already in the past if (reminderDate <= now) continue; + // Respect the account's notification preferences for this event type. + if (!this.isReminderAllowed(subscription, leadTime.eventType)) { + result.skipped++; + continue; + } + for (const connection of activeConnections) { // Check if this event type is enabled for the connection if (!connection.enabledEventTypes.includes(leadTime.eventType)) continue; @@ -301,6 +369,7 @@ export class RenewalReminderScheduler { leadTime.eventType, formatDate(renewalDate), connection.id, + leadTime.daysBeforeRenewal, ); const title = this.buildEventTitle(subscription, leadTime); @@ -361,7 +430,11 @@ export class RenewalReminderScheduler { } // ── Trial ending reminders ───────────────────────────────────────────── - if (subscription.status === 'trialing' && subscription.trialEndDate) { + if ( + subscription.status === 'trialing' && + subscription.trialEndDate && + this.isReminderAllowed(subscription, 'trial_ending') + ) { const trialEnd = new Date(subscription.trialEndDate); const trialReminder = new Date(trialEnd); trialReminder.setDate(trialReminder.getDate() - 3); // 3-day warning @@ -423,6 +496,18 @@ export class RenewalReminderScheduler { // ── Helpers ──────────────────────────────────────────────────────────────── + /** Account-level notification preference check for one reminder type. */ + private isReminderAllowed( + subscription: SubscriptionForReminder, + eventType: CalendarEventType, + ): boolean { + if (subscription.notificationsEnabled === false) return false; + if (this.config.isReminderEnabled) { + return this.config.isReminderEnabled(subscription.userId, eventType) !== false; + } + return true; + } + private buildEventTitle( subscription: SubscriptionForReminder, leadTime: ReminderLeadTime, @@ -464,8 +549,10 @@ export class RenewalReminderScheduler { eventType: CalendarEventType, billingDate: string, connectionId: string, + leadTimeDays?: number, ): string { - return `${subscriptionId}::${eventType}::${billingDate}::${connectionId}`; + const lead = leadTimeDays === undefined ? '-' : `${leadTimeDays}d`; + return `${subscriptionId}::${eventType}::${lead}::${billingDate}::${connectionId}`; } private addMinutes(date: Date, minutes: number): Date { diff --git a/backend/server/createApiServer.ts b/backend/server/createApiServer.ts index 223e5bf8..733a28ce 100644 --- a/backend/server/createApiServer.ts +++ b/backend/server/createApiServer.ts @@ -451,6 +451,15 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express { res.status(result.success ? 200 : (result.status ?? 400)).json(result); }); + calRouter.get('/reminders/config', (_req: Request, res: Response) => { + res.json(reminderController.getConfig()); + }); + + calRouter.put('/reminders/config', (req: Request, res: Response) => { + const result = reminderController.updateConfig(req.body); + res.status(result.success ? 200 : (result.status ?? 400)).json(result); + }); + app.use('/api/v1/calendar', calRouter); // ── 404 fallback ─────────────────────────────────────────────────────────── From 9cacf0197f0809f85574ff11fc70a5fb2009f6b6 Mon Sep 17 00:00:00 2001 From: Okey Amy Date: Mon, 19 Oct 2026 18:14:22 +0100 Subject: [PATCH 3/4] feat(subscriptions): timestamped lifecycle history timeline (#1729) Every subscription state transition (created, updated, price changed, plan changed, paused, resumed, cancelled) is recorded with a timestamp in the subscription store and shown as a timeline on the detail screen. The history is persisted with the store and exportable as CSV from the UI. Closes #1729 --- src/screens/SubscriptionDetailScreen.tsx | 90 ++++++++ .../subscriptionLifecycleHistory.test.ts | 208 ++++++++++++++++++ src/store/subscriptionStore.ts | 82 ++++++- src/types/subscription.ts | 22 ++ src/utils/__tests__/lifecycleHistory.test.ts | 116 ++++++++++ src/utils/lifecycleHistory.ts | 107 +++++++++ 6 files changed, 621 insertions(+), 4 deletions(-) create mode 100644 src/store/__tests__/subscriptionLifecycleHistory.test.ts create mode 100644 src/utils/__tests__/lifecycleHistory.test.ts create mode 100644 src/utils/lifecycleHistory.ts diff --git a/src/screens/SubscriptionDetailScreen.tsx b/src/screens/SubscriptionDetailScreen.tsx index f126fd33..8f87c051 100644 --- a/src/screens/SubscriptionDetailScreen.tsx +++ b/src/screens/SubscriptionDetailScreen.tsx @@ -20,6 +20,7 @@ import { currencyService } from '../services/currencyService'; import { formatCurrency } from '../utils/formatting'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { getCategoryIcon } from '../utils/subscriptionHelpers'; +import { buildLifecycleHistoryCSV, getLifecycleEventLabel } from '../utils/lifecycleHistory'; import { RootStackParamList } from '../navigation/types'; import { CreditPaymentMethod } from '../types/credit'; import { useGroupStore } from '../store/groupStore'; @@ -74,6 +75,7 @@ const SubscriptionDetailScreen: React.FC = () => { applyCreditToInvoice, expireCredits, setCreditPolicy, + getLifecycleHistory, } = useSubscriptionStore(); const { subscriptions, toggleSubscriptionStatus, updateSubscription, recordBillingOutcome } = useSubscriptionStore(); @@ -166,6 +168,29 @@ const SubscriptionDetailScreen: React.FC = () => { .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; }, [invoices, subscription?.id]); + const lifecycleEvents = useMemo( + () => (subscription ? getLifecycleHistory(subscription.id) : []), + [subscription, getLifecycleHistory] + ); + + const handleExportHistory = useCallback(async () => { + if (!subscription) return; + const events = getLifecycleHistory(subscription.id); + if (events.length === 0) { + Alert.alert( + 'No history yet', + 'Lifecycle events will appear here as this subscription changes.' + ); + return; + } + + const { Share } = await import('react-native'); + await Share.share({ + title: `${subscription.name} — lifecycle history`, + message: buildLifecycleHistoryCSV(events), + }); + }, [getLifecycleHistory, subscription]); + const handlePurchaseCredit = useCallback(async () => { if (!subscription) return; const amount = Number(creditAmount); @@ -460,6 +485,42 @@ const SubscriptionDetailScreen: React.FC = () => { + {/* Lifecycle history */} + + + Lifecycle history + void handleExportHistory()} + accessibilityRole="button" + accessibilityLabel="Export lifecycle history" + testID="export-lifecycle-history-button"> + Export + + + + Timestamped record of this subscription's state changes. + + {lifecycleEvents.length > 0 ? ( + lifecycleEvents.map((event) => ( + + + + {getLifecycleEventLabel(event.type)} + + {new Date(event.occurredAt).toLocaleString()} + {event.details ? ` · ${event.details}` : ''} + + + + )) + ) : ( + No lifecycle events recorded yet. + )} + + {/* Credit Balance */} Credit balance @@ -1006,6 +1067,35 @@ const styles = StyleSheet.create({ color: colors.textSecondary, marginTop: 2, }, + historyHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + lifecycleRow: { + flexDirection: 'row', + alignItems: 'flex-start', + marginTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border, + paddingTop: spacing.sm, + }, + lifecycleDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.primary, + marginTop: 5, + marginRight: spacing.sm, + }, + lifecycleContent: { + flex: 1, + }, + lifecycleLabel: { + ...typography.body2, + color: colors.text, + fontWeight: '700', + }, applicationRow: { borderTopWidth: 1, borderTopColor: colors.border, diff --git a/src/store/__tests__/subscriptionLifecycleHistory.test.ts b/src/store/__tests__/subscriptionLifecycleHistory.test.ts new file mode 100644 index 00000000..e326d7bb --- /dev/null +++ b/src/store/__tests__/subscriptionLifecycleHistory.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { useSubscriptionStore } from '../subscriptionStore'; +import { BillingCycle, SubscriptionCategory, type Subscription } from '../../types/subscription'; + +jest.mock('react-native', () => ({ + Share: { share: jest.fn(() => Promise.resolve({ action: 'sharedAction' })) }, + Platform: { OS: 'ios', select: (spec: Record) => spec.ios ?? spec.default }, + AppState: { + currentState: 'active', + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + InteractionManager: { runAfterInteractions: (cb: () => void) => cb() }, + Alert: { alert: jest.fn() }, + I18nManager: { isRTL: false, forceRTL: jest.fn() }, + Linking: { openURL: jest.fn(() => Promise.resolve()) }, + Vibration: { vibrate: jest.fn() }, + StyleSheet: { create: (styles: unknown) => styles, flatten: (styles: unknown) => styles }, +})); + +jest.mock('@react-native-async-storage/async-storage', () => { + const store = new Map(); + return { + setItem: jest.fn((key: string, value: string) => { + store.set(key, value); + return Promise.resolve(); + }), + getItem: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)), + removeItem: jest.fn((key: string) => { + store.delete(key); + return Promise.resolve(); + }), + clear: jest.fn(() => { + store.clear(); + return Promise.resolve(); + }), + }; +}); + +jest.mock('../../services/notificationService', () => ({ + syncRenewalReminders: jest.fn(() => Promise.resolve()), + presentChargeSuccessNotification: jest.fn(() => Promise.resolve()), + presentChargeFailedNotification: jest.fn(() => Promise.resolve()), + presentLocalNotification: jest.fn(() => Promise.resolve()), + presentDunningRetryNotification: jest.fn(() => Promise.resolve()), + presentDunningWarningNotification: jest.fn(() => Promise.resolve()), + presentDunningSuspendedNotification: jest.fn(() => Promise.resolve()), + presentDunningCancelledNotification: jest.fn(() => Promise.resolve()), + presentDunningRecoveryNotification: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../../services/network/networkMonitor', () => ({ + networkMonitor: { + isOnline: jest.fn(() => true), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }, +})); + +const makeSub = (overrides: Partial = {}): Subscription => ({ + id: 'sub-1', + name: 'Netflix', + category: SubscriptionCategory.STREAMING, + price: 9.99, + currency: 'USD', + billingCycle: BillingCycle.MONTHLY, + nextBillingDate: new Date('2026-08-30T00:00:00.000Z'), + isActive: true, + notificationsEnabled: true, + isCryptoEnabled: false, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + ...overrides, +}); + +describe('subscription lifecycle history', () => { + beforeEach(() => { + useSubscriptionStore.setState({ + subscriptions: [], + creditAccounts: {}, + pauseHistory: [], + lifecycleHistory: [], + stats: { + totalActive: 0, + totalMonthlySpend: 0, + totalYearlySpend: 0, + categoryBreakdown: { + [SubscriptionCategory.STREAMING]: 0, + [SubscriptionCategory.SOFTWARE]: 0, + [SubscriptionCategory.GAMING]: 0, + [SubscriptionCategory.PRODUCTIVITY]: 0, + [SubscriptionCategory.FITNESS]: 0, + [SubscriptionCategory.EDUCATION]: 0, + [SubscriptionCategory.FINANCE]: 0, + [SubscriptionCategory.OTHER]: 0, + }, + }, + isLoading: false, + error: null, + prorationPreview: null, + creditMemos: {}, + syncStatus: 'idle', + crdtMetadata: {}, + }); + }); + + it('records timestamped events and filters them per subscription', () => { + const store = useSubscriptionStore.getState(); + store.recordLifecycleEvent('sub-1', 'created'); + store.recordLifecycleEvent('sub-2', 'created'); + store.recordLifecycleEvent('sub-1', 'paused', 'Paused for 30 day(s)'); + + const events = useSubscriptionStore.getState().getLifecycleHistory('sub-1'); + + expect(events).toHaveLength(2); + expect(events.map((event) => event.type).sort()).toEqual(['created', 'paused']); + expect(events.every((event) => event.occurredAt instanceof Date)).toBe(true); + expect(events.find((event) => event.type === 'paused')?.details).toBe('Paused for 30 day(s)'); + expect(useSubscriptionStore.getState().getLifecycleHistory()).toHaveLength(3); + }); + + it('returns the full history newest first', () => { + useSubscriptionStore.setState({ + lifecycleHistory: [ + { + id: 'evt-old', + subscriptionId: 'sub-1', + type: 'created', + occurredAt: new Date('2026-01-01T00:00:00.000Z'), + }, + { + id: 'evt-new', + subscriptionId: 'sub-1', + type: 'paused', + occurredAt: new Date('2026-02-01T00:00:00.000Z'), + }, + ], + }); + + const events = useSubscriptionStore.getState().getLifecycleHistory('sub-1'); + expect(events.map((event) => event.id)).toEqual(['evt-new', 'evt-old']); + }); + + it('records a created event when a subscription is added', async () => { + await useSubscriptionStore.getState().addSubscription({ + name: 'Spotify', + category: SubscriptionCategory.STREAMING, + price: 11.99, + currency: 'USD', + billingCycle: BillingCycle.MONTHLY, + nextBillingDate: new Date('2026-09-01T00:00:00.000Z'), + isCryptoEnabled: false, + }); + + const sub = useSubscriptionStore.getState().subscriptions[0]; + const events = useSubscriptionStore.getState().getLifecycleHistory(sub.id); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('created'); + expect(events[0].details).toBe('Spotify added'); + }); + + it('records a price change with old and new values', async () => { + useSubscriptionStore.setState({ subscriptions: [makeSub()] }); + + await useSubscriptionStore.getState().updateSubscription('sub-1', { price: 19.99 }); + + const events = useSubscriptionStore.getState().getLifecycleHistory('sub-1'); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('price_changed'); + expect(events[0].details).toContain('9.99'); + expect(events[0].details).toContain('19.99'); + }); + + it('records a generic update when no price change occurs', async () => { + useSubscriptionStore.setState({ subscriptions: [makeSub()] }); + + await useSubscriptionStore.getState().updateSubscription('sub-1', { name: 'Netflix Premium' }); + + const events = useSubscriptionStore.getState().getLifecycleHistory('sub-1'); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('updated'); + }); + + it('records pause and resume transitions from the status toggle', async () => { + useSubscriptionStore.setState({ subscriptions: [makeSub()] }); + + await useSubscriptionStore.getState().toggleSubscriptionStatus('sub-1'); + await useSubscriptionStore.getState().toggleSubscriptionStatus('sub-1'); + + const types = useSubscriptionStore + .getState() + .getLifecycleHistory('sub-1') + .map((event) => event.type) + .sort(); + expect(types).toEqual(['paused', 'resumed']); + }); + + it('records a cancellation event when a subscription is removed', async () => { + useSubscriptionStore.setState({ subscriptions: [makeSub()] }); + + await useSubscriptionStore.getState().deleteSubscription('sub-1'); + + const events = useSubscriptionStore.getState().getLifecycleHistory('sub-1'); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('cancelled'); + }); +}); diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index ae12ec18..cf8544b3 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -7,7 +7,10 @@ import { SubscriptionStats, SubscriptionCategory, // eslint-disable-line BillingCycle, // eslint-disable-line + SubscriptionLifecycleEvent, + SubscriptionLifecycleEventType, } from '../types/subscription'; +import { createLifecycleEvent, sortLifecycleHistory } from '../utils/lifecycleHistory'; import { CreditAccountState, CreditApplicationResult, @@ -100,7 +103,7 @@ export type SubscriptionSyncStatus = 'idle' | 'pending' | 'syncing' | 'conflict' type PersistedSubscriptionSlice = Pick< SubscriptionState, - 'subscriptions' | 'creditAccounts' | 'pauseHistory' | 'crdtMetadata' + 'subscriptions' | 'creditAccounts' | 'pauseHistory' | 'crdtMetadata' | 'lifecycleHistory' >; function stampCrdtMetadata( @@ -309,6 +312,10 @@ const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscr plannedResumeDate: record.plannedResumeDate ? new Date(record.plannedResumeDate) : undefined, resumeAt: record.resumeAt ? new Date(record.resumeAt) : undefined, })), + lifecycleHistory: (state.lifecycleHistory ?? []).map((event) => ({ + ...event, + occurredAt: new Date(event.occurredAt), + })), crdtMetadata: state.crdtMetadata ?? {}, }); @@ -317,7 +324,13 @@ const migratePersistedState = ( _version: number ): PersistedSubscriptionSlice => { if (!persisted || typeof persisted !== 'object') { - return { subscriptions: [], creditAccounts: {}, pauseHistory: [], crdtMetadata: {} }; + return { + subscriptions: [], + creditAccounts: {}, + pauseHistory: [], + lifecycleHistory: [], + crdtMetadata: {}, + }; } const maybeState = persisted as Partial; @@ -354,7 +367,14 @@ const migratePersistedState = ( ? maybeState.crdtMetadata : {}; - return { subscriptions, creditAccounts, pauseHistory, crdtMetadata }; + const lifecycleHistory = Array.isArray(maybeState.lifecycleHistory) + ? maybeState.lifecycleHistory.map((event) => ({ + ...event, + occurredAt: toValidDate(event.occurredAt, new Date()), + })) + : []; + + return { subscriptions, creditAccounts, pauseHistory, lifecycleHistory, crdtMetadata }; }; const pendingWrites = new Map(); @@ -406,6 +426,8 @@ export interface SubscriptionState { subscriptions: Subscription[]; creditAccounts: Record; pauseHistory: PauseRecord[]; + /** Timestamped record of plan/subscription state transitions. */ + lifecycleHistory: SubscriptionLifecycleEvent[]; stats: SubscriptionStats; isLoading: boolean; error: AppError | null; @@ -423,6 +445,14 @@ export interface SubscriptionState { resumeSubscription: (id: string, early?: boolean) => PauseRecord | null; getPauseHistory: (subscriptionId?: string) => PauseRecord[]; getActivePause: (subscriptionId: string) => PauseRecord | undefined; + /** Record a timestamped lifecycle transition for a subscription. */ + recordLifecycleEvent: ( + subscriptionId: string, + type: SubscriptionLifecycleEventType, + details?: string + ) => SubscriptionLifecycleEvent; + /** Timestamped lifecycle events for a subscription (newest first). Omit the id for all events. */ + getLifecycleHistory: (subscriptionId?: string) => SubscriptionLifecycleEvent[]; addSubscription: (data: SubscriptionFormData) => Promise; addFromTemplate: ( callerId: string, @@ -480,6 +510,7 @@ export const useSubscriptionStore = create()( subscriptions: dummySubscriptions, creditAccounts: {}, pauseHistory: [], + lifecycleHistory: [], stats: { totalActive: 0, totalMonthlySpend: 0, @@ -609,6 +640,23 @@ export const useSubscriptionStore = create()( (record.state === PauseState.PAUSED || record.status === 'active') ), + recordLifecycleEvent: (subscriptionId, type, details) => { + const event = createLifecycleEvent(subscriptionId, type, details); + set((state) => ({ + lifecycleHistory: [...(state.lifecycleHistory ?? []), event], + })); + return event; + }, + + getLifecycleHistory: (subscriptionId) => { + const events = get().lifecycleHistory ?? []; + return sortLifecycleHistory( + subscriptionId + ? events.filter((event) => event.subscriptionId === subscriptionId) + : events + ); + }, + previewPlanChange: ( id: string, newPrice: number, @@ -661,6 +709,11 @@ export const useSubscriptionStore = create()( isLoading: false, })); + get().recordLifecycleEvent( + id, + 'plan_changed', + `${sub.price} ${sub.currency} → ${newPlanData.price ?? sub.price} ${sub.currency} (${effectiveDate})` + ); get().calculateStats(); await syncRenewalReminders(get().subscriptions); } catch (error) { @@ -708,6 +761,11 @@ export const useSubscriptionStore = create()( isLoading: false, })); + get().recordLifecycleEvent( + newSubscription.id, + 'created', + `${newSubscription.name} added` + ); get().calculateStats(); await syncRenewalReminders(get().subscriptions); await notifySubscriptionEvent(SUBSCRIPTION_EVENT.ADDED, newSubscription); @@ -790,9 +848,16 @@ export const useSubscriptionStore = create()( previous && previous.price !== data.price ) { + get().recordLifecycleEvent( + id, + 'price_changed', + `${previous.price} ${updatedSubscription.currency} → ${data.price} ${updatedSubscription.currency}` + ); await notifySubscriptionEvent(SUBSCRIPTION_EVENT.PRICE_CHANGED, updatedSubscription, { previousPrice: previous.price, }); + } else if (updatedSubscription) { + get().recordLifecycleEvent(id, 'updated'); } if (updatedSubscription) { await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); @@ -832,6 +897,11 @@ export const useSubscriptionStore = create()( isLoading: false, })); + get().recordLifecycleEvent( + id, + 'cancelled', + current ? `${current.name} removed` : undefined + ); get().calculateStats(); await syncRenewalReminders(get().subscriptions); if (current) { @@ -875,6 +945,7 @@ export const useSubscriptionStore = create()( await syncRenewalReminders(get().subscriptions); const updatedSubscription = get().subscriptions.find((sub) => sub.id === id); if (updatedSubscription) { + get().recordLifecycleEvent(id, updatedSubscription.isActive ? 'resumed' : 'paused'); await notifySubscriptionEvent( updatedSubscription.isActive ? SUBSCRIPTION_EVENT.RESUMED : SUBSCRIPTION_EVENT.PAUSED, updatedSubscription @@ -917,8 +988,9 @@ export const useSubscriptionStore = create()( get().calculateStats(); await syncRenewalReminders(get().subscriptions); - const updatedSubscription = get().subscriptions.find((sub) => sub.id === id); + const updatedSubscription = get().subscriptions.find((s) => s.id === id); if (updatedSubscription) { + get().recordLifecycleEvent(id, 'paused', `Paused for ${durationDays} day(s)`); await notifySubscriptionEvent(SUBSCRIPTION_EVENT.PAUSED, updatedSubscription); await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); } @@ -971,6 +1043,7 @@ export const useSubscriptionStore = create()( await syncRenewalReminders(get().subscriptions); const updatedSubscription = get().subscriptions.find((s) => s.id === id); if (updatedSubscription) { + get().recordLifecycleEvent(id, 'resumed'); await notifySubscriptionEvent(SUBSCRIPTION_EVENT.RESUMED, updatedSubscription); await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); } @@ -1439,6 +1512,7 @@ export const useSubscriptionStore = create()( subscriptions: state.subscriptions, creditAccounts: state.creditAccounts, pauseHistory: state.pauseHistory, + lifecycleHistory: state.lifecycleHistory, crdtMetadata: state.crdtMetadata, }), migrate: (persistedState, version) => migratePersistedState(persistedState, version), diff --git a/src/types/subscription.ts b/src/types/subscription.ts index 5ba2d2cd..b2094b65 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -108,3 +108,25 @@ export interface SubscriptionStats { categoryBreakdown: Record; totalGasSpent?: number; } + +// ── Lifecycle history ───────────────────────────────────────────────────────── + +export type SubscriptionLifecycleEventType = + | 'created' + | 'updated' + | 'price_changed' + | 'plan_changed' + | 'activated' + | 'paused' + | 'resumed' + | 'cancelled'; + +/** A single timestamped state transition in a subscription's lifecycle. */ +export interface SubscriptionLifecycleEvent { + id: string; + subscriptionId: string; + type: SubscriptionLifecycleEventType; + occurredAt: Date; + /** Optional human-readable context, e.g. "9.99 USD → 12.99 USD". */ + details?: string; +} diff --git a/src/utils/__tests__/lifecycleHistory.test.ts b/src/utils/__tests__/lifecycleHistory.test.ts new file mode 100644 index 00000000..3a0e3814 --- /dev/null +++ b/src/utils/__tests__/lifecycleHistory.test.ts @@ -0,0 +1,116 @@ +import { + createLifecycleEvent, + getLifecycleEventLabel, + sortLifecycleHistory, + buildLifecycleHistoryCSV, + buildLifecycleHistoryJSON, +} from '../lifecycleHistory'; +import type { SubscriptionLifecycleEvent } from '../../types/subscription'; + +function makeEvent( + overrides: Partial = {} +): SubscriptionLifecycleEvent { + return { + id: 'evt-1', + subscriptionId: 'sub-1', + type: 'created', + occurredAt: new Date('2026-01-01T10:00:00.000Z'), + ...overrides, + }; +} + +describe('createLifecycleEvent', () => { + it('stamps the subscription, type, details and timestamp', () => { + const occurredAt = new Date('2026-02-03T12:34:56.000Z'); + const event = createLifecycleEvent('sub-9', 'paused', 'Paused for 14 day(s)', occurredAt); + + expect(event.subscriptionId).toBe('sub-9'); + expect(event.type).toBe('paused'); + expect(event.details).toBe('Paused for 14 day(s)'); + expect(event.occurredAt).toBe(occurredAt); + expect(event.id).toBeTruthy(); + }); + + it('defaults the timestamp to now', () => { + const before = Date.now(); + const event = createLifecycleEvent('sub-9', 'resumed'); + expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before); + expect(event.occurredAt.getTime()).toBeLessThanOrEqual(Date.now()); + }); + + it('generates unique ids', () => { + const a = createLifecycleEvent('sub-1', 'created'); + const b = createLifecycleEvent('sub-1', 'created'); + expect(a.id).not.toBe(b.id); + }); +}); + +describe('getLifecycleEventLabel', () => { + it('maps known event types to readable labels', () => { + expect(getLifecycleEventLabel('price_changed')).toBe('Price changed'); + expect(getLifecycleEventLabel('cancelled')).toBe('Cancelled'); + expect(getLifecycleEventLabel('plan_changed')).toBe('Plan changed'); + }); +}); + +describe('sortLifecycleHistory', () => { + it('orders events newest first without mutating the input', () => { + const older = makeEvent({ id: 'old', occurredAt: new Date('2026-01-01T00:00:00.000Z') }); + const newer = makeEvent({ id: 'new', occurredAt: new Date('2026-03-01T00:00:00.000Z') }); + const input = [older, newer]; + + const sorted = sortLifecycleHistory(input); + + expect(sorted.map((event) => event.id)).toEqual(['new', 'old']); + expect(input[0].id).toBe('old'); + }); +}); + +describe('buildLifecycleHistoryCSV', () => { + it('emits a header plus one row per event, newest first', () => { + const csv = buildLifecycleHistoryCSV([ + makeEvent({ + id: 'a', + type: 'created', + occurredAt: new Date('2026-01-01T00:00:00.000Z'), + }), + makeEvent({ + id: 'b', + type: 'price_changed', + occurredAt: new Date('2026-02-01T00:00:00.000Z'), + details: '9.99 USD → 12.99 USD', + }), + ]); + + const lines = csv.split('\n'); + expect(lines[0]).toBe('timestamp,subscriptionId,event,details'); + expect(lines[1]).toBe('2026-02-01T00:00:00.000Z,sub-1,price_changed,9.99 USD → 12.99 USD'); + expect(lines[2]).toBe('2026-01-01T00:00:00.000Z,sub-1,created,'); + }); + + it('quotes details containing commas', () => { + const csv = buildLifecycleHistoryCSV([ + makeEvent({ type: 'paused', details: 'Paused for 14 days, no charge' }), + ]); + expect(csv).toContain('"Paused for 14 days, no charge"'); + }); + + it('returns just the header for an empty history', () => { + expect(buildLifecycleHistoryCSV([])).toBe('timestamp,subscriptionId,event,details'); + }); +}); + +describe('buildLifecycleHistoryJSON', () => { + it('wraps events with an export timestamp and count', () => { + const json = buildLifecycleHistoryJSON([ + makeEvent({ type: 'resumed', occurredAt: new Date('2026-05-01T00:00:00.000Z') }), + ]); + + const parsed = JSON.parse(json); + expect(parsed.eventCount).toBe(1); + expect(parsed.events).toHaveLength(1); + expect(parsed.events[0].event).toBe('resumed'); + expect(parsed.events[0].timestamp).toBe('2026-05-01T00:00:00.000Z'); + expect(typeof parsed.exportedAt).toBe('string'); + }); +}); diff --git a/src/utils/lifecycleHistory.ts b/src/utils/lifecycleHistory.ts new file mode 100644 index 00000000..7a8a77a6 --- /dev/null +++ b/src/utils/lifecycleHistory.ts @@ -0,0 +1,107 @@ +/** + * Lifecycle history helpers. + * + * Pure functions for building, sorting, labelling and exporting the + * timestamped state transitions recorded in the subscription store. + */ + +import type { + SubscriptionLifecycleEvent, + SubscriptionLifecycleEventType, +} from '../types/subscription'; + +const EVENT_LABELS: Record = { + created: 'Created', + updated: 'Updated', + price_changed: 'Price changed', + plan_changed: 'Plan changed', + activated: 'Activated', + paused: 'Paused', + resumed: 'Resumed', + cancelled: 'Cancelled', +}; + +let counter = 0; + +function generateEventId(): string { + counter = (counter + 1) % 1000; + return `life-${Date.now().toString(36)}-${counter.toString(36)}-${Math.random() + .toString(36) + .substring(2, 8)}`; +} + +/** Create a timestamped lifecycle event for a subscription. */ +export function createLifecycleEvent( + subscriptionId: string, + type: SubscriptionLifecycleEventType, + details?: string, + occurredAt: Date = new Date() +): SubscriptionLifecycleEvent { + return { + id: generateEventId(), + subscriptionId, + type, + occurredAt, + details, + }; +} + +/** Human-readable label for an event type. */ +export function getLifecycleEventLabel(type: SubscriptionLifecycleEventType): string { + return EVENT_LABELS[type] ?? type; +} + +/** Events ordered newest first. */ +export function sortLifecycleHistory( + events: SubscriptionLifecycleEvent[] +): SubscriptionLifecycleEvent[] { + return [...events].sort( + (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime() + ); +} + +function csvEscape(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** Export lifecycle events as a CSV string (newest first). */ +export function buildLifecycleHistoryCSV(events: SubscriptionLifecycleEvent[]): string { + const header = 'timestamp,subscriptionId,event,details'; + const rows = sortLifecycleHistory(events).map((event) => + [ + event.occurredAt instanceof Date + ? event.occurredAt.toISOString() + : new Date(event.occurredAt).toISOString(), + event.subscriptionId, + event.type, + event.details ?? '', + ] + .map((value) => csvEscape(String(value))) + .join(',') + ); + return [header, ...rows].join('\n'); +} + +/** Export lifecycle events as a pretty-printed JSON string (newest first). */ +export function buildLifecycleHistoryJSON(events: SubscriptionLifecycleEvent[]): string { + return JSON.stringify( + { + exportedAt: new Date().toISOString(), + eventCount: events.length, + events: sortLifecycleHistory(events).map((event) => ({ + timestamp: (event.occurredAt instanceof Date + ? event.occurredAt + : new Date(event.occurredAt) + ).toISOString(), + subscriptionId: event.subscriptionId, + event: event.type, + details: event.details ?? null, + })), + }, + null, + 2 + ); +} From f1f65c9a4199803b1a3ce84804ebab6dde1ad74a Mon Sep 17 00:00:00 2001 From: Okey Amy Date: Mon, 19 Oct 2026 18:14:53 +0100 Subject: [PATCH 4/4] feat(billing): per-region tax and VAT on invoices (#1732) Region rates are configurable in TaxService (configureRegionRate) with overrides taking precedence over built-in rates and falling back across state/city keys. generateInvoice accepts a region, applies the resolved rate at invoice time, records tax_region/tax_type (migration 016) and labels tax separately on the PDF and in invoice emails. Closes #1732 --- .../__tests__/invoiceController.test.ts | 105 +++++++++++++++ .../__tests__/invoiceGeneration.test.ts | 53 ++++++++ .../billing/controller/invoiceController.ts | 30 ++++- backend/billing/domain/InvoiceEmailService.ts | 5 +- backend/billing/domain/InvoiceGenerator.ts | 21 ++- .../billing/__tests__/taxService.test.ts | 97 ++++++++++++++ backend/services/billing/taxService.ts | 122 +++++++++++++++++- backend/services/billing/taxTypes.ts | 12 ++ db/migrations/016_invoice_tax_region.sql | 13 ++ 9 files changed, 445 insertions(+), 13 deletions(-) create mode 100644 backend/billing/__tests__/invoiceController.test.ts create mode 100644 db/migrations/016_invoice_tax_region.sql diff --git a/backend/billing/__tests__/invoiceController.test.ts b/backend/billing/__tests__/invoiceController.test.ts new file mode 100644 index 00000000..46e7cef7 --- /dev/null +++ b/backend/billing/__tests__/invoiceController.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for regional tax applied at invoice time (issue #1732). + */ + +import { createInvoiceController } from "../controller/invoiceController"; +import { TaxService } from "../../services/billing/taxService"; +import type { Pool } from "../../shared/db/connectionPool"; + +function makePool(): { pool: Pool; queries: Array<{ text: string; params: unknown[] }> } { + const queries: Array<{ text: string; params: unknown[] }> = []; + const pool = { + query: jest.fn((text: string, params: unknown[]) => { + queries.push({ text, params }); + return Promise.resolve({ rows: [] }); + }), + } as unknown as Pool; + return { pool, queries }; +} + +describe("invoice controller — regional tax", () => { + beforeEach(() => { + TaxService.clearRegionRates(); + }); + + afterAll(() => { + TaxService.clearRegionRates(); + }); + + it("applies the configured region rate when generating an invoice", async () => { + TaxService.configureRegionRate({ region: "DE", rateBps: 1900, taxType: "vat" }); + const { pool, queries } = makePool(); + const controller = createInvoiceController({ pool }); + + const result = await controller.generateInvoice({ + customerName: "Jane Doe", + customerEmail: "jane@example.com", + items: [{ description: "Pro Plan", quantity: 1, unitPrice: 100 }], + region: "DE", + currency: "EUR", + }); + + expect(result.success).toBe(true); + const invoice = result.data!.invoice; + expect(invoice.taxRegion).toBe("DE"); + expect(invoice.taxType).toBe("vat"); + expect(invoice.taxRate).toBe(0.19); + expect(invoice.taxAmount).toBe(19); + expect(invoice.total).toBe(119); + + // Region and type are persisted separately from the revenue columns + expect(queries[0].text).toContain("tax_region"); + expect(queries[0].text).toContain("tax_type"); + expect(queries[0].params).toContain("DE"); + expect(queries[0].params).toContain("vat"); + }); + + it("uses the built-in rate for a region without a configured override", async () => { + const { pool } = makePool(); + const controller = createInvoiceController({ pool }); + + const result = await controller.generateInvoice({ + customerName: "Jane Doe", + customerEmail: "jane@example.com", + items: [{ description: "Pro Plan", quantity: 1, unitPrice: 100 }], + region: "FR", + }); + + expect(result.data!.invoice.taxRate).toBe(0.2); + expect(result.data!.invoice.taxRegion).toBe("FR"); + expect(result.data!.invoice.taxType).toBe("vat"); + }); + + it("lets an explicit taxRate win over the region config", async () => { + TaxService.configureRegionRate({ region: "DE", rateBps: 1900 }); + const { pool } = makePool(); + const controller = createInvoiceController({ pool }); + + const result = await controller.generateInvoice({ + customerName: "Jane Doe", + customerEmail: "jane@example.com", + items: [{ description: "Pro Plan", quantity: 1, unitPrice: 100 }], + region: "DE", + taxRate: 0.05, + }); + + expect(result.data!.invoice.taxRate).toBe(0.05); + expect(result.data!.invoice.taxRegion).toBe("DE"); + }); + + it("records the region but charges no tax for unknown regions", async () => { + const { pool } = makePool(); + const controller = createInvoiceController({ pool }); + + const result = await controller.generateInvoice({ + customerName: "Jane Doe", + customerEmail: "jane@example.com", + items: [{ description: "Pro Plan", quantity: 1, unitPrice: 100 }], + region: "ZZ", + }); + + expect(result.data!.invoice.taxRate).toBe(0); + expect(result.data!.invoice.taxAmount).toBe(0); + expect(result.data!.invoice.taxRegion).toBe("ZZ"); + }); +}); diff --git a/backend/billing/__tests__/invoiceGeneration.test.ts b/backend/billing/__tests__/invoiceGeneration.test.ts index 5d6150ff..fb6b94a8 100644 --- a/backend/billing/__tests__/invoiceGeneration.test.ts +++ b/backend/billing/__tests__/invoiceGeneration.test.ts @@ -78,6 +78,23 @@ describe("InvoiceGenerator", () => { expect(diffDays).toBeGreaterThanOrEqual(29); expect(diffDays).toBeLessThanOrEqual(31); }); + + it("records the tax region and type on the invoice", () => { + const invoice = generator.createInvoice({ + senderName: "SubTrackr", + senderEmail: "billing@subtrackr.com", + customerName: "John Doe", + customerEmail: "john@example.com", + items, + taxRate: 0.19, + taxRegion: "DE", + taxType: "vat", + }); + + expect(invoice.taxRegion).toBe("DE"); + expect(invoice.taxType).toBe("vat"); + expect(invoice.taxAmount).toBe(13.3); // 69.99 * 0.19 = 13.2981 + }); }); describe("generatePdf", () => { @@ -98,6 +115,26 @@ describe("InvoiceGenerator", () => { const pdfHeader = generated.pdfBuffer.toString("latin1").slice(0, 8); expect(pdfHeader).toBe("%PDF-1.4"); }); + + it("reports tax separately with its region and type", () => { + const invoice = generator.createInvoice({ + senderName: "SubTrackr", + senderEmail: "billing@subtrackr.com", + customerName: "John Doe", + customerEmail: "john@example.com", + items, + taxRate: 0.19, + taxRegion: "DE", + taxType: "vat", + }); + const generated = generator.generatePdf(invoice); + const pdf = generated.pdfBuffer.toString("latin1"); + + expect(pdf).toContain("Subtotal:"); + // PDF literal-string syntax escapes parentheses in the displayed text. + expect(pdf).toContain("Tax \\(VAT 19.0% · DE\\)"); + expect(pdf).toContain("Total:"); + }); }); }); @@ -141,6 +178,22 @@ describe("InvoiceEmailService", () => { expect(text).toContain("Pro Plan"); expect(text).toContain("Total:"); }); + + it("labels tax with its region and type", () => { + const taxedInvoice = generator.createInvoice({ + senderName: "SubTrackr", + senderEmail: "billing@subtrackr.com", + customerName: "John Doe", + customerEmail: "john@example.com", + items: [{ description: "Pro Plan", quantity: 1, unitPrice: 49.99, total: 49.99 }], + taxRate: 0.19, + taxRegion: "DE", + taxType: "vat", + }); + + expect(emailService.buildHtmlBody(taxedInvoice)).toContain("Tax (VAT 19.0% · DE):"); + expect(emailService.buildTextBody(taxedInvoice)).toContain("Tax (VAT 19.0% · DE):"); + }); }); describe("sendInvoiceEmail", () => { diff --git a/backend/billing/controller/invoiceController.ts b/backend/billing/controller/invoiceController.ts index a5c5b59b..34308b66 100644 --- a/backend/billing/controller/invoiceController.ts +++ b/backend/billing/controller/invoiceController.ts @@ -8,6 +8,7 @@ import type { Pool } from "../../shared/db/connectionPool"; import { InvoiceGenerator, type InvoiceData, type GeneratedInvoice, type InvoiceBranding } from "../domain/InvoiceGenerator"; import { InvoiceEmailService, type EmailConfig, type EmailResult } from "../domain/InvoiceEmailService"; +import { TaxService } from "../../services/billing/taxService"; export interface InvoiceControllerDeps { pool: Pool; @@ -34,6 +35,8 @@ export function createInvoiceController(deps: InvoiceControllerDeps) { customerEmail: string; items: Array<{ description: string; quantity: number; unitPrice: number }>; taxRate?: number; + /** Region key such as "DE" or "US-CA"; resolves the configured rate at invoice time. */ + region?: string; discount?: number; currency?: string; subscriptionId?: string; @@ -58,13 +61,29 @@ export function createInvoiceController(deps: InvoiceControllerDeps) { total: Math.round(i.quantity * i.unitPrice * 100) / 100, })); + // Apply the configured per-region tax rate at invoice time. An explicit + // taxRate in the request still wins; the region is recorded either way. + let taxRate = body.taxRate; + let taxRegion: string | undefined; + let taxType: string | undefined; + if (body.region) { + const regionEntry = TaxService.resolveRegionTaxRate(body.region); + taxRegion = regionEntry?.jurisdictionKey ?? body.region.trim().toUpperCase(); + taxType = regionEntry?.taxType; + if (taxRate === undefined && regionEntry) { + taxRate = regionEntry.rateBps / 10_000; + } + } + const invoice = generator.createInvoice({ senderName: deps.emailConfig?.fromName ?? "SubTrackr", senderEmail: deps.emailConfig?.fromEmail ?? "billing@subtrackr.com", customerName: body.customerName, customerEmail: body.customerEmail, items, - taxRate: body.taxRate, + taxRate, + taxRegion, + taxType, discount: body.discount, currency: body.currency, subscriptionId: body.subscriptionId, @@ -80,12 +99,13 @@ export function createInvoiceController(deps: InvoiceControllerDeps) { await deps.pool.query( `INSERT INTO invoices (invoice_number, customer_name, customer_email, status, subtotal, - tax_rate, tax_amount, discount, total, currency, pdf_base64, locale, branding, + tax_rate, tax_amount, tax_region, tax_type, discount, total, currency, pdf_base64, locale, branding, subscription_id, notes, invoice_date, due_date, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)`, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)`, [ invoice.invoiceNumber, invoice.customerName, invoice.customerEmail, invoice.status, invoice.subtotal, invoice.taxRate, invoice.taxAmount, + invoice.taxRegion ?? null, invoice.taxType ?? null, invoice.discount, invoice.total, invoice.currency, generated.pdfBase64, invoice.locale, JSON.stringify(invoice.branding), invoice.subscriptionId ?? null, invoice.notes ?? null, invoice.invoiceDate, invoice.dueDate, new Date().toISOString(), @@ -132,6 +152,8 @@ export function createInvoiceController(deps: InvoiceControllerDeps) { subtotal: parseFloat(row.subtotal), taxRate: parseFloat(row.tax_rate), taxAmount: parseFloat(row.tax_amount), + taxRegion: row.tax_region ?? undefined, + taxType: row.tax_type ?? undefined, discount: parseFloat(row.discount), total: parseFloat(row.total), currency: row.currency, @@ -173,7 +195,7 @@ export function createInvoiceController(deps: InvoiceControllerDeps) { try { const result = await deps.pool.query( `SELECT invoice_number, customer_name, customer_email, status, subtotal, - tax_amount, discount, total, currency, invoice_date, due_date + tax_amount, tax_region, tax_type, discount, total, currency, invoice_date, due_date FROM invoices WHERE invoice_number = $1`, [invoiceNumber], ); diff --git a/backend/billing/domain/InvoiceEmailService.ts b/backend/billing/domain/InvoiceEmailService.ts index c07da79c..b0f792e7 100644 --- a/backend/billing/domain/InvoiceEmailService.ts +++ b/backend/billing/domain/InvoiceEmailService.ts @@ -6,6 +6,7 @@ */ import type { InvoiceData, GeneratedInvoice } from "./InvoiceGenerator"; +import { formatTaxLabel } from "./InvoiceGenerator"; import { formatDate, formatMoney } from './formatting'; export interface EmailConfig { @@ -87,7 +88,7 @@ export class InvoiceEmailService { ${invoice.discount > 0 ? `` : ""} - ${invoice.taxAmount > 0 ? `` : ""} + ${invoice.taxAmount > 0 ? `` : ""}
Subtotal:${fmt(invoice.subtotal)}
Discount:-${fmt(invoice.discount)}
Tax (${(invoice.taxRate * 100).toFixed(1)}%):${fmt(invoice.taxAmount)}
Tax (${formatTaxLabel(invoice)}):${fmt(invoice.taxAmount)}
Total:${fmt(invoice.total)}
${invoice.notes ? `

${this.escapeHtml(invoice.notes)}

` : ""} @@ -115,7 +116,7 @@ export class InvoiceEmailService { `Subtotal: ${formatMoney(invoice.subtotal, invoice.currency, invoice.locale)}`, ]; if (invoice.discount > 0) lines.push(`Discount: -${formatMoney(invoice.discount, invoice.currency, invoice.locale)}`); - if (invoice.taxAmount > 0) lines.push(`Tax: ${formatMoney(invoice.taxAmount, invoice.currency, invoice.locale)}`); + if (invoice.taxAmount > 0) lines.push(`Tax (${formatTaxLabel(invoice)}): ${formatMoney(invoice.taxAmount, invoice.currency, invoice.locale)}`); lines.push(`Total: ${formatMoney(invoice.total, invoice.currency, invoice.locale)}`, "", "PDF invoice attached.", `Payment due by ${formatDate(invoice.dueDate, invoice.locale)}.`); return lines.join("\n"); } diff --git a/backend/billing/domain/InvoiceGenerator.ts b/backend/billing/domain/InvoiceGenerator.ts index dc58a64d..4b65046b 100644 --- a/backend/billing/domain/InvoiceGenerator.ts +++ b/backend/billing/domain/InvoiceGenerator.ts @@ -45,6 +45,10 @@ export interface InvoiceData { subtotal: number; taxRate: number; taxAmount: number; + /** Region the tax was calculated for, e.g. "DE" or "US-CA". */ + taxRegion?: string; + /** Tax type applied, e.g. "vat", "gst", "sales_tax". */ + taxType?: string; discount: number; total: number; currency: string; @@ -70,6 +74,17 @@ function roundMoney(amount: number): number { return Math.round((amount + Number.EPSILON * Math.sign(amount)) * 100) / 100; } +/** + * Human-readable tax label for invoice documents, e.g. "VAT 19.0% · DE". + */ +export function formatTaxLabel(invoice: Pick): string { + const rate = `${(invoice.taxRate * 100).toFixed(1)}%`; + if (!invoice.taxType && !invoice.taxRegion) return rate; + const type = invoice.taxType ? invoice.taxType.toUpperCase() : 'Tax'; + const region = invoice.taxRegion ? ` · ${invoice.taxRegion.toUpperCase()}` : ''; + return `${type} ${rate}${region}`; +} + /** Constrains `value` to `[min, max]`, tolerating non-finite input. */ function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; @@ -119,6 +134,8 @@ export class InvoiceGenerator { customerEmail: string; items: InvoiceLineItem[]; taxRate?: number; + taxRegion?: string; + taxType?: string; discount?: number; currency?: string; subscriptionId?: string; @@ -154,6 +171,8 @@ export class InvoiceGenerator { subtotal: Math.round(subtotal * 100) / 100, taxRate, taxAmount, + taxRegion: params.taxRegion, + taxType: params.taxType, discount, total, currency, @@ -243,7 +262,7 @@ export class InvoiceGenerator { } if (invoice.taxAmount > 0) { content.push(`0 -16 Td`); - content.push(`(${this.escapePdfString(`Tax (${(invoice.taxRate * 100).toFixed(1)}%): ${fmt(invoice.taxAmount)}`)}) Tj`); + content.push(`(${this.escapePdfString(`Tax (${formatTaxLabel(invoice)}): ${fmt(invoice.taxAmount)}`)}) Tj`); } content.push(`0 -20 Td`); content.push(`/F1 14 Tf`); diff --git a/backend/services/billing/__tests__/taxService.test.ts b/backend/services/billing/__tests__/taxService.test.ts index 15f8e634..a56fc155 100644 --- a/backend/services/billing/__tests__/taxService.test.ts +++ b/backend/services/billing/__tests__/taxService.test.ts @@ -527,3 +527,100 @@ describe('TaxService', () => { expect(entry).not.toBeNull(); }); }); + +// ── Configurable per-region rates (issue #1732) ─────────────────────────────── + +describe('TaxService — configurable region rates', () => { + beforeEach(() => { + TaxService.clearRegionRates(); + }); + + afterAll(() => { + TaxService.clearRegionRates(); + }); + + it('applies a configured override instead of the built-in rate', () => { + const builtIn = TaxService.getTaxRate(makeJurisdiction({ country: 'DE' })); + expect(builtIn!.rateBps).toBe(1900); + + TaxService.configureRegionRate({ region: 'DE', rateBps: 1600, taxType: 'vat' }); + + // The cached built-in lookup must not mask the new override + const entry = TaxService.getTaxRate(makeJurisdiction({ country: 'DE' })); + expect(entry!.rateBps).toBe(1600); + expect(entry!.displayName).toBe('German VAT Standard Rate'); + }); + + it('registers a brand-new region with a default tax type', () => { + const entry = TaxService.configureRegionRate({ region: 'ES', rateBps: 2100 }); + + expect(entry.jurisdictionKey).toBe('ES'); + expect(entry.taxType).toBe('vat'); + + expect(TaxService.resolveRegionTaxRate('es')!.rateBps).toBe(2100); + expect(TaxService.getTaxRateByKey('ES')!.rateBps).toBe(2100); + expect(TaxService.getSupportedJurisdictions()).toContain('ES'); + }); + + it('resolves region keys with state and city fallbacks', () => { + TaxService.configureRegionRate({ region: 'US-CA', rateBps: 725, taxType: 'sales_tax' }); + + const entry = TaxService.resolveRegionTaxRate('US-CA-SF'); + + expect(entry).not.toBeNull(); + expect(entry!.jurisdictionKey).toBe('US-CA'); + expect(entry!.rateBps).toBe(725); + }); + + it('lists and clears configured regions', () => { + TaxService.configureRegionRate({ region: 'DE', rateBps: 1600 }); + TaxService.configureRegionRate({ region: 'ES', rateBps: 2100 }); + + expect( + TaxService.listRegionRates() + .map((e) => e.jurisdictionKey) + .sort() + ).toEqual(['DE', 'ES']); + expect(TaxService.getRegionRate('de')!.rateBps).toBe(1600); + + expect(TaxService.clearRegionRate('DE')).toBe(true); + expect(TaxService.clearRegionRate('DE')).toBe(false); + + // Falls back to the built-in rate once the override is cleared + expect(TaxService.getTaxRate(makeJurisdiction({ country: 'DE' }))!.rateBps).toBe(1900); + }); + + it('feeds overrides into tax calculation', () => { + TaxService.configureRegionRate({ region: 'DE', rateBps: 1000, taxType: 'vat' }); + + const result = TaxService.calculateTax({ + subscriptionId: 'sub-1', + planId: 'plan-1', + merchantId: 'merchant-1', + subscriberId: 'cust-1', + jurisdiction: makeJurisdiction({ country: 'DE' }), + subtotal: 10000, + currency: 'EUR', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + digitalGoodsClass: 'standard', + }); + + expect(result.taxRateBps).toBe(1000); + expect(result.taxAmount).toBe(1000); + expect(result.taxType).toBe('vat'); + }); + + it('rejects invalid region configurations', () => { + expect(() => TaxService.configureRegionRate({ region: '', rateBps: 1000 })).toThrow(); + expect(() => TaxService.configureRegionRate({ region: 'DE', rateBps: -1 })).toThrow(); + expect(() => TaxService.configureRegionRate({ region: 'DE', rateBps: 10001 })).toThrow(); + expect(() => TaxService.configureRegionRate({ region: 'DE', rateBps: NaN })).toThrow(); + }); + + it('returns null for unknown or empty regions', () => { + expect(TaxService.resolveRegionTaxRate('')).toBeNull(); + expect(TaxService.resolveRegionTaxRate('ZZ')).toBeNull(); + expect(TaxService.getRegionRate('ZZ')).toBeNull(); + }); +}); diff --git a/backend/services/billing/taxService.ts b/backend/services/billing/taxService.ts index dc6ddf7a..58639a38 100644 --- a/backend/services/billing/taxService.ts +++ b/backend/services/billing/taxService.ts @@ -4,6 +4,7 @@ import type { DigitalGoodsTaxRule, MidCycleTaxChange, NexusReport, + RegionTaxRateConfig, TaxCalculationResult, TaxInvoiceContext, TaxJurisdiction, @@ -264,6 +265,15 @@ const BUILT_IN_TAX_RATES: TaxRateEntry[] = [ const taxRateCache = new Map(); const taxStatusCache = new Map(); +/** + * Configurable per-region rate overrides. Checked before the built-in rate + * table so operators can adjust rates without a code change. + */ +const regionRateOverrides = new Map(); + +/** 10,000 bps = 100%. */ +const MAX_TAX_RATE_BPS = 10_000; + function deduplicate(arr: T[]): T[] { return [...new Set(arr)]; } @@ -325,6 +335,18 @@ export class TaxService { const keys = jurisdictionFallbackKeys(jurisdiction); for (const key of keys) { + const override = regionRateOverrides.get(key); + if (override) { + taxRateCache.set(cacheKey, { + jurisdictionKey: key, + entry: override, + cachedAt: Date.now(), + ttlMs: DEFAULT_TAX_CACHE_TTL_MS, + }); + cleanRateCache(); + return override; + } + const entry = BUILT_IN_TAX_RATES.find((r) => r.jurisdictionKey === key); if (entry) { if (digitalGoodsClass && !entry.appliesToDigitalGoods) { @@ -367,6 +389,81 @@ export class TaxService { return entry?.rateBps ?? 0; } + /** + * Configure (or replace) the tax rate for a region. Overrides take + * precedence over the built-in rate table and are applied at invoice time. + */ + static configureRegionRate(config: RegionTaxRateConfig): TaxRateEntry { + const region = (config.region ?? '').trim().toUpperCase(); + if (!region) { + throw new Error('region is required'); + } + if ( + !Number.isFinite(config.rateBps) || + config.rateBps < 0 || + config.rateBps > MAX_TAX_RATE_BPS + ) { + throw new Error(`rateBps must be between 0 and ${MAX_TAX_RATE_BPS}`); + } + + const existing = + regionRateOverrides.get(region) ?? + BUILT_IN_TAX_RATES.find((r) => r.jurisdictionKey === region); + + const entry: TaxRateEntry = { + jurisdictionKey: region, + taxType: config.taxType ?? existing?.taxType ?? 'vat', + rateBps: Math.round(config.rateBps), + displayName: config.displayName ?? existing?.displayName ?? `${region} Tax Rate`, + effectiveFrom: existing?.effectiveFrom ?? 0, + effectiveUntil: existing?.effectiveUntil ?? 0, + appliesToDigitalGoods: existing?.appliesToDigitalGoods ?? true, + reverseCharge: existing?.reverseCharge ?? false, + nexusThreshold: existing?.nexusThreshold ?? 0, + }; + + regionRateOverrides.set(region, entry); + taxRateCache.clear(); + return entry; + } + + /** Get the configured override for a region, if one exists. */ + static getRegionRate(region: string): TaxRateEntry | null { + return regionRateOverrides.get((region ?? '').trim().toUpperCase()) ?? null; + } + + /** List all configured region rate overrides. */ + static listRegionRates(): TaxRateEntry[] { + return Array.from(regionRateOverrides.values()); + } + + /** Remove a configured region override. Returns true if one existed. */ + static clearRegionRate(region: string): boolean { + const removed = regionRateOverrides.delete((region ?? '').trim().toUpperCase()); + if (removed) { + taxRateCache.clear(); + } + return removed; + } + + /** Remove all configured region overrides. */ + static clearRegionRates(): void { + regionRateOverrides.clear(); + taxRateCache.clear(); + } + + /** + * Resolve the tax rate for a region key such as "DE", "US-CA", or "US-CA-SF". + * Configured overrides win over built-in rates. + */ + static resolveRegionTaxRate(region: string): TaxRateEntry | null { + const normalized = (region ?? '').trim().toUpperCase(); + if (!normalized) return null; + + const [country = '', state = '', city = ''] = normalized.split('-'); + return TaxService.getTaxRate({ country, state, city }); + } + /** * Determine whether a tax-exempt customer is truly exempt for a given jurisdiction. */ @@ -615,17 +712,30 @@ export class TaxService { } /** - * Get all registered jurisdiction tax rates. + * Get all registered jurisdiction tax rates, with configured overrides + * replacing any built-in entry for the same region. */ static getRegisteredJurisdictions(): TaxRateEntry[] { - return BUILT_IN_TAX_RATES; + const merged = new Map(); + for (const entry of BUILT_IN_TAX_RATES) { + merged.set(entry.jurisdictionKey, entry); + } + for (const [key, entry] of regionRateOverrides) { + merged.set(key, entry); + } + return Array.from(merged.values()); } /** - * Get tax rate for a specific jurisdiction key. + * Get tax rate for a specific jurisdiction key (overrides included). */ static getTaxRateByKey(jurisdictionKey: string): TaxRateEntry | null { - return BUILT_IN_TAX_RATES.find((r) => r.jurisdictionKey === jurisdictionKey) ?? null; + const key = (jurisdictionKey ?? '').trim().toUpperCase(); + return ( + regionRateOverrides.get(key) ?? + BUILT_IN_TAX_RATES.find((r) => r.jurisdictionKey === key) ?? + null + ); } /** @@ -715,10 +825,10 @@ export class TaxService { } /** - * List all supported jurisdictions. + * List all supported jurisdictions (built-in plus configured regions). */ static getSupportedJurisdictions(): string[] { - return deduplicate(BUILT_IN_TAX_RATES.map((r) => r.jurisdictionKey)); + return deduplicate(TaxService.getRegisteredJurisdictions().map((r) => r.jurisdictionKey)); } /** diff --git a/backend/services/billing/taxTypes.ts b/backend/services/billing/taxTypes.ts index 5b78e289..c7aefb91 100644 --- a/backend/services/billing/taxTypes.ts +++ b/backend/services/billing/taxTypes.ts @@ -33,6 +33,18 @@ export interface TaxRateEntry { nexusThreshold: number; } +/** Configurable per-region tax rate override, applied at invoice time. */ +export interface RegionTaxRateConfig { + /** Jurisdiction key, e.g. "DE", "US-NY", or "GLOBAL". */ + region: string; + /** Rate in basis points (e.g. 1900 = 19%). */ + rateBps: number; + /** Optional tax type; defaults to the region's existing type or "vat". */ + taxType?: TaxType; + /** Optional display name; defaults to the region's existing name or "${region} Tax Rate". */ + displayName?: string; +} + export interface TaxRateChangeEvent { jurisdictionKey: string; oldRateBps: number; diff --git a/db/migrations/016_invoice_tax_region.sql b/db/migrations/016_invoice_tax_region.sql new file mode 100644 index 00000000..33d6ac86 --- /dev/null +++ b/db/migrations/016_invoice_tax_region.sql @@ -0,0 +1,13 @@ +-- Regional tax reporting on invoices (issue #1732). +-- Rates are configured per region in TaxService; invoices record which +-- region/type applied so tax can be reported separately from revenue. +BEGIN; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS tax_region TEXT; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS tax_type TEXT; +COMMIT; + +-- @down +BEGIN; +ALTER TABLE invoices DROP COLUMN IF EXISTS tax_type; +ALTER TABLE invoices DROP COLUMN IF EXISTS tax_region; +COMMIT;