feat(customers): let users set a customer number shown on the invoice (#957)

Implements #914 (kundnummer on customers, printed on the invoice PDF).

- Migration: nullable text column customers.customer_number, no unique
  constraint in v1 so existing rows and imports keep working.
- API: CreateCustomerSchema/UpdateCustomerSchema accept an optional
  customer_number (trimmed, max 32 chars, nullable-then-optional so the
  OpenAPI registry sees it as not required); create/update routes
  persist it and normalize empty string to null so it can be cleared.
- v1 public API: customers create/detail/update round-trip the field
  (insert and update field lists, response projections, response
  schemas), and the invoices :send route fetches customer_number in its
  explicit customer join so the emailed PDF matches the downloaded one
  (the pdf route already selects customers(*)).
- UI: optional Kundnummer field in CustomerForm (next-intl keys in both
  sv and en), wired into the edit dialog's initialData; read-only
  Kundnummer row on the customer detail page's business-details card.
- Invoice PDF: renders "Kundnr:" / "Customer no.:" in the customer box
  when set; the PDF reads the live customers join, so no snapshot
  column is needed.
- Tests: route tests cover 400 validation, trimming, clearing with
  null/empty, and omit-leaves-untouched on POST and PATCH; v1 tests
  cover the create/update round-trip (insert/update payload + response
  projection) and the :send customer-join projection.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-09 21:27:03 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 11400b4dec
commit 2e7931b36b
18 changed files with 463 additions and 8 deletions
+17
View File
@@ -38,6 +38,7 @@ export default function CustomerForm({
const schema = useMemo(() => z.object({
name: z.string().min(1, t('name_required')),
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
customer_number: z.string().trim().max(32, t('customer_number_too_long')).optional(),
email: z.string().email(t('email_invalid')).optional().or(z.literal('')),
phone: z.string().optional(),
address_line1: z.string().optional(),
@@ -70,6 +71,7 @@ export default function CustomerForm({
defaultValues: {
name: initialData?.name || '',
customer_type: initialData?.customer_type || 'swedish_business',
customer_number: initialData?.customer_number || '',
email: initialData?.email || '',
phone: initialData?.phone || '',
address_line1: initialData?.address_line1 || '',
@@ -178,6 +180,21 @@ export default function CustomerForm({
)}
</div>
{/* Customer number */}
<div className="space-y-2">
<Label htmlFor="customer_number">{t('customer_number_label')}</Label>
<Input
id="customer_number"
placeholder={t('customer_number_placeholder')}
{...register('customer_number')}
/>
{errors.customer_number ? (
<p className="text-sm text-destructive">{errors.customer_number.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t('customer_number_hint')}</p>
)}
</div>
{/* Contact */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">