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
63 changes: 63 additions & 0 deletions src/migrations/1796000000000-add-invoice-tax-columns.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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');
}
}
}
14 changes: 14 additions & 0 deletions src/payments/entities/invoice.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions src/payments/invoices/invoices.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
103 changes: 103 additions & 0 deletions src/payments/invoices/invoices.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +28,7 @@ describe('InvoicesService (Invoice Number Sequencing)', () => {
module = await Test.createTestingModule({
providers: [
InvoicesService,
TaxService,
{
provide: getRepositoryToken(Invoice),
useValue: {
Expand Down Expand Up @@ -63,6 +66,7 @@ describe('InvoicesService (Invoice Number Sequencing)', () => {
currency: 'USD',
status: InvoiceStatus.PAID,
issuedDate: new Date(),
items: [],
};

const mockPayment: Partial<Payment> = {
Expand Down Expand Up @@ -98,6 +102,12 @@ describe('InvoicesService (Invoice Number Sequencing)', () => {
const mockInvoice: Partial<Invoice> = {
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' }]);
Expand Down Expand Up @@ -313,4 +323,97 @@ describe('InvoicesService (Invoice Number Sequencing)', () => {
);
});
});

describe('Tax Calculation', () => {
const buildInvoice = (data: Record<string, unknown>) => ({
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<Payment> = {
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<Payment> = {
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<Payment> = {
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<Payment> = {
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('<strong>Total Amount:</strong> 119 USD');
});
});
});
38 changes: 36 additions & 2 deletions src/payments/invoices/invoices.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand All @@ -32,6 +40,7 @@ export class InvoicesService {
private readonly invoiceRepository: Repository<Invoice>,
@InjectRepository(Payment)
private readonly paymentRepository: Repository<Payment>,
private readonly taxService: TaxService,
) {
if (!fs.existsSync(this.storagePath)) {
fs.mkdirSync(this.storagePath, { recursive: true });
Expand Down Expand Up @@ -90,6 +99,11 @@ export class InvoicesService {
async generateAndArchiveInvoice(payment: Payment): Promise<Invoice> {
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}`,
Expand All @@ -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,
Expand Down Expand Up @@ -147,6 +163,11 @@ export class InvoicesService {
}

// Generate HTML template
const taxLine =
invoice.taxAmount != null && Number(invoice.taxAmount) > 0
? `<p><strong>Tax (${escapeHtml(formatTaxRate(Number(invoice.taxRate)))}${invoice.taxJurisdiction ? ` - ${escapeHtml(invoice.taxJurisdiction)}` : ''}):</strong> ${escapeHtml(invoice.taxAmount)} ${escapeHtml(invoice.currency)}</p>`
: '';

const htmlContent = `
<html>
<head><title>Invoice ${escapeHtml(invoice.invoiceNumber)}</title></head>
Expand All @@ -155,6 +176,8 @@ export class InvoicesService {
<p><strong>Invoice Number:</strong> ${escapeHtml(invoice.invoiceNumber)}</p>
<p><strong>Date:</strong> ${escapeHtml(invoice.issuedDate.toISOString())}</p>
<p><strong>Status:</strong> ${escapeHtml(invoice.status.toUpperCase())}</p>
<p><strong>Amount:</strong> ${escapeHtml(invoice.amount)} ${escapeHtml(invoice.currency)}</p>
${taxLine}
<p><strong>Total Amount:</strong> ${escapeHtml(invoice.totalAmount)} ${escapeHtml(invoice.currency)}</p>
<hr/>
<h3>Items</h3>
Expand All @@ -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<TaxService['resolveTax']> {
return this.taxService.resolveTax(
Number(payment.amount),
this.taxService.resolveJurisdiction(payment),
);
}

async getInvoice(id: string): Promise<Invoice> {
const invoice = await this.invoiceRepository.findOne({ where: { id } });
if (!invoice) {
Expand Down
Loading
Loading