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

@@ -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);
});