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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user