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

@@ -55,7 +55,13 @@ export default function NewInvoicePage() {
},
});
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = methods;
const watchedValues = watch();
const lineItems = watchedValues.lineItems ?? [];
@@ -87,7 +93,7 @@ export default function NewInvoicePage() {
async function goNext() {
if (step === 1) {
const valid = await methods.trigger([
'clientName',
'billingEntity',
'billingEmail',
'billingAddress',
'dueDate',
@@ -112,11 +118,7 @@ export default function NewInvoicePage() {
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/${portSlug}/invoices`)}
>
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<h1 className="text-xl font-semibold">New Invoice</h1>
@@ -131,22 +133,16 @@ export default function NewInvoicePage() {
step > s.id
? 'bg-primary text-primary-foreground'
: step === s.id
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
</div>
<span
className={`text-sm ${
step === s.id ? 'font-medium' : 'text-muted-foreground'
}`}
>
<span className={`text-sm ${step === s.id ? 'font-medium' : 'text-muted-foreground'}`}>
{s.label}
</span>
{idx < STEPS.length - 1 && (
<div className="w-8 h-px bg-border mx-1" />
)}
{idx < STEPS.length - 1 && <div className="w-8 h-px bg-border mx-1" />}
</div>
))}
</div>
@@ -161,17 +157,36 @@ export default function NewInvoicePage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="clientName">
Client Name <span className="text-destructive">*</span>
<Label htmlFor="billingEntityType">
Billing Entity <span className="text-destructive">*</span>
</Label>
<Input
id="clientName"
{...register('clientName')}
placeholder="Client or company name"
/>
{errors.clientName && (
<p className="text-xs text-destructive">{errors.clientName.message}</p>
<div className="grid grid-cols-2 gap-2">
<Select
defaultValue="client"
onValueChange={(v) =>
setValue('billingEntity.type', v as 'client' | 'company')
}
>
<SelectTrigger id="billingEntityType">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="client">Client</SelectItem>
<SelectItem value="company">Company</SelectItem>
</SelectContent>
</Select>
<Input {...register('billingEntity.id')} placeholder="Entity ID" />
</div>
{errors.billingEntity && (
<p className="text-xs text-destructive">
{errors.billingEntity.message ??
errors.billingEntity.id?.message ??
errors.billingEntity.type?.message}
</p>
)}
<p className="text-xs text-muted-foreground">
Picker UI is coming in Task 10.2 for now paste the client or company ID.
</p>
</div>
<div className="space-y-1">
@@ -202,11 +217,7 @@ export default function NewInvoicePage() {
<Label htmlFor="dueDate">
Due Date <span className="text-destructive">*</span>
</Label>
<Input
id="dueDate"
type="date"
{...register('dueDate')}
/>
<Input id="dueDate" type="date" {...register('dueDate')} />
{errors.dueDate && (
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
)}
@@ -216,7 +227,9 @@ export default function NewInvoicePage() {
<Label>Payment Terms</Label>
<Select
defaultValue="net30"
onValueChange={(v) => setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])}
onValueChange={(v) =>
setValue('paymentTerms', v as CreateInvoiceInput['paymentTerms'])
}
>
<SelectTrigger>
<SelectValue placeholder="Select terms" />
@@ -284,8 +297,10 @@ export default function NewInvoicePage() {
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Client</span>
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
<span className="text-muted-foreground">Billing Entity</span>
<p className="font-medium mt-0.5">
{watchedValues.billingEntity?.type}: {watchedValues.billingEntity?.id}
</p>
</div>
<div>
<span className="text-muted-foreground">Due Date</span>
@@ -293,9 +308,7 @@ export default function NewInvoicePage() {
</div>
<div>
<span className="text-muted-foreground">Payment Terms</span>
<p className="font-medium mt-0.5 capitalize">
{watchedValues.paymentTerms}
</p>
<p className="font-medium mt-0.5 capitalize">{watchedValues.paymentTerms}</p>
</div>
<div>
<span className="text-muted-foreground">Currency</span>
@@ -354,12 +367,7 @@ export default function NewInvoicePage() {
{/* Navigation */}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={step === 1}
>
<Button type="button" variant="outline" onClick={goBack} disabled={step === 1}>
<ChevronLeft className="mr-1.5 h-4 w-4" />
Back
</Button>