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 {
| Subtotal: | ${fmt(invoice.subtotal)} |
${invoice.discount > 0 ? `| Discount: | -${fmt(invoice.discount)} |
` : ""}
- ${invoice.taxAmount > 0 ? `| Tax (${(invoice.taxRate * 100).toFixed(1)}%): | ${fmt(invoice.taxAmount)} |
` : ""}
+ ${invoice.taxAmount > 0 ? `| 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/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 ───────────────────────────────────────────────────────────
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/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;
}
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;
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
+ );
+}