Files
accounted/components/customers/CustomerForm.tsx
T
MattssonandClaude Fable 5.1 3918ff6620 fix(customers): make country ISO-2 everywhere and check it against the customer type (#2241)
* fix(customers): make country ISO-2 everywhere and check it against the customer type (#2025, #2028)

customers.country and suppliers.country were read as ISO codes by the
periodisk sammanstallning (SKV 5740), Peppol and the provider importers but
written as English names by the customer form and the v1 API, so a correct
German customer produced GERMANY811234567 in the SKV file plus two false
warnings, and an EU customer saved with land Sverige got reverse charge with
nothing objecting until after the invoice was sent.

- lib/vat/country-codes.ts: one helper that normalises codes and the
  Swedish/English names the writers used to store, the country-vs-type
  rule (swedish_business = SE, eu_business = EU member other than SE that
  matches the VAT prefix, non_eu_business = outside the EU), and the
  reverse-charge country gate.
- Writers: customer form and supplier form get a country select; internal
  REST, v1 REST, bulk-create, MCP create/update, CSV/Excel import and the
  provider migration mapper normalise to a code and refuse unknown text;
  the consistency rule is a form error and an API 400
  (CUSTOMER_COUNTRY_MISMATCH on update). An omitted country is SE for
  Swedish types, derived from the VAT prefix for eu_business, required
  for non_eu_business.
- vat-rules.ts: getVatRules and friends take the country as a third
  argument and grant reverse charge only for an EU country other than SE;
  every invoice/sales-order/MCP call site passes customer.country.
- periodisk sammanstallning reads legacy names through the same helper.
- Migration 20260903170000: normalize_country_code() SQL twin, country_raw
  rollback column on both tables, backfill of every non-code row; unknown
  text is left as-is. pg-real test for the function.

Closes #2025, closes #2028

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE

* fix(customers): keep reverse charge for defaulted-SE EU rows, gate the country rule on the fields it reads, fix build

Skeptic and CI findings on #2241, one pass:

- Migration step 4: eu_business rows whose country was null or only the old
  writer default (SE) while the VAT number names another EU member take the
  country from the prefix. The pre-2026-09 rules granted reverse charge on
  type + VIES validation alone, so these rows invoiced at 0% and would have
  flipped to 25% on the next invoice. country_raw = '' marks a null origin;
  rollback uses nullif(country_raw, '').
- countryPermitsReverseCharge refuses SE only: a VIES-validated number
  outweighs a non-EU address (Swiss company registered in DE, Monaco with a
  FR number, Northern Ireland XI).
- checkCountryConsistency: an eu_business outside the EU VAT area is
  accepted when the VAT prefix is an EU-trade registration (incl. XI);
  Monaco maps to the FR prefix.
- Internal PATCH, MCP update and the commit executor judge the country rule
  only when customer_type, country or vat_number is part of the update, so
  a contradictory legacy row can still change its email (v1 already did).
- Webshop-order customers get the order's billing country; spreadsheet
  import derives a missing country from the type and flags contradictions
  (parser row error + execute schema refine).
- Build: v1 [id] route typed the existing row through a narrowed alias
  (never) and passed messageSv/messageEn the v1 error context lacks; the
  self-billed customer projection lacked country.
- Checks: regenerated skills/accounted-api (customer example country SE).
- New parity test holds the migration's SQL name table to the TS table.
- DECISIONS.md: correct migration version and the revised rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:09:46 +02:00

601 lines
22 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useLocale, useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AttnLine } from '@/components/ui/attn-line'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import {
EMAIL_PATTERN,
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
parseInvoiceRecipientText,
} from '@/lib/invoices/email-recipients'
import {
PERSONAL_NUMBER_INPUT_RE,
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
isMaskedPersonalNumber,
} from '@/lib/customers/mask-personal-number'
import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape'
import {
COUNTRY_CONSISTENCY_MESSAGES,
checkCountryConsistency,
getCountryOptions,
normalizeCountryCode,
} from '@/lib/vat/country-codes'
import type { CreateCustomerInput } from '@/types'
interface CustomerFormProps {
onSubmit: (data: CreateCustomerInput) => Promise<void>
isLoading: boolean
initialData?: Partial<CreateCustomerInput>
}
export default function CustomerForm({
onSubmit,
isLoading,
initialData,
}: CustomerFormProps) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('form_customer')
const locale = useLocale() === 'en' ? 'en' : 'sv'
const countryOptions = useMemo(() => getCountryOptions(locale), [locale])
const [isValidatingVat, setIsValidatingVat] = useState(false)
const [vatValidationResult, setVatValidationResult] = useState<{
valid: boolean
name?: string
} | null>(null)
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(),
contact_person: z.string().max(200, t('contact_person_too_long')).optional(),
email: z.string().email(t('email_invalid')).optional().or(z.literal('')),
phone: z.string().optional(),
invoice_email_cc_addresses: z.string().optional(),
invoice_email_bcc_addresses: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
// ISO 3166-1 alpha-2. A row from before 2026-09 can still carry a name
// the backfill could not map; it is shown as-is in the picker and has to
// be replaced before the form saves.
country: z.string().refine((v) => normalizeCountryCode(v) !== null, t('country_invalid')),
org_number: z.string().optional(),
vat_number: z.string().optional(),
// Accepts a plaintext personnummer or either mask the API returns. The
// '********-????' placeholder has to pass: it is what a row whose stored
// value cannot be decrypted renders as, and rejecting it here blocked the
// whole edit dialog, so the customer's name and address became unsavable
// over a field the user could not fix.
personal_number: z
.string()
.regex(PERSONAL_NUMBER_INPUT_RE, t('personal_number_invalid'))
.optional()
.or(z.literal('')),
language: z.enum(['sv', 'en']).optional(),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
}).superRefine((customer, ctx) => {
// Country vs customer type vs VAT prefix (#2025): an EU customer with
// land Sverige got reverse charge and nothing objected until the
// periodisk sammanställning, after the invoice was sent. The API refuses
// the same combinations with a 400; saying it here keeps the fix one
// click away instead of one failed save away.
const countryIssue = checkCountryConsistency({
partyType: customer.customer_type,
country: customer.country,
vatNumber: customer.vat_number,
})
if (countryIssue) {
ctx.addIssue({
code: 'custom',
path: ['country'],
message: COUNTRY_CONSISTENCY_MESSAGES[countryIssue][locale],
})
}
// A personnummer entered as a business org number would be shown
// unmasked in every list (only individual customers are masked).
if (
customer.org_number &&
customer.customer_type !== 'individual' &&
looksLikeSwedishPersonalNumber(customer.org_number)
) {
ctx.addIssue({
code: 'custom',
path: ['org_number'],
message: t('org_number_looks_personal'),
})
}
const cc = parseInvoiceRecipientText(customer.invoice_email_cc_addresses ?? '')
const bcc = parseInvoiceRecipientText(customer.invoice_email_bcc_addresses ?? '')
for (const [field, addresses] of [
['invoice_email_cc_addresses', cc],
['invoice_email_bcc_addresses', bcc],
] as const) {
const invalid = addresses.find((address) => !EMAIL_PATTERN.test(address))
if (invalid) {
ctx.addIssue({
code: 'custom',
path: [field],
message: t('invoice_email_invalid', { address: invalid }),
})
}
}
if (cc.length + bcc.length > MAX_INVOICE_EMAIL_COPY_RECIPIENTS) {
ctx.addIssue({
code: 'custom',
path: ['invoice_email_cc_addresses'],
message: t('invoice_email_too_many', { count: MAX_INVOICE_EMAIL_COPY_RECIPIENTS }),
})
}
}), [t, locale])
type FormData = z.infer<typeof schema>
const {
register,
handleSubmit,
watch,
control,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: initialData?.name || '',
customer_type: initialData?.customer_type || 'swedish_business',
customer_number: initialData?.customer_number || '',
contact_person: initialData?.contact_person ?? '',
email: initialData?.email || '',
phone: initialData?.phone || '',
invoice_email_cc_addresses: initialData?.invoice_email_cc_addresses?.join('\n') ?? '',
invoice_email_bcc_addresses: initialData?.invoice_email_bcc_addresses?.join('\n') ?? '',
address_line1: initialData?.address_line1 || '',
postal_code: initialData?.postal_code || '',
city: initialData?.city || '',
country: normalizeCountryCode(initialData?.country) ?? initialData?.country ?? 'SE',
org_number: initialData?.org_number || '',
vat_number: initialData?.vat_number || '',
personal_number: initialData?.personal_number || '',
language: initialData?.language || 'sv',
default_payment_terms: initialData?.default_payment_terms || 30,
notes: initialData?.notes || '',
},
})
const customerType = watch('customer_type')
const vatNumber = watch('vat_number')
const countryValue = watch('country')
// A stored value the picker does not list (an unmapped legacy name, or a
// code outside the curated list) still has to be visible, or the field
// would look empty while holding something.
const countryValueUnlisted =
countryValue && !countryOptions.some((option) => option.code === countryValue)
// The stored value could not be decrypted. The field is editable (typing a
// fresh personnummer replaces it); say so, because the placeholder on its own
// reads like a rendering fault.
const personalNumberUnreadable = watch('personal_number') === UNDECRYPTABLE_PERSONAL_NUMBER_MASK
const handleValidateVat = async () => {
if (!vatNumber) return
setIsValidatingVat(true)
setVatValidationResult(null)
try {
const response = await fetch('/api/vat/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vat_number: vatNumber }),
})
const result = await response.json()
if (!response.ok) {
// Map the parsed body plus the status: on this path `result.error` is
// the canonical envelope OBJECT (the route is withRouteContext), and
// rendering it as a toast description would crash the React render.
toast({
title: t('vat_failed_title'),
description: getErrorMessage(result, { statusCode: response.status }),
variant: 'destructive',
})
return
}
setVatValidationResult({
valid: result.valid,
name: result.name,
})
if (result.valid && result.name) {
toast({
title: t('vat_verified_title'),
description: t('vat_verified_description', { name: result.name }),
})
} else if (!result.valid) {
toast({
title: t('vat_failed_title'),
description: result.error || t('vat_failed_default'),
variant: 'destructive',
})
}
} catch {
toast({
title: t('vat_error_title'),
variant: 'destructive',
})
} finally {
setIsValidatingVat(false)
}
}
const onFormSubmit = (data: FormData) => {
const {
invoice_email_cc_addresses: ccText,
invoice_email_bcc_addresses: bccText,
...customerData
} = data
const isEditing = initialData !== undefined
const payload: CreateCustomerInput = {
...customerData,
// NULL means never configured and lets a migration enrich the row.
// Empty values on an existing row are explicit clears and survive sync.
contact_person: data.contact_person?.trim() || (isEditing ? '' : null),
email: data.email || undefined,
personal_number: data.personal_number || null,
invoice_email_cc_addresses: ccText
? parseInvoiceRecipientText(ccText)
: isEditing ? [] : null,
invoice_email_bcc_addresses: bccText
? parseInvoiceRecipientText(bccText)
: isEditing ? [] : null,
}
// A mask means "unchanged", whichever form it is. Sending it would be
// harmless (the route ignores masks too) but omitting it keeps the intent
// legible in the request body.
if (isMaskedPersonalNumber(data.personal_number)) {
delete payload.personal_number
}
onSubmit(payload)
}
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Customer Type */}
<div className="space-y-2">
<Label>{t('type_label')}</Label>
<Controller
name="customer_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue placeholder={t('type_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="individual">{t('type_individual')}</SelectItem>
<SelectItem value="swedish_business">{t('type_swedish_business')}</SelectItem>
<SelectItem value="eu_business">{t('type_eu_business')}</SelectItem>
<SelectItem value="non_eu_business">{t('type_non_eu_business')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{t('type_hint')}
</p>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">{t('name_label')}</Label>
<Input
id="name"
placeholder={t('name_placeholder')}
{...register('name')}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</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="space-y-4">
<div className="space-y-2">
<Label htmlFor="contact_person">{t('contact_person_label')}</Label>
<Input
id="contact_person"
placeholder={t('contact_person_placeholder')}
{...register('contact_person')}
/>
{errors.contact_person && (
<p className="text-sm text-destructive">{errors.contact_person.message}</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
type="email"
placeholder={t('email_placeholder')}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
placeholder={t('phone_placeholder')}
{...register('phone')}
/>
</div>
</div>
</div>
{/* Customer-specific invoice recipients */}
<div className="space-y-4">
<h3 className="text-sm">{t('invoice_email_section')}</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="invoice_email_cc_addresses">{t('invoice_email_cc_label')}</Label>
<Textarea
id="invoice_email_cc_addresses"
rows={3}
placeholder={t('invoice_email_placeholder')}
{...register('invoice_email_cc_addresses')}
/>
{errors.invoice_email_cc_addresses && (
<p className="text-sm text-destructive">{errors.invoice_email_cc_addresses.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="invoice_email_bcc_addresses">{t('invoice_email_bcc_label')}</Label>
<Textarea
id="invoice_email_bcc_addresses"
rows={3}
placeholder={t('invoice_email_placeholder')}
{...register('invoice_email_bcc_addresses')}
/>
{errors.invoice_email_bcc_addresses && (
<p className="text-sm text-destructive">{errors.invoice_email_bcc_addresses.message}</p>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">{t('invoice_email_hint')}</p>
</div>
{/* Address */}
<div className="space-y-4">
<h3>{t('address_section')}</h3>
<div className="space-y-2">
<Label htmlFor="address_line1">{t('street_label')}</Label>
<Input
id="address_line1"
placeholder={t('street_placeholder')}
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">{t('postal_label')}</Label>
<Input
id="postal_code"
placeholder={t('postal_placeholder')}
{...register('postal_code')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">{t('city_label')}</Label>
<Input
id="city"
placeholder={t('city_placeholder')}
{...register('city')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="country">{t('country_label')}</Label>
<Controller
name="country"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger id="country">
<SelectValue placeholder={t('country_placeholder')} />
</SelectTrigger>
<SelectContent>
{countryValueUnlisted && (
<SelectItem value={countryValue}>
{normalizeCountryCode(countryValue)
? countryValue
: t('country_unknown_option', { value: countryValue })}
</SelectItem>
)}
{countryOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
{locale === 'en' ? option.nameEn : option.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.country && (
<p className="text-sm text-destructive">{errors.country.message}</p>
)}
</div>
</div>
</div>
{/* Identification: depends on customer type */}
{customerType === 'individual' ? (
<div className="space-y-4 pt-4 border-t">
<h3>{t('individual_section')}</h3>
<div className="space-y-2">
<Label htmlFor="personal_number">{t('personal_number_label')}</Label>
<Input
id="personal_number"
placeholder={t('personal_number_placeholder')}
{...register('personal_number')}
/>
{errors.personal_number ? (
<p className="text-sm text-destructive">{errors.personal_number.message}</p>
) : personalNumberUnreadable ? (
<AttnLine>{t('personal_number_unreadable')}</AttnLine>
) : null}
</div>
</div>
) : (
<div className="space-y-4 pt-4 border-t">
<h3>{t('business_section')}</h3>
<div className="space-y-2">
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
placeholder={t('org_number_placeholder')}
{...register('org_number')}
/>
{errors.org_number && (
<p className="text-sm text-destructive">{errors.org_number.message}</p>
)}
</div>
{(customerType === 'eu_business' || customerType === 'non_eu_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<div className="flex gap-2">
<Input
id="vat_number"
placeholder={customerType === 'eu_business' ? t('vat_placeholder_eu') : t('vat_placeholder_se')}
{...register('vat_number')}
className="flex-1"
/>
{customerType === 'eu_business' && (
<Button
type="button"
variant="outline"
onClick={handleValidateVat}
disabled={!vatNumber || isValidatingVat}
>
{isValidatingVat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : vatValidationResult?.valid ? (
<CheckCircle className="h-4 w-4 text-success" />
) : vatValidationResult?.valid === false ? (
<XCircle className="h-4 w-4 text-destructive" />
) : (
t('vat_verify')
)}
</Button>
)}
</div>
{customerType === 'eu_business' && (
<p className="text-xs text-muted-foreground">
{t('vat_hint_eu')}
</p>
)}
</div>
)}
</div>
)}
{/* Payment terms */}
<div className="space-y-2">
<Label htmlFor="payment_terms">{t('payment_terms_label')}</Label>
<Input
id="payment_terms"
type="number"
{...register('default_payment_terms', { valueAsNumber: true })}
/>
</div>
{/* Invoice language */}
<div className="space-y-2">
<Label>{t('language_label')}</Label>
<Controller
name="language"
control={control}
render={({ field }) => (
<Select value={field.value ?? 'sv'} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sv">{t('language_sv')}</SelectItem>
<SelectItem value="en">{t('language_en')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">{t('language_hint')}</p>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">{t('notes_label')}</Label>
<Textarea
id="notes"
placeholder={t('notes_placeholder')}
{...register('notes')}
/>
</div>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button
type="submit"
disabled={isLoading || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('submit_saving')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
{t('submit_save')}
</>
) : (
t('submit_save')
)}
</Button>
</div>
</form>
)
}