diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index fac31b17..a47635ec 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -806,6 +806,23 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat ) } + // The form always carries the self-billing fields (they default to '' in both + // create and edit mode). This editor's normal create/draft/edit flows never + // use self-billing, that goes through the dedicated /api/invoices/self-billed + // path, so drop these empty carriers before spreading the form data into the + // /api/invoices (or PATCH) body: a bare external_invoice_number: '' otherwise + // trips the shared CreateInvoiceSchema's min(1). Belt-and-suspenders; the + // server schema also coerces '' to undefined for these fields. + function stripSelfBillingFields(data: FormData): FormData { + const { + external_invoice_number: _ein, + self_billing_agreement_ref: _sbar, + received_date: _rd, + ...rest + } = data + return rest + } + // Self-billing path: no review dialog, no PDF, no send: it arrives already // booked. POST straight to the dedicated endpoint and open the verifikat. async function handleSelfBilledSubmit(data: FormData) { @@ -943,7 +960,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat return rest }) const sanitizedPayload: CreateInvoiceInput & { default_dimensions: Record } = { - ...(pendingData as CreateInvoiceInput), + ...(stripSelfBillingFields(pendingData) as CreateInvoiceInput), ore_rounding: oreRounding, // Invoice-level default dims: always sent so an edited draft can clear // them; {} means "no defaults". @@ -1020,7 +1037,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat return rest }) const payload: CreateInvoiceInput & { default_dimensions: Record } = { - ...(data as CreateInvoiceInput), + ...(stripSelfBillingFields(data) as CreateInvoiceInput), save_as_draft: true, ore_rounding: oreRounding, default_dimensions: defaultDims, @@ -1079,7 +1096,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat return rest }) const payload: CreateInvoiceInput & { default_dimensions: Record } = { - ...(data as CreateInvoiceInput), + ...(stripSelfBillingFields(data) as CreateInvoiceInput), ore_rounding: oreRounding, default_dimensions: defaultDims, items: sanitizedItems as CreateInvoiceInput['items'], diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index 0f627eae..0fa78dcb 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -384,6 +384,37 @@ describe('CreateInvoiceSchema', () => { })) expect(result.success).toBe(true) }) + + // Regression: the dashboard invoice form always sends the self-billing fields + // (default '' for a normal invoice). Empty strings must read as "not + // provided", not fail min(1)/isoDate, or every regular invoice create 400s. + it('treats empty self-billing strings as omitted (not a validation error)', () => { + const result = CreateInvoiceSchema.safeParse(validInvoice({ + external_invoice_number: '', + self_billing_agreement_ref: '', + received_date: '', + })) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.external_invoice_number).toBeUndefined() + expect(result.data.self_billing_agreement_ref).toBeUndefined() + expect(result.data.received_date).toBeUndefined() + } + }) + + it('still accepts real self-billing values', () => { + const result = CreateInvoiceSchema.safeParse(validInvoice({ + is_self_billed: true, + external_invoice_number: 'CUST-2026-014', + self_billing_agreement_ref: 'AVTAL-7', + received_date: '2026-07-07', + })) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.external_invoice_number).toBe('CUST-2026-014') + expect(result.data.received_date).toBe('2026-07-07') + } + }) }) describe('UpdateInvoiceSchema', () => { diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 6909694d..efd6e0ea 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -401,9 +401,22 @@ export const CreateInvoiceSchema = z.object({ // invoice. A plain optional flag (no schema refine) so UpdateInvoiceSchema's // .omit() keeps working on this object. is_self_billed: z.boolean().optional(), - external_invoice_number: z.string().min(1).max(64).optional(), - self_billing_agreement_ref: z.string().max(128).optional(), - received_date: isoDate.optional(), + // The dashboard invoice form always sends these self-billing fields (default + // '' in create/edit mode) even for a normal invoice, so an empty string must + // read as "not provided", not fail validation. Otherwise a plain + // external_invoice_number: '' trips the min(1) and 400s every regular invoice + // create. Required-when-self-billed is still enforced in the v1 route via a + // falsy check after parse, so normalising '' -> undefined here is safe. + external_invoice_number: z + .union([z.string().min(1).max(64), z.literal('')]) + .transform((v) => v || undefined) + .optional(), + self_billing_agreement_ref: z + .string() + .max(128) + .transform((v) => v || undefined) + .optional(), + received_date: optionalIsoDate, items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'), })