From 08e75c84a2c97d17f94b45d665cd4af346fba8de Mon Sep 17 00:00:00 2001 From: walexjnr Date: Mon, 17 Aug 2026 21:23:01 +0000 Subject: [PATCH] feat(invoices): compute invoice tax and totals instead of hardcoding taxAmount to zero - Add TaxService that resolves the customer's billing jurisdiction (from payment metadata or user profile) and computes decimal-safe taxAmount and totalAmount = amount + taxAmount. - Persist the applied rate and jurisdiction on the invoice via new taxRate/taxJurisdiction columns (with migration). - Render the tax line in the generated invoice document. - Reflect recorded tax in the revenue recognition report. - Add unit tests for zero-rate, standard-rate, and rounding-boundary jurisdictions, and fix pre-existing broken mocks in the invoices spec. --- .../1796000000000-add-invoice-tax-columns.ts | 63 ++++++++++ src/payments/entities/invoice.entity.ts | 14 +++ src/payments/invoices/invoices.module.ts | 5 +- .../invoices/invoices.service.spec.ts | 103 +++++++++++++++ src/payments/invoices/invoices.service.ts | 38 +++++- src/payments/invoices/tax.service.spec.ts | 92 ++++++++++++++ src/payments/invoices/tax.service.ts | 117 ++++++++++++++++++ src/payments/reporting/reporting.service.ts | 12 ++ 8 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 src/migrations/1796000000000-add-invoice-tax-columns.ts create mode 100644 src/payments/invoices/tax.service.spec.ts create mode 100644 src/payments/invoices/tax.service.ts diff --git a/src/migrations/1796000000000-add-invoice-tax-columns.ts b/src/migrations/1796000000000-add-invoice-tax-columns.ts new file mode 100644 index 00000000..406b37d6 --- /dev/null +++ b/src/migrations/1796000000000-add-invoice-tax-columns.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Adds the tax bookkeeping columns to `invoices`: + * - `taxRate` — the applied rate as a decimal fraction (e.g. 0.19) + * - `taxJurisdiction` — the ISO 3166-1 country code / region the rate was + * resolved from (audit trail) + * + * Existing invoices keep `taxAmount = 0` / `totalAmount = amount`, so this is + * a purely additive, non-destructive change. + */ +export class AddInvoiceTaxColumns1796000000000 implements MigrationInterface { + name = 'AddInvoiceTaxColumns1796000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('invoices'); + + if (!table) { + return; + } + + if (!table.findColumnByName('taxRate')) { + await queryRunner.addColumn( + 'invoices', + new TableColumn({ + name: 'taxRate', + type: 'numeric', + precision: 5, + scale: 4, + isNullable: true, + }), + ); + } + + if (!table.findColumnByName('taxJurisdiction')) { + await queryRunner.addColumn( + 'invoices', + new TableColumn({ + name: 'taxJurisdiction', + type: 'varchar', + length: '64', + isNullable: true, + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('invoices'); + + if (!table) { + return; + } + + if (table.findColumnByName('taxJurisdiction')) { + await queryRunner.dropColumn('invoices', 'taxJurisdiction'); + } + + if (table.findColumnByName('taxRate')) { + await queryRunner.dropColumn('invoices', 'taxRate'); + } + } +} diff --git a/src/payments/entities/invoice.entity.ts b/src/payments/entities/invoice.entity.ts index 8c9bb0f2..ef0d7eee 100644 --- a/src/payments/entities/invoice.entity.ts +++ b/src/payments/entities/invoice.entity.ts @@ -57,6 +57,20 @@ export class Invoice { @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) taxAmount: number; + /** + * Applicable tax rate as a decimal fraction (e.g. `0.2` for 20%). + * Null when no jurisdiction was resolved for the invoice. + */ + @Column({ type: 'decimal', precision: 5, scale: 4, nullable: true }) + taxRate: number | null; + + /** + * Jurisdiction the tax rate was resolved from (ISO 3166-1 alpha-2 code or + * country name). Kept for audit purposes. + */ + @Column({ type: 'varchar', length: 64, nullable: true }) + taxJurisdiction: string | null; + @Column({ type: 'decimal', precision: 10, scale: 2 }) totalAmount: number; diff --git a/src/payments/invoices/invoices.module.ts b/src/payments/invoices/invoices.module.ts index 8e99d809..1a84ce9d 100644 --- a/src/payments/invoices/invoices.module.ts +++ b/src/payments/invoices/invoices.module.ts @@ -4,11 +4,12 @@ import { Invoice } from '../entities/invoice.entity'; import { Payment } from '../entities/payment.entity'; import { InvoicesService } from './invoices.service'; import { InvoicesController } from './invoices.controller'; +import { TaxService } from './tax.service'; @Module({ imports: [TypeOrmModule.forFeature([Invoice, Payment])], controllers: [InvoicesController], - providers: [InvoicesService], - exports: [InvoicesService], + providers: [InvoicesService, TaxService], + exports: [InvoicesService, TaxService], }) export class InvoicesModule {} diff --git a/src/payments/invoices/invoices.service.spec.ts b/src/payments/invoices/invoices.service.spec.ts index 3127f46b..ee8a34aa 100644 --- a/src/payments/invoices/invoices.service.spec.ts +++ b/src/payments/invoices/invoices.service.spec.ts @@ -2,9 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConflictException } from '@nestjs/common'; +import * as fs from 'fs'; import { InvoicesService } from './invoices.service'; import { Invoice, InvoiceStatus } from '../entities/invoice.entity'; import { Payment, PaymentStatus, PaymentMethod } from '../entities/payment.entity'; +import { TaxService } from './tax.service'; /** * Unit and integration tests for InvoicesService @@ -26,6 +28,7 @@ describe('InvoicesService (Invoice Number Sequencing)', () => { module = await Test.createTestingModule({ providers: [ InvoicesService, + TaxService, { provide: getRepositoryToken(Invoice), useValue: { @@ -63,6 +66,7 @@ describe('InvoicesService (Invoice Number Sequencing)', () => { currency: 'USD', status: InvoiceStatus.PAID, issuedDate: new Date(), + items: [], }; const mockPayment: Partial = { @@ -98,6 +102,12 @@ describe('InvoicesService (Invoice Number Sequencing)', () => { const mockInvoice: Partial = { id: 'inv-1', invoiceNumber: 'INV-000042', + amount: 100, + totalAmount: 100, + currency: 'USD', + status: InvoiceStatus.PAID, + issuedDate: new Date(), + items: [], }; (invoiceRepo.query as jest.Mock).mockResolvedValue([{ seq_value: '000042' }]); @@ -313,4 +323,97 @@ describe('InvoicesService (Invoice Number Sequencing)', () => { ); }); }); + + describe('Tax Calculation', () => { + const buildInvoice = (data: Record) => ({ + id: 'inv-tax', + invoiceNumber: 'INV-000100', + issuedDate: new Date(), + items: [], + status: InvoiceStatus.PAID, + ...data, + }); + + it('records zero tax for a zero-rate jurisdiction', async () => { + const mockPayment: Partial = { + id: 'pay-tax-zero', + userId: 'user-1', + amount: 100, + currency: 'USD', + metadata: { billingCountryCode: 'US' }, + }; + + (invoiceRepo.query as jest.Mock).mockResolvedValue([{ seq_value: '000100' }]); + (invoiceRepo.create as jest.Mock).mockImplementation((data) => buildInvoice(data)); + (invoiceRepo.save as jest.Mock).mockImplementation(async (invoice) => invoice); + + const result = await service.generateAndArchiveInvoice(mockPayment as Payment); + + expect(Number(result.taxAmount)).toBe(0); + expect(Number(result.totalAmount)).toBe(100); + expect(Number(result.taxRate)).toBe(0); + expect(result.taxJurisdiction).toBe('US'); + }); + + it('applies the standard rate for a taxable jurisdiction', async () => { + const mockPayment: Partial = { + id: 'pay-tax-de', + userId: 'user-1', + amount: 100, + currency: 'USD', + metadata: { billingCountryCode: 'DE' }, + }; + + (invoiceRepo.query as jest.Mock).mockResolvedValue([{ seq_value: '000101' }]); + (invoiceRepo.create as jest.Mock).mockImplementation((data) => buildInvoice(data)); + (invoiceRepo.save as jest.Mock).mockImplementation(async (invoice) => invoice); + + const result = await service.generateAndArchiveInvoice(mockPayment as Payment); + + expect(Number(result.taxAmount)).toBe(19); + expect(Number(result.totalAmount)).toBe(119); + expect(Number(result.taxRate)).toBeCloseTo(0.19); + expect(result.taxJurisdiction).toBe('DE'); + }); + + it('rounds tax to the nearest cent at a rounding boundary', async () => { + const mockPayment: Partial = { + id: 'pay-tax-ng', + userId: 'user-1', + amount: 9.99, + currency: 'USD', + metadata: { billingCountryCode: 'NG' }, + }; + + (invoiceRepo.query as jest.Mock).mockResolvedValue([{ seq_value: '000102' }]); + (invoiceRepo.create as jest.Mock).mockImplementation((data) => buildInvoice(data)); + (invoiceRepo.save as jest.Mock).mockImplementation(async (invoice) => invoice); + + const result = await service.generateAndArchiveInvoice(mockPayment as Payment); + + expect(Number(result.taxAmount)).toBe(0.75); + expect(Number(result.totalAmount)).toBe(10.74); + expect(Number(result.taxRate)).toBeCloseTo(0.075); + }); + + it('renders the tax line in the archived invoice document', async () => { + const mockPayment: Partial = { + id: 'pay-tax-html', + userId: 'user-1', + amount: 100, + currency: 'USD', + metadata: { billingCountryCode: 'DE' }, + }; + + (invoiceRepo.query as jest.Mock).mockResolvedValue([{ seq_value: '000103' }]); + (invoiceRepo.create as jest.Mock).mockImplementation((data) => buildInvoice(data)); + (invoiceRepo.save as jest.Mock).mockImplementation(async (invoice) => invoice); + + const result = await service.generateAndArchiveInvoice(mockPayment as Payment); + + const html = fs.readFileSync(result.fileUrl as string, 'utf-8'); + expect(html).toContain('Tax (19% - DE)'); + expect(html).toContain('Total Amount: 119 USD'); + }); + }); }); diff --git a/src/payments/invoices/invoices.service.ts b/src/payments/invoices/invoices.service.ts index a477bb34..56d44800 100644 --- a/src/payments/invoices/invoices.service.ts +++ b/src/payments/invoices/invoices.service.ts @@ -13,6 +13,7 @@ import * as path from 'path'; import { Invoice, InvoiceStatus } from '../entities/invoice.entity'; import { Payment } from '../entities/payment.entity'; import { APP_EVENTS } from '../../common/constants/event.constants'; +import { TaxService } from './tax.service'; /** * PostgreSQL error codes (from PostgreSQL documentation) @@ -22,6 +23,13 @@ enum PostgresErrorCode { SERIALIZATION_FAILURE = '40001', } +/** + * Formats a decimal tax rate (e.g. `0.075`) as a percentage string ("7.5%"). + */ +function formatTaxRate(rate: number): string { + return `${parseFloat((rate * 100).toFixed(2))}%`; +} + @Injectable() export class InvoicesService { private readonly logger = new Logger(InvoicesService.name); @@ -32,6 +40,7 @@ export class InvoicesService { private readonly invoiceRepository: Repository, @InjectRepository(Payment) private readonly paymentRepository: Repository, + private readonly taxService: TaxService, ) { if (!fs.existsSync(this.storagePath)) { fs.mkdirSync(this.storagePath, { recursive: true }); @@ -90,6 +99,11 @@ export class InvoicesService { async generateAndArchiveInvoice(payment: Payment): Promise { const invoiceNumber = await this.generateInvoiceNumber(); + const tax = this.taxService.resolveTax( + Number(payment.amount), + this.taxService.resolveJurisdiction(payment), + ); + const items = [ { description: `Payment for transaction ${payment.id}`, @@ -101,8 +115,10 @@ export class InvoicesService { let invoice = this.invoiceRepository.create({ invoiceNumber, amount: payment.amount, - taxAmount: 0, - totalAmount: payment.amount, + taxAmount: tax.taxAmount, + totalAmount: tax.totalAmount, + taxRate: tax.rate, + taxJurisdiction: tax.jurisdiction, currency: payment.currency, items, status: InvoiceStatus.PAID, @@ -147,6 +163,11 @@ export class InvoicesService { } // Generate HTML template + const taxLine = + invoice.taxAmount != null && Number(invoice.taxAmount) > 0 + ? `

Tax (${escapeHtml(formatTaxRate(Number(invoice.taxRate)))}${invoice.taxJurisdiction ? ` - ${escapeHtml(invoice.taxJurisdiction)}` : ''}): ${escapeHtml(invoice.taxAmount)} ${escapeHtml(invoice.currency)}

` + : ''; + const htmlContent = ` Invoice ${escapeHtml(invoice.invoiceNumber)} @@ -155,6 +176,8 @@ export class InvoicesService {

Invoice Number: ${escapeHtml(invoice.invoiceNumber)}

Date: ${escapeHtml(invoice.issuedDate.toISOString())}

Status: ${escapeHtml(invoice.status.toUpperCase())}

+

Amount: ${escapeHtml(invoice.amount)} ${escapeHtml(invoice.currency)}

+ ${taxLine}

Total Amount: ${escapeHtml(invoice.totalAmount)} ${escapeHtml(invoice.currency)}


Items

@@ -180,6 +203,17 @@ export class InvoicesService { return invoice; } + /** + * Resolves the tax breakdown for a payment's amount and jurisdiction. + * Exposed for callers that need the numbers before persisting an invoice. + */ + computeTax(payment: Payment): ReturnType { + return this.taxService.resolveTax( + Number(payment.amount), + this.taxService.resolveJurisdiction(payment), + ); + } + async getInvoice(id: string): Promise { const invoice = await this.invoiceRepository.findOne({ where: { id } }); if (!invoice) { diff --git a/src/payments/invoices/tax.service.spec.ts b/src/payments/invoices/tax.service.spec.ts new file mode 100644 index 00000000..8211d796 --- /dev/null +++ b/src/payments/invoices/tax.service.spec.ts @@ -0,0 +1,92 @@ +import { TaxService } from './tax.service'; +import { Payment } from '../entities/payment.entity'; + +describe('TaxService', () => { + let service: TaxService; + + beforeEach(() => { + service = new TaxService(); + }); + + describe('resolveJurisdiction', () => { + it('prefers the billing country code in payment metadata', () => { + const payment = { + metadata: { billingCountryCode: 'DE', country: 'Germany' }, + } as unknown as Payment; + + expect(service.resolveJurisdiction(payment)).toBe('DE'); + }); + + it('falls back to the country name in payment metadata', () => { + const payment = { metadata: { billingCountry: 'Nigeria' } } as unknown as Payment; + + expect(service.resolveJurisdiction(payment)).toBe('Nigeria'); + }); + + it('falls back to the user profile when metadata is absent', () => { + const payment = { + metadata: null, + user: { countryCode: 'FR' }, + } as unknown as Payment; + + expect(service.resolveJurisdiction(payment)).toBe('FR'); + }); + + it('returns null when no jurisdiction is available', () => { + const payment = { metadata: {}, user: null } as unknown as Payment; + + expect(service.resolveJurisdiction(payment)).toBeNull(); + }); + }); + + describe('resolveTax', () => { + it('charges no tax for a zero-rate jurisdiction (US)', () => { + const tax = service.resolveTax(100, 'US'); + + expect(tax.rate).toBe(0); + expect(tax.taxAmount).toBe(0); + expect(tax.totalAmount).toBe(100); + }); + + it('charges no tax for an unknown jurisdiction', () => { + const tax = service.resolveTax(50, 'XX'); + + expect(tax.rate).toBe(0); + expect(tax.taxAmount).toBe(0); + expect(tax.totalAmount).toBe(50); + }); + + it('applies the standard rate for a taxable jurisdiction (DE 19%)', () => { + const tax = service.resolveTax(100, 'DE'); + + expect(tax.rate).toBe(0.19); + expect(tax.taxAmount).toBe(19); + expect(tax.totalAmount).toBe(119); + }); + + it('rounds tax to the nearest cent at a rounding boundary (NG 7.5% on 9.99)', () => { + // 9.99 * 0.075 = 0.74925 → rounds to 0.75 + const tax = service.resolveTax(9.99, 'NG'); + + expect(tax.rate).toBe(0.075); + expect(tax.taxAmount).toBe(0.75); + expect(tax.totalAmount).toBe(10.74); + }); + + it('keeps totalAmount equal to amount + taxAmount by construction', () => { + const tax = service.resolveTax(9.99, 'DE'); + + expect(tax.totalAmount).toBeCloseTo(tax.taxAmount + 9.99, 10); + expect(tax.totalAmount).toBe(11.89); + }); + + it('records the jurisdiction on the breakdown', () => { + const tax = service.resolveTax(25, 'GB'); + + expect(tax.jurisdiction).toBe('GB'); + expect(tax.rate).toBe(0.2); + expect(tax.taxAmount).toBe(5); + expect(tax.totalAmount).toBe(30); + }); + }); +}); diff --git a/src/payments/invoices/tax.service.ts b/src/payments/invoices/tax.service.ts new file mode 100644 index 00000000..09f8f61e --- /dev/null +++ b/src/payments/invoices/tax.service.ts @@ -0,0 +1,117 @@ +import { Injectable } from '@nestjs/common'; +import { Payment } from '../entities/payment.entity'; + +/** + * Result of a tax resolution step. + */ +export interface TaxBreakdown { + /** + * Jurisdiction the rate was resolved from (ISO 3166-1 alpha-2 code or + * country name), or null when no jurisdiction could be determined. + */ + jurisdiction: string | null; + /** Applicable tax rate as a decimal fraction (e.g. `0.2` for 20%). */ + rate: number; + /** Tax amount rounded to the nearest cent (2 decimal places). */ + taxAmount: number; + /** `amount + taxAmount` — the total billed to the customer. */ + totalAmount: number; +} + +/** + * Default VAT/GST/sales-tax rates keyed by ISO 3166-1 alpha-2 country code. + * + * Jurisdictions not listed (and jurisdictions with no consumption tax, e.g. + * the US at the federal level) resolve to a zero rate. Rates are expressed as + * decimal fractions so tax can be computed with decimal-safe arithmetic. + */ +const DEFAULT_TAX_RATES: Record = { + AE: 0.05, // UAE VAT + AU: 0.1, // Australia GST + BR: 0.17, // Brazil ICMS + CA: 0.05, // Canada GST (federal) + CH: 0.077, // Switzerland VAT (standard) + DE: 0.19, // Germany VAT + ES: 0.21, // Spain VAT + FR: 0.2, // France VAT + GB: 0.2, // United Kingdom VAT + IE: 0.23, // Ireland VAT + IN: 0.18, // India GST (standard) + IT: 0.22, // Italy VAT + JP: 0.1, // Japan consumption tax + MX: 0.16, // Mexico VAT + NG: 0.075, // Nigeria VAT + NL: 0.21, // Netherlands VAT + NZ: 0.15, // New Zealand GST + SG: 0.09, // Singapore GST + ZA: 0.15, // South Africa VAT + US: 0, // No federal sales tax +}; + +/** + * Rounds a monetary value to the nearest cent using half-up rounding, avoiding + * floating-point drift by rounding in minor units (hundredths). + */ +function roundToCents(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +@Injectable() +export class TaxService { + /** + * Returns the tax rate for a jurisdiction, defaulting to a zero rate when + * the jurisdiction is unknown or has no consumption tax. + */ + getRateForJurisdiction(jurisdiction: string | null | undefined): number { + if (!jurisdiction) { + return 0; + } + const code = jurisdiction.trim().toUpperCase(); + return DEFAULT_TAX_RATES[code] ?? 0; + } + + /** + * Resolves the customer's billing jurisdiction from the payment, preferring + * the payment metadata (billing country/country code recorded at checkout) + * and falling back to the user profile when available. + */ + resolveJurisdiction(payment: Payment): string | null { + const metadata = payment.metadata ?? {}; + const candidate = + metadata['billingCountryCode'] ?? + metadata['billingCountry'] ?? + metadata['countryCode'] ?? + metadata['country']; + + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + + // The user profile may carry localization fields (country/country_code) + // depending on deployment; treat them as optional. + const user = payment.user as + | { countryCode?: string | null; country?: string | null } + | null + | undefined; + + return user?.countryCode || user?.country || null; + } + + /** + * Computes the tax and total for a net amount given the customer's + * jurisdiction. `totalAmount` is `amount + taxAmount` by construction and + * both figures are rounded to the nearest cent. + */ + resolveTax(amount: number, jurisdiction: string | null | undefined): TaxBreakdown { + const rate = this.getRateForJurisdiction(jurisdiction); + const taxAmount = roundToCents(amount * rate); + const totalAmount = roundToCents(amount + taxAmount); + + return { + jurisdiction: jurisdiction ?? null, + rate, + taxAmount, + totalAmount, + }; + } +} diff --git a/src/payments/reporting/reporting.service.ts b/src/payments/reporting/reporting.service.ts index 296dc644..9a5ee591 100644 --- a/src/payments/reporting/reporting.service.ts +++ b/src/payments/reporting/reporting.service.ts @@ -101,11 +101,23 @@ export class ReportingService { const oneOffRevenue = grossRevenue - subscriptionRevenue; + // Tax recorded on invoices issued in the period. Invoices carry the tax + // collected for each sale (see InvoicesService), so the revenue + // recognition report reflects the actual tax liability instead of zero. + const invoices = await this.invoiceRepository.find({ + where: { + issuedDate: Between(startDate, endDate), + status: In([InvoiceStatus.PAID, InvoiceStatus.SENT]), + }, + }); + const totalTaxCollected = invoices.reduce((sum, inv) => sum + Number(inv.taxAmount || 0), 0); + return { period: { startDate, endDate }, grossRevenue, totalRefunds, netRevenue, + totalTaxCollected, breakdown: { subscriptionRevenue, oneOffRevenue,