feat(invoices): polymorphic billing entity with snapshot clientName

Wires the billingEntityType/billingEntityId columns (added in PR 1) through
the invoice validator and service. Clients can now be billed as either a
client or a company; clientName becomes a snapshot derived from the entity
at create time.

- createInvoiceSchema: replace clientName with billingEntity {type,id}
- listInvoicesSchema: add billingEntityType/billingEntityId filters
- createInvoice: resolveBillingEntity helper (tenant-scoped; tx-aware)
  falls back to entity primary email/address when not supplied
- listInvoices: honor new billing-entity filters
- updateInvoice: unchanged — billing entity is fixed after create
- invoice wizard step 1: temporary billing-entity id input (Task 10.2
  replaces this with a proper picker)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 16:02:00 +02:00
parent c685c9fada
commit 9d7decfc5b
5 changed files with 442 additions and 116 deletions

View File

@@ -0,0 +1,224 @@
/**
* invoices.service billing-entity integration tests.
*
* Covers:
* - createInvoice with billingEntity=client snapshots clientName from client.fullName
* - createInvoice with billingEntity=company snapshots clientName from company.name
* - createInvoice throws ValidationError when client does not exist
* - createInvoice throws ValidationError when company is in another tenant (cross-tenant)
* - createInvoice uses entity primary email + address when none provided
* - createInvoice allows caller to override billingEmail and billingAddress
*
* Uses dynamic imports (PR 8 pattern) so env is loaded before service modules
* touch `db`.
*/
import { describe, it, expect, beforeAll } from 'vitest';
describe('invoices.service — billing entity', () => {
let createInvoice: typeof import('@/lib/services/invoices').createInvoice;
let makePort: typeof import('../helpers/factories').makePort;
let makeClient: typeof import('../helpers/factories').makeClient;
let makeCompany: typeof import('../helpers/factories').makeCompany;
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
let db: typeof import('@/lib/db').db;
let clientContacts: typeof import('@/lib/db/schema/clients').clientContacts;
let clientAddresses: typeof import('@/lib/db/schema/clients').clientAddresses;
let companyAddresses: typeof import('@/lib/db/schema/companies').companyAddresses;
let ValidationError: typeof import('@/lib/errors').ValidationError;
beforeAll(async () => {
const svc = await import('@/lib/services/invoices');
createInvoice = svc.createInvoice;
const factories = await import('../helpers/factories');
makePort = factories.makePort;
makeClient = factories.makeClient;
makeCompany = factories.makeCompany;
makeAuditMeta = factories.makeAuditMeta;
const dbMod = await import('@/lib/db');
db = dbMod.db;
const clientsSchema = await import('@/lib/db/schema/clients');
clientContacts = clientsSchema.clientContacts;
clientAddresses = clientsSchema.clientAddresses;
const companiesSchema = await import('@/lib/db/schema/companies');
companyAddresses = companiesSchema.companyAddresses;
const errors = await import('@/lib/errors');
ValidationError = errors.ValidationError;
});
it('creates an invoice with billingEntity=client and snapshots clientName from client.fullName', async () => {
const port = await makePort();
const client = await makeClient({
portId: port.id,
overrides: { fullName: 'Alice Admiral' },
});
const invoice = await createInvoice(
port.id,
{
billingEntity: { type: 'client', id: client.id },
dueDate: '2026-12-31',
paymentTerms: 'net30',
currency: 'USD',
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 500 }],
},
makeAuditMeta({ portId: port.id }),
);
expect(invoice.id).toBeTruthy();
expect(invoice.billingEntityType).toBe('client');
expect(invoice.billingEntityId).toBe(client.id);
expect(invoice.clientName).toBe('Alice Admiral');
expect(invoice.portId).toBe(port.id);
});
it('creates an invoice with billingEntity=company and snapshots clientName from company.name', async () => {
const port = await makePort();
const company = await makeCompany({
portId: port.id,
overrides: { name: 'Poseidon Holdings Ltd' },
});
const invoice = await createInvoice(
port.id,
{
billingEntity: { type: 'company', id: company.id },
dueDate: '2026-12-31',
paymentTerms: 'net30',
currency: 'USD',
lineItems: [{ description: 'Marina services', quantity: 2, unitPrice: 1250 }],
},
makeAuditMeta({ portId: port.id }),
);
expect(invoice.billingEntityType).toBe('company');
expect(invoice.billingEntityId).toBe(company.id);
expect(invoice.clientName).toBe('Poseidon Holdings Ltd');
});
it('throws ValidationError when billing entity (client) does not exist', async () => {
const port = await makePort();
await expect(
createInvoice(
port.id,
{
billingEntity: { type: 'client', id: 'nonexistent-client-id' },
dueDate: '2026-12-31',
paymentTerms: 'net30',
currency: 'USD',
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
},
makeAuditMeta({ portId: port.id }),
),
).rejects.toBeInstanceOf(ValidationError);
});
it('throws ValidationError when billing entity (company) is in a different port', async () => {
const portA = await makePort();
const portB = await makePort();
const companyInB = await makeCompany({ portId: portB.id });
await expect(
createInvoice(
portA.id,
{
billingEntity: { type: 'company', id: companyInB.id },
dueDate: '2026-12-31',
paymentTerms: 'net30',
currency: 'USD',
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
},
makeAuditMeta({ portId: portA.id }),
),
).rejects.toBeInstanceOf(ValidationError);
});
it('uses entity primary email + address when none provided in the request', async () => {
const port = await makePort();
const client = await makeClient({
portId: port.id,
overrides: { fullName: 'Bob Bosun' },
});
// Add a primary email contact and a primary address
await db.insert(clientContacts).values({
clientId: client.id,
channel: 'email',
value: 'bob@example.com',
isPrimary: true,
});
await db.insert(clientAddresses).values({
clientId: client.id,
portId: port.id,
label: 'Primary',
streetAddress: '1 Pier Road',
city: 'Harbor City',
stateProvince: 'CA',
postalCode: '90000',
country: 'USA',
isPrimary: true,
});
const invoice = await createInvoice(
port.id,
{
billingEntity: { type: 'client', id: client.id },
dueDate: '2026-12-31',
paymentTerms: 'net30',
currency: 'USD',
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
},
makeAuditMeta({ portId: port.id }),
);
expect(invoice.billingEmail).toBe('bob@example.com');
expect(invoice.billingAddress).toBe('1 Pier Road, Harbor City, CA, 90000, USA');
});
it('allows caller to override billingEmail and billingAddress', async () => {
const port = await makePort();
const company = await makeCompany({
portId: port.id,
overrides: { name: 'Nautical Corp', billingEmail: 'billing@nautical.example' },
});
await db.insert(companyAddresses).values({
companyId: company.id,
portId: port.id,
label: 'Primary',
streetAddress: '2 Ocean Blvd',
city: 'Portville',
stateProvince: 'FL',
postalCode: '33101',
country: 'USA',
isPrimary: true,
});
const invoice = await createInvoice(
port.id,
{
billingEntity: { type: 'company', id: company.id },
billingEmail: 'override@example.com',
billingAddress: 'Custom address line',
dueDate: '2026-12-31',
paymentTerms: 'net30',
currency: 'USD',
lineItems: [{ description: 'Dockage', quantity: 1, unitPrice: 100 }],
},
makeAuditMeta({ portId: port.id }),
);
expect(invoice.billingEmail).toBe('override@example.com');
expect(invoice.billingAddress).toBe('Custom address line');
// clientName snapshot is still entity-derived (not overridable on create)
expect(invoice.clientName).toBe('Nautical Corp');
});
});

View File

@@ -194,7 +194,7 @@ describe('updateBerthStatusSchema', () => {
describe('createInvoiceSchema', () => {
const validInvoice = {
clientName: 'Bob',
billingEntity: { type: 'client' as const, id: 'client-123' },
dueDate: '2026-06-01',
lineItems: [{ description: 'Berth fee', quantity: 1, unitPrice: 5000 }],
};
@@ -203,9 +203,17 @@ describe('createInvoiceSchema', () => {
expect(createInvoiceSchema.safeParse(validInvoice).success).toBe(true);
});
it('accepts a valid invoice with billingEntity type=company', () => {
const result = createInvoiceSchema.safeParse({
...validInvoice,
billingEntity: { type: 'company' as const, id: 'company-123' },
});
expect(result.success).toBe(true);
});
it('accepts invoice with only expenseIds', () => {
const result = createInvoiceSchema.safeParse({
clientName: 'Bob',
billingEntity: { type: 'client' as const, id: 'client-123' },
dueDate: '2026-06-01',
expenseIds: ['exp-1'],
});
@@ -213,12 +221,34 @@ describe('createInvoiceSchema', () => {
});
it('rejects invoice with neither lineItems nor expenseIds', () => {
const result = createInvoiceSchema.safeParse({ clientName: 'Bob', dueDate: '2026-06-01' });
const result = createInvoiceSchema.safeParse({
billingEntity: { type: 'client' as const, id: 'client-123' },
dueDate: '2026-06-01',
});
expect(result.success).toBe(false);
});
it('rejects empty clientName', () => {
const result = createInvoiceSchema.safeParse({ ...validInvoice, clientName: '' });
it('rejects missing billingEntity', () => {
const result = createInvoiceSchema.safeParse({
dueDate: '2026-06-01',
lineItems: [{ description: 'Fee', quantity: 1, unitPrice: 1 }],
});
expect(result.success).toBe(false);
});
it('rejects billingEntity with invalid type', () => {
const result = createInvoiceSchema.safeParse({
...validInvoice,
billingEntity: { type: 'unknown', id: 'id-1' },
});
expect(result.success).toBe(false);
});
it('rejects billingEntity with empty id', () => {
const result = createInvoiceSchema.safeParse({
...validInvoice,
billingEntity: { type: 'client', id: '' },
});
expect(result.success).toBe(false);
});