Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions backend/billing/__tests__/invoiceController.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
53 changes: 53 additions & 0 deletions backend/billing/__tests__/invoiceGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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:");
});
});
});

Expand Down Expand Up @@ -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", () => {
Expand Down
30 changes: 26 additions & 4 deletions backend/billing/controller/invoiceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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],
);
Expand Down
5 changes: 3 additions & 2 deletions backend/billing/domain/InvoiceEmailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type { InvoiceData, GeneratedInvoice } from "./InvoiceGenerator";
import { formatTaxLabel } from "./InvoiceGenerator";
import { formatDate, formatMoney } from './formatting';

export interface EmailConfig {
Expand Down Expand Up @@ -87,7 +88,7 @@ export class InvoiceEmailService {
<table style="width:100%;margin-bottom:16px;">
<tr><td style="text-align:right;padding:4px 0;">Subtotal:</td><td style="text-align:right;padding:4px 0;width:120px;">${fmt(invoice.subtotal)}</td></tr>
${invoice.discount > 0 ? `<tr><td style="text-align:right;padding:4px 0;">Discount:</td><td style="text-align:right;padding:4px 0;">-${fmt(invoice.discount)}</td></tr>` : ""}
${invoice.taxAmount > 0 ? `<tr><td style="text-align:right;padding:4px 0;">Tax (${(invoice.taxRate * 100).toFixed(1)}%):</td><td style="text-align:right;padding:4px 0;">${fmt(invoice.taxAmount)}</td></tr>` : ""}
${invoice.taxAmount > 0 ? `<tr><td style="text-align:right;padding:4px 0;">Tax (${formatTaxLabel(invoice)}):</td><td style="text-align:right;padding:4px 0;">${fmt(invoice.taxAmount)}</td></tr>` : ""}
<tr style="font-size:18px;font-weight:700;"><td style="text-align:right;padding:8px 0;border-top:2px solid #4F46E5;">Total:</td><td style="text-align:right;padding:8px 0;border-top:2px solid #4F46E5;">${fmt(invoice.total)}</td></tr>
</table>
${invoice.notes ? `<p style="color:#6B7280;font-size:13px;">${this.escapeHtml(invoice.notes)}</p>` : ""}
Expand Down Expand Up @@ -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");
}
Expand Down
21 changes: 20 additions & 1 deletion backend/billing/domain/InvoiceGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<InvoiceData, 'taxRate' | 'taxRegion' | 'taxType'>): 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;
Expand Down Expand Up @@ -119,6 +134,8 @@ export class InvoiceGenerator {
customerEmail: string;
items: InvoiceLineItem[];
taxRate?: number;
taxRegion?: string;
taxType?: string;
discount?: number;
currency?: string;
subscriptionId?: string;
Expand Down Expand Up @@ -154,6 +171,8 @@ export class InvoiceGenerator {
subtotal: Math.round(subtotal * 100) / 100,
taxRate,
taxAmount,
taxRegion: params.taxRegion,
taxType: params.taxType,
discount,
total,
currency,
Expand Down Expand Up @@ -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`);
Expand Down
Loading
Loading