import { getMetadataArgsStorage } from 'typeorm'; import { CoolCommException } from '@cool-midway/core'; import { BaseEntity } from '../../../src/comm/entity/base'; import { TeamInvoiceEntity } from '../../../src/modules/team/entity/invoice'; import { TeamInvoiceService } from '../../../src/modules/team/service/invoice'; import { AdminTeamBillingController } from '../../../src/modules/team/controller/admin/billing'; import { CreateInvoiceDTO, SupportedCurrency } from '../../../src/comm/types/omni'; describe('Point 24: 国际多币种发票生成与历史收据导出 单元测试', () => { const metadata = getMetadataArgsStorage(); // ========================================== // 1. 实体定义与数据库字段映射测试 // ========================================== describe('1. 实体定义与数据库字段映射测试', () => { it('TeamInvoiceEntity 表名映射应为 team_invoice 且继承 BaseEntity', () => { const table = metadata.tables.find(t => t.target === TeamInvoiceEntity); expect(table).toBeDefined(); expect(table?.name).toBe('team_invoice'); expect(TeamInvoiceEntity.prototype instanceof BaseEntity).toBe(true); }); it('TeamInvoiceEntity 应映射各核心字段至下划线数据列', () => { const columns = metadata.columns.filter(c => c.target === TeamInvoiceEntity); const expectedColumns: Record = { invoiceNo: 'invoice_no', orderId: 'order_id', currency: 'currency', currencySymbol: 'currency_symbol', subtotal: 'subtotal', taxRate: 'tax_rate', taxAmount: 'tax_amount', totalAmount: 'total_amount', title: 'title', items: 'items', billingInfo: 'billing_info', status: 'status', paidAt: 'paid_at', htmlTemplate: 'html_template', textTemplate: 'text_template', }; for (const [prop, colName] of Object.entries(expectedColumns)) { const col = columns.find(c => c.propertyName === prop); expect(col).toBeDefined(); expect(col?.options.name || col?.propertyName).toBe(colName); } }); }); // ========================================== // 2. 发票流水号算法规范测试 // ========================================== describe('2. 发票流水号生成算法测试', () => { let service: TeamInvoiceService; beforeEach(() => { service = new TeamInvoiceService(); }); it('流水号应严格遵循 INV-YYYYMMDD-XXXX 格式规范', () => { const invoiceNo = service.generateInvoiceNumber(new Date('2026-09-20'), 1); expect(invoiceNo).toMatch(/^INV-\d{8}-[A-Z0-9]{4}$/); expect(invoiceNo).toBe('INV-20260920-0001'); }); it('当日流水号自增序列溢出或大数时应正确填充与生成', () => { const invoiceNo42 = service.generateInvoiceNumber(new Date('2026-09-20'), 42); expect(invoiceNo42).toBe('INV-20260920-0042'); const invoiceNo9999 = service.generateInvoiceNumber(new Date('2026-09-20'), 9999); expect(invoiceNo9999).toBe('INV-20260920-9999'); }); }); // ========================================== // 3. 税费折算与金融级精确计算测试 // ========================================== describe('3. 税费与净额精确折算测试', () => { let service: TeamInvoiceService; beforeEach(() => { service = new TeamInvoiceService(); }); it('不含税模式下应根据小计和税率精确计算税额与含税总额', () => { const calc = service.calculateTaxAndTotals({ subtotal: 100, taxRate: 0.1, // 10% isTaxInclusive: false, }); expect(calc.subtotal).toBe(100.0); expect(calc.taxAmount).toBe(10.0); expect(calc.totalAmount).toBe(110.0); }); it('含税总额模式下应根据总额和税率精确逆向拆解净额与税额', () => { const calc = service.calculateTaxAndTotals({ totalAmount: 110, taxRate: 0.1, isTaxInclusive: true, }); expect(calc.subtotal).toBe(100.0); expect(calc.taxAmount).toBe(10.0); expect(calc.totalAmount).toBe(110.0); }); it('复杂小数与税率计算时必须四舍五入防浮点数精度误差', () => { const calc = service.calculateTaxAndTotals({ subtotal: 99.99, taxRate: 0.0825, // 8.25% isTaxInclusive: false, }); // 99.99 * 0.0825 = 8.249175 -> 四舍五入为 8.25 expect(calc.taxAmount).toBe(8.25); expect(calc.totalAmount).toBe(108.24); }); it('零税率或免税交易应正确处理', () => { const calc = service.calculateTaxAndTotals({ subtotal: 50, taxRate: 0, }); expect(calc.subtotal).toBe(50.0); expect(calc.taxAmount).toBe(0.0); expect(calc.totalAmount).toBe(50.0); }); }); // ========================================== // 4. 多币种支持与符号格式化测试 // ========================================== describe('4. 国际主流币种(USD, EUR, GBP, CNY)与符号匹配测试', () => { let service: TeamInvoiceService; beforeEach(() => { service = new TeamInvoiceService(); }); const testCurrencies: Array<{ code: SupportedCurrency; expectedSymbol: string; formattedSample: string; }> = [ { code: 'USD', expectedSymbol: '$', formattedSample: '$100.00' }, { code: 'EUR', expectedSymbol: '€', formattedSample: '€100.00' }, { code: 'GBP', expectedSymbol: '£', formattedSample: '£100.00' }, { code: 'CNY', expectedSymbol: '¥', formattedSample: '¥100.00' }, ]; testCurrencies.forEach(({ code, expectedSymbol, formattedSample }) => { it(`币种 ${code} 应匹配符号 ${expectedSymbol} 并正确格式化`, () => { const symbol = service.getCurrencySymbol(code); expect(symbol).toBe(expectedSymbol); const formatted = service.formatCurrency(100, code); expect(formatted).toBe(formattedSample); }); }); it('应容错小写币种输入并归一化', () => { const symbol = service.getCurrencySymbol('usd' as SupportedCurrency); expect(symbol).toBe('$'); }); it('未知币种应安全回退默认美元 USD ($)', () => { const symbol = service.getCurrencySymbol('UNKNOWN' as SupportedCurrency); expect(symbol).toBe('$'); }); }); // ========================================== // 5. 打印模板输出测试 (瑞士国际主义风格) // ========================================== describe('5. 打印模板生成测试 (瑞士国际主义风格)', () => { let service: TeamInvoiceService; beforeEach(() => { service = new TeamInvoiceService(); }); it('生成的 HTML 模板应包含发票核心字段且遵循克制排版无 emoji', () => { const html = service.renderHtmlTemplate({ invoiceNo: 'INV-20260920-0001', issueDate: '2026-09-20', currency: 'USD', currencySymbol: '$', subtotal: 100.0, taxRate: 0.1, taxAmount: 10.0, totalAmount: 110.0, billingInfo: { companyName: 'Acme Global Ltd', taxId: 'US-99887766', address: '100 Market St, San Francisco, CA', }, items: [ { description: 'OmniCard Enterprise Plan (Annual)', quantity: 1, unitPrice: 100.0, amount: 100.0, }, ], }); expect(html).toContain('INV-20260920-0001'); expect(html).toContain('Acme Global Ltd'); expect(html).toContain('$100.00'); expect(html).toContain('$10.00'); expect(html).toContain('$110.00'); expect(html).toContain('OmniCard Enterprise Plan (Annual)'); // 瑞士国际主义风格验证:无 emoji,包含 Inter 或 Helvetica 字体定义 expect(html).toMatch(/Inter|Helvetica/i); expect(html).not.toMatch(/[\uD800-\uDBFF][\uDC00-\uDFFF]/); // 禁止 emoji }); it('生成的纯文本模板应结构化对齐', () => { const text = service.renderTextTemplate({ invoiceNo: 'INV-20260920-0001', issueDate: '2026-09-20', currency: 'EUR', currencySymbol: '€', subtotal: 80.0, taxRate: 0.2, taxAmount: 16.0, totalAmount: 96.0, billingInfo: { companyName: 'Berlin Tech GmbH', }, items: [ { description: 'Pro Subscription', quantity: 1, unitPrice: 80.0, amount: 80.0, }, ], }); expect(text).toContain('INVOICE / RECEIPT'); expect(text).toContain('INV-20260920-0001'); expect(text).toContain('Berlin Tech GmbH'); expect(text).toContain('€80.00'); expect(text).toContain('€96.00'); }); }); // ========================================== // 6. 服务层发票生成与历史查询 (TeamInvoiceService) // ========================================== describe('6. 业务服务层发票生成与查询测试', () => { let service: TeamInvoiceService; let invoices: TeamInvoiceEntity[]; let nextId = 1; beforeEach(() => { invoices = []; nextId = 1; const mockRepo = { count: jest.fn(async () => invoices.length), save: jest.fn(async (entity: TeamInvoiceEntity) => { const saved = Object.assign(new TeamInvoiceEntity(), entity, { id: nextId++, createTime: new Date(), updateTime: new Date(), }); invoices.push(saved); return saved; }), find: jest.fn(async (options?: { where?: Partial }) => { if (!options?.where) return [...invoices]; return invoices.filter(item => { const itemMap = item as unknown as Record; for (const [key, val] of Object.entries(options.where!)) { if (itemMap[key] !== val) return false; } return true; }); }), }; service = new TeamInvoiceService(); service.teamInvoiceEntity = mockRepo as unknown as TeamInvoiceService['teamInvoiceEntity']; }); it('generateInvoice 应校验租户 ID 并成功创建发票', async () => { await expect( service.generateInvoice(0, { title: '企业版订阅', subtotal: 100, } as CreateInvoiceDTO) ).rejects.toThrow(CoolCommException); const orderData: CreateInvoiceDTO = { orderId: 'ORD-2026-001', currency: 'USD', title: 'OmniCard Enterprise Plan', subtotal: 200, taxRate: 0.1, billingInfo: { companyName: 'Global Corp', taxId: 'TAX-12345', }, items: [ { description: 'Team Seats x 10', quantity: 1, unitPrice: 200, amount: 200, }, ], }; const result = await service.generateInvoice(101, orderData); expect(result).toBeDefined(); expect(result.tenantId).toBe(101); expect(result.invoiceNo).toMatch(/^INV-\d{8}-\d{4}$/); expect(result.currency).toBe('USD'); expect(result.currencySymbol).toBe('$'); expect(result.subtotal).toBe(200.0); expect(result.taxAmount).toBe(20.0); expect(result.totalAmount).toBe(220.0); expect(result.htmlTemplate).toBeDefined(); expect(result.textTemplate).toBeDefined(); }); it('listInvoices 应严格按租户隔离并返回历史发票列表', async () => { // 租户 101 的两张发票 await service.generateInvoice(101, { subtotal: 100, currency: 'USD', title: 'Invoice 1', }); await service.generateInvoice(101, { subtotal: 200, currency: 'EUR', title: 'Invoice 2', }); // 租户 202 的一张发票 await service.generateInvoice(202, { subtotal: 300, currency: 'GBP', title: 'Invoice 3', }); const list101 = await service.listInvoices(101); expect(list101.length).toBe(2); expect(list101.every(inv => inv.tenantId === 101)).toBe(true); const list202 = await service.listInvoices(202); expect(list202.length).toBe(1); expect(list202[0].tenantId).toBe(202); }); }); // ========================================== // 6b. 发票下载真实文件内容 (P38) // ========================================== describe('6b. 发票下载真实文件内容 (P38)', () => { let service: TeamInvoiceService; let invoices: TeamInvoiceEntity[]; let nextId = 1; beforeEach(() => { invoices = []; nextId = 1; const mockRepo = { count: jest.fn(async () => invoices.length), save: jest.fn(async (entity: TeamInvoiceEntity) => { const saved = Object.assign(new TeamInvoiceEntity(), entity, { id: nextId++, createTime: new Date(), updateTime: new Date(), }); invoices.push(saved); return saved; }), find: jest.fn(async (options?: { where?: Partial }) => { if (!options?.where) return [...invoices]; return invoices.filter(item => { const itemMap = item as unknown as Record; for (const [key, val] of Object.entries(options.where!)) { if (itemMap[key] !== val) return false; } return true; }); }), findOne: jest.fn( async (options?: { where?: Partial }) => { if (!options?.where) return null; const found = invoices.find(item => { const itemMap = item as unknown as Record; for (const [key, val] of Object.entries(options.where!)) { if (itemMap[key] !== val) return false; } return true; }); return found || null; } ), findOneBy: jest.fn(async (where: Partial) => { const found = invoices.find(item => { const itemMap = item as unknown as Record; for (const [key, val] of Object.entries(where)) { if (itemMap[key] !== val) return false; } return true; }); return found || null; }), }; service = new TeamInvoiceService(); service.teamInvoiceEntity = mockRepo as unknown as TeamInvoiceService['teamInvoiceEntity']; }); it('downloadInvoice 发票不存在时应抛 CoolCommException,不得返回成功 URL', async () => { await expect(service.downloadInvoice(99999, 101)).rejects.toThrow( CoolCommException ); await expect(service.downloadInvoice(99999, 101)).rejects.toThrow( /不存在|not found/i ); }); it('downloadInvoice 应返回真实文件内容,且含发票号与金额', async () => { const created = await service.generateInvoice(101, { subtotal: 200, taxRate: 0.1, currency: 'USD', title: 'Enterprise Plan', }); const result = await service.downloadInvoice(created.id, 101); expect(result).toBeDefined(); expect(result.content).toBeTruthy(); expect(typeof result.content).toBe('string'); expect(result.content.length).toBeGreaterThan(20); expect(result.content).toContain(created.invoiceNo); expect(result.content).toMatch(/220\.00|\$220/); expect(result.filename).toMatch( new RegExp(`${created.invoiceNo}\\.(txt|pdf)$`) ); expect(result.contentType).toMatch(/text\/plain|application\/pdf/); expect(result.invoiceNo).toBe(created.invoiceNo); // 禁止假 CDN / 空壳外链 expect(result).not.toHaveProperty('url'); expect(result.content).not.toMatch( /cdn\.omnicard|https?:\/\/.*invoice.*\.pdf/i ); }); it('downloadInvoice 跨租户或无权访问时应报错,不得返回成功 URL', async () => { const created = await service.generateInvoice(101, { subtotal: 50, currency: 'USD', title: 'Cross-tenant check', }); await expect(service.downloadInvoice(created.id, 202)).rejects.toThrow( CoolCommException ); }); it('downloadInvoice 在无 Stripe 发票号时不得假装已从 Stripe 拉取 PDF', async () => { const created = await service.generateInvoice(101, { subtotal: 88, currency: 'EUR', title: 'No Stripe PDF', }); const result = await service.downloadInvoice(created.id, 101); expect(result.content).toContain(created.invoiceNo); expect(result.content).not.toMatch( /stripe\.com|invoicestripes|invoice_pdf/i ); expect(JSON.stringify(result)).not.toMatch( /https:\/\/files\.stripe\.com|invoice_pdf/i ); }); }); // ========================================== // 7. 控制器接口调用测试 (AdminTeamBillingController) // ========================================== describe('7. 控制器发票接口测试 (AdminTeamBillingController)', () => { let controller: AdminTeamBillingController; let invoiceService: TeamInvoiceService; beforeEach(() => { invoiceService = new TeamInvoiceService(); invoiceService.generateInvoice = jest.fn(async (tenantId: number, dto: CreateInvoiceDTO) => { const inv = new TeamInvoiceEntity(); inv.id = 1; inv.tenantId = tenantId; inv.invoiceNo = 'INV-20260920-0001'; inv.totalAmount = 100; inv.currency = (dto.currency as SupportedCurrency) || 'USD'; inv.currencySymbol = '$'; return inv; }); invoiceService.listInvoices = jest.fn(async (tenantId: number) => { const inv = new TeamInvoiceEntity(); inv.id = 1; inv.tenantId = tenantId; inv.invoiceNo = 'INV-20260920-0001'; return [inv]; }); invoiceService.downloadInvoice = jest.fn(async () => ({ content: 'INVOICE / RECEIPT\nInvoice No : INV-20260920-0001\nTOTAL AMOUNT : $110.00\n', contentType: 'text/plain; charset=utf-8', filename: 'INV-20260920-0001.txt', invoiceNo: 'INV-20260920-0001', })); controller = new AdminTeamBillingController(); controller.teamInvoiceService = invoiceService; controller.ok = (data: unknown) => ({ code: 1000, message: 'success', data, }); (controller as unknown as { ctx: { admin: { tenantId: number } } }).ctx = { admin: { tenantId: 888 }, }; }); it('POST /invoice/generate 应成功生成发票并关联当前租户', async () => { const res = await controller.generateInvoice({ title: 'Enterprise Plan', subtotal: 100, currency: 'USD', }); expect(res.code).toBe(1000); expect(invoiceService.generateInvoice).toHaveBeenCalledWith( 888, expect.objectContaining({ title: 'Enterprise Plan', }) ); }); it('POST /invoice/list 应返回当前租户的发票历史收据列表', async () => { const res = await controller.listInvoices({}); expect(res.code).toBe(1000); expect(invoiceService.listInvoices).toHaveBeenCalledWith(888); }); it('POST /invoice/download 应按 id 下载真实文件内容并委派 service', async () => { const res = (await controller.downloadInvoice({ id: 1 })) as { code: number; message: string; data: { content: string; filename: string; invoiceNo: string; contentType: string; }; }; expect(res.code).toBe(1000); expect(invoiceService.downloadInvoice).toHaveBeenCalledWith(1, 888); expect(res.data.content).toContain('INV-20260920-0001'); expect(res.data.content).toMatch(/110\.00|\$110/); expect(res.data).not.toHaveProperty('url'); }); it('POST /invoice/download 缺少 id 时应抛 CoolCommException', async () => { await expect(controller.downloadInvoice({})).rejects.toThrow( CoolCommException ); await expect(controller.downloadInvoice({ id: 0 })).rejects.toThrow( CoolCommException ); }); }); });