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

@@ -2,20 +2,17 @@ import { eq, and, desc, like, lt, sql, gte, lte, inArray, ne } from 'drizzle-orm
import type { PgColumn } from 'drizzle-orm/pg-core';
import { db } from '@/lib/db';
import {
invoices,
invoiceLineItems,
invoiceExpenses,
expenses,
} from '@/lib/db/schema/financial';
import { invoices, invoiceLineItems, invoiceExpenses, expenses } from '@/lib/db/schema/financial';
import { files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { systemSettings } from '@/lib/db/schema/system';
import { clients, clientAddresses } from '@/lib/db/schema/clients';
import { companies, companyAddresses } from '@/lib/db/schema/companies';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { withTransaction } from '@/lib/db/utils';
import { NotFoundError, ConflictError } from '@/lib/errors';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate';
@@ -50,9 +47,7 @@ async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<str
const [existing] = await tx
.select({ invoiceNumber: invoices.invoiceNumber })
.from(invoices)
.where(
and(eq(invoices.portId, portId), like(invoices.invoiceNumber, `${prefix}-%`)),
)
.where(and(eq(invoices.portId, portId), like(invoices.invoiceNumber, `${prefix}-%`)))
.orderBy(desc(invoices.invoiceNumber))
.limit(1);
@@ -64,6 +59,88 @@ async function generateInvoiceNumber(portId: string, tx: typeof db): Promise<str
return `${prefix}-${String(seq).padStart(3, '0')}`;
}
// ─── Resolve billing entity (polymorphic client | company) ────────────────
/**
* Look up the billing entity referenced on invoice create and derive the
* display name + fallback billing email/address. Scoped to the tenant (portId);
* throws ValidationError on missing / cross-tenant lookups.
*
* Runs inside the caller's transaction so the lookup is consistent with the
* rest of the create operation.
*/
async function resolveBillingEntity(
tx: typeof db,
portId: string,
entity: { type: 'client' | 'company'; id: string },
): Promise<{
clientName: string;
billingEmail: string | null;
billingAddress: string | null;
}> {
if (entity.type === 'client') {
const client = await tx.query.clients.findFirst({
where: and(eq(clients.id, entity.id), eq(clients.portId, portId)),
with: {
contacts: true,
},
});
if (!client) throw new ValidationError('billing entity (client) not found');
// Prefer primary email contact, fall back to any email contact
const emailContact =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'email');
const addressRow = await tx.query.clientAddresses.findFirst({
where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)),
});
const billingAddress = addressRow
? [
addressRow.streetAddress,
addressRow.city,
addressRow.stateProvince,
addressRow.postalCode,
addressRow.country,
]
.filter(Boolean)
.join(', ')
: null;
return {
clientName: client.fullName,
billingEmail: emailContact?.value ?? null,
billingAddress: billingAddress || null,
};
}
const company = await tx.query.companies.findFirst({
where: and(eq(companies.id, entity.id), eq(companies.portId, portId)),
});
if (!company) throw new ValidationError('billing entity (company) not found');
const addressRow = await tx.query.companyAddresses.findFirst({
where: and(eq(companyAddresses.companyId, company.id), eq(companyAddresses.isPrimary, true)),
});
const billingAddress = addressRow
? [
addressRow.streetAddress,
addressRow.city,
addressRow.stateProvince,
addressRow.postalCode,
addressRow.country,
]
.filter(Boolean)
.join(', ')
: null;
return {
clientName: company.name,
billingEmail: company.billingEmail ?? null,
billingAddress: billingAddress || null,
};
}
// ─── List ─────────────────────────────────────────────────────────────────
export async function listInvoices(portId: string, query: ListInvoicesInput) {
@@ -81,6 +158,12 @@ export async function listInvoices(portId: string, query: ListInvoicesInput) {
if (query.dateTo) {
filters.push(lte(invoices.dueDate, query.dateTo));
}
if (query.billingEntityType) {
filters.push(eq(invoices.billingEntityType, query.billingEntityType));
}
if (query.billingEntityId) {
filters.push(eq(invoices.billingEntityId, query.billingEntityId));
}
return buildListQuery({
table: invoices,
@@ -139,14 +222,20 @@ export async function createInvoice(
meta: ServiceAuditMeta,
) {
const invoice = await withTransaction(async (tx) => {
// Resolve the polymorphic billing entity (client | company). Throws
// ValidationError if the entity is missing or belongs to another tenant.
const entitySnapshot = await resolveBillingEntity(tx, portId, data.billingEntity);
// clientName is always entity-derived on create (it's a snapshot).
// Caller-supplied billingEmail/billingAddress win over entity-derived values.
const effectiveClientName = entitySnapshot.clientName;
const effectiveBillingEmail = data.billingEmail ?? entitySnapshot.billingEmail;
const effectiveBillingAddress = data.billingAddress ?? entitySnapshot.billingAddress;
const invoiceNumber = await generateInvoiceNumber(portId, tx);
// Calculate subtotal from line items
const lineItemsData = data.lineItems ?? [];
const subtotal = lineItemsData.reduce(
(sum, li) => sum + li.quantity * li.unitPrice,
0,
);
const subtotal = lineItemsData.reduce((sum, li) => sum + li.quantity * li.unitPrice, 0);
// BR-042: net10 discount — read from systemSettings
let discountPct = 0;
@@ -155,10 +244,7 @@ export async function createInvoice(
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, 'invoice_net10_discount'),
eq(systemSettings.portId, portId),
),
and(eq(systemSettings.key, 'invoice_net10_discount'), eq(systemSettings.portId, portId)),
)
.limit(1);
@@ -182,17 +268,12 @@ export async function createInvoice(
.from(invoiceExpenses)
.innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
.where(
and(
inArray(invoiceExpenses.expenseId, expenseIds),
sql`${invoices.status} != 'draft'`,
),
and(inArray(invoiceExpenses.expenseId, expenseIds), sql`${invoices.status} != 'draft'`),
)
.limit(1);
if (alreadyLinked.length > 0) {
throw new ConflictError(
'One or more expenses are already linked to a non-draft invoice',
);
throw new ConflictError('One or more expenses are already linked to a non-draft invoice');
}
}
@@ -201,9 +282,11 @@ export async function createInvoice(
.values({
portId,
invoiceNumber,
clientName: data.clientName,
billingEmail: data.billingEmail ?? null,
billingAddress: data.billingAddress ?? null,
billingEntityType: data.billingEntity.type,
billingEntityId: data.billingEntity.id,
clientName: effectiveClientName,
billingEmail: effectiveBillingEmail,
billingAddress: effectiveBillingAddress,
dueDate: data.dueDate,
paymentTerms: data.paymentTerms ?? 'net30',
currency: data.currency ?? 'USD',
@@ -297,10 +380,7 @@ export async function updateInvoice(
// Recalculate totals if line items changed
if (data.lineItems !== undefined) {
const lineItemsData = data.lineItems;
const subtotal = lineItemsData.reduce(
(sum, li) => sum + li.quantity * li.unitPrice,
0,
);
const subtotal = lineItemsData.reduce((sum, li) => sum + li.quantity * li.unitPrice, 0);
const paymentTerms = data.paymentTerms ?? existing.paymentTerms;
let discountPct = 0;
@@ -364,17 +444,15 @@ export async function updateInvoice(
.limit(1);
if (alreadyLinked.length > 0) {
throw new ConflictError(
'One or more expenses are already linked to a non-draft invoice',
);
throw new ConflictError('One or more expenses are already linked to a non-draft invoice');
}
}
await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id));
if (data.expenseIds.length > 0) {
await tx.insert(invoiceExpenses).values(
data.expenseIds.map((expenseId) => ({ invoiceId: id, expenseId })),
);
await tx
.insert(invoiceExpenses)
.values(data.expenseIds.map((expenseId) => ({ invoiceId: id, expenseId })));
}
}
@@ -416,11 +494,7 @@ export async function updateInvoice(
// ─── Delete (draft only) ──────────────────────────────────────────────────
export async function deleteInvoice(
id: string,
portId: string,
meta: ServiceAuditMeta,
) {
export async function deleteInvoice(id: string, portId: string, meta: ServiceAuditMeta) {
const existing = await getInvoiceById(id, portId);
if (existing.status !== 'draft') {
throw new ConflictError('Only draft invoices can be deleted');
@@ -429,9 +503,7 @@ export async function deleteInvoice(
await withTransaction(async (tx) => {
await tx.delete(invoiceExpenses).where(eq(invoiceExpenses.invoiceId, id));
await tx.delete(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id));
await tx
.delete(invoices)
.where(and(eq(invoices.id, id), eq(invoices.portId, portId)));
await tx.delete(invoices).where(and(eq(invoices.id, id), eq(invoices.portId, portId)));
});
void createAuditLog({
@@ -453,11 +525,7 @@ export async function deleteInvoice(
// ─── Generate PDF ─────────────────────────────────────────────────────────
export async function generateInvoicePdf(
id: string,
portId: string,
meta: ServiceAuditMeta,
) {
export async function generateInvoicePdf(id: string, portId: string, meta: ServiceAuditMeta) {
const invoice = await getInvoiceById(id, portId);
const [port] = await db
@@ -519,11 +587,7 @@ export async function generateInvoicePdf(
// ─── Send invoice ─────────────────────────────────────────────────────────
export async function sendInvoice(
id: string,
portId: string,
meta: ServiceAuditMeta,
) {
export async function sendInvoice(id: string, portId: string, meta: ServiceAuditMeta) {
const invoice = await getInvoiceById(id, portId);
// Generate PDF if not exists
@@ -621,11 +685,7 @@ export async function detectOverdue(portId: string) {
.select({ id: invoices.id, invoiceNumber: invoices.invoiceNumber, dueDate: invoices.dueDate })
.from(invoices)
.where(
and(
eq(invoices.portId, portId),
eq(invoices.status, 'sent'),
lt(invoices.dueDate, today),
),
and(eq(invoices.portId, portId), eq(invoices.status, 'sent'), lt(invoices.dueDate, today)),
);
if (overdueInvoices.length === 0) return;
@@ -636,9 +696,10 @@ export async function detectOverdue(portId: string) {
.set({ status: 'overdue', updatedAt: new Date() })
.where(eq(invoices.id, inv.id));
const daysPastDue = Math.max(1, Math.ceil(
(Date.now() - new Date(inv.dueDate).getTime()) / (1000 * 60 * 60 * 24),
));
const daysPastDue = Math.max(
1,
Math.ceil((Date.now() - new Date(inv.dueDate).getTime()) / (1000 * 60 * 60 * 24)),
);
emitToRoom(`port:${portId}`, 'invoice:overdue', {
invoiceId: inv.id,
invoiceNumber: inv.invoiceNumber,