Files
accounted/app/api/customers/[id]/route.ts
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

286 lines
12 KiB
TypeScript

import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number'
import {
looksLikeSwedishPersonalNumber,
normalizeReroutedPersonalNumber,
orgNumberHoldsPersonalNumber,
personalNumberDigits,
} from '@/lib/customers/personal-number-shape'
import { isMaskedPersonalNumber } from '@/lib/customers/mask-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { COUNTRY_CONSISTENCY_MESSAGES, checkCountryConsistency } from '@/lib/vat/country-codes'
export const GET = withRouteContext(
'customer.get',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const opLog = log.child({ customerId: id })
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error) {
if (error.code === 'PGRST116') {
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
}
opLog.error('customer fetch failed', error)
return errorResponseFromCode('INTERNAL_ERROR', opLog, {
requestId,
details: { reason: getUserErrorMessage(error) },
})
}
const { data: invoices } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, currency')
.eq('customer_id', id)
.eq('company_id', companyId)
.order('invoice_date', { ascending: false })
return NextResponse.json({ data: { ...maskCustomerRow(data), invoices: invoices || [] } })
},
)
export const PATCH = withRouteContext(
'customer.update',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const opLog = log.child({ customerId: id })
const result = await validateBody(request, UpdateCustomerSchema, {
log: opLog,
operation: 'customer.update',
})
if (!result.success) return result.response
const body = result.data
const { data: existing, error: existingError } = await supabase
.from('customers')
.select('id, customer_type, country, vat_number')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (existingError || !existing) {
if (existingError?.code === 'PGRST116') {
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
}
opLog.error('customer lookup before update failed', existingError)
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId })
}
// No ordinary read returns the stored personnummer, only '********-1234',
// or '********-????' when the stored value could not be decrypted. A
// client that PATCHes back a customer it just read therefore submits one
// of those, and it counts as "field not supplied": it carries no new
// value, so it must not be validated, stored or treated as a clear.
// CustomerForm strips it before sending, but the guard belongs here too:
// any other client (script, agent, future UI) that skips it would
// otherwise destroy the value.
//
// Both forms are recognized via lib/customers/mask-personal-number.ts so
// this route, UpdateCustomerSchema and the form cannot disagree about what
// counts as a mask. They previously each carried their own '-1234'-only
// copy, which made an undecryptable row uneditable in every field, not
// just this one.
const personalNumberSubmitted =
body.personal_number !== undefined && !isMaskedPersonalNumber(body.personal_number)
const effectiveType = body.customer_type ?? existing.customer_type
if (personalNumberSubmitted && body.personal_number && effectiveType !== 'individual') {
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId })
}
// GDPR art. 5.1 c: only customer_type='individual' rows get their
// identifiers masked, so a personnummer accepted as a business
// org_number would be displayed unmasked everywhere.
if (
body.org_number &&
effectiveType !== 'individual' &&
looksLikeSwedishPersonalNumber(body.org_number)
) {
return errorResponseFromCode('CUSTOMER_ORG_NUMBER_IS_PERSONAL', opLog, { requestId })
}
// The mirror image for individuals: a personnummer submitted as
// org_number is the personnummer in the wrong field. It is stored
// encrypted in personal_number and org_number is cleared, same as
// CreateCustomerSchema does on create. Next to a DIFFERENT plaintext
// personal_number in the same body the two conflict.
const reroutedPersonalNumber = orgNumberHoldsPersonalNumber(effectiveType, body.org_number)
? normalizeReroutedPersonalNumber(body.org_number!)
: null
if (
reroutedPersonalNumber
&& personalNumberSubmitted
&& body.personal_number
&& personalNumberDigits(body.personal_number) !== personalNumberDigits(reroutedPersonalNumber)
) {
return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_CONFLICT', opLog, { requestId })
}
// Country vs type vs VAT prefix on the row as it will END UP (#2025): a
// type change alone can make the stored country wrong, and a country
// change alone can contradict the stored VAT number. Judged only when
// one of the three is in the body: a legacy row that is already
// contradictory must still be able to change its email.
const countryRuleTouched =
body.customer_type !== undefined || body.country !== undefined || body.vat_number !== undefined
const countryIssue = countryRuleTouched
? checkCountryConsistency({
partyType: effectiveType,
country: body.country ?? existing.country,
vatNumber: body.vat_number ?? existing.vat_number,
})
: null
if (countryIssue) {
return errorResponseFromCode('CUSTOMER_COUNTRY_MISMATCH', opLog, {
requestId,
messageSv: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].sv,
messageEn: COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en,
details: { issue: countryIssue, field: 'country' },
})
}
const updateData: Record<string, unknown> = {}
if (body.name !== undefined) updateData.name = body.name
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
// Empty string clears the customer number, same as an explicit null.
if (body.customer_number !== undefined) updateData.customer_number = body.customer_number || null
if (body.contact_person !== undefined) updateData.contact_person = body.contact_person
if (body.email !== undefined) updateData.email = body.email
if (body.phone !== undefined) updateData.phone = body.phone
if (body.invoice_email_cc_addresses !== undefined) {
updateData.invoice_email_cc_addresses = body.invoice_email_cc_addresses
}
if (body.invoice_email_bcc_addresses !== undefined) {
updateData.invoice_email_bcc_addresses = body.invoice_email_bcc_addresses
}
if (body.address_line1 !== undefined) updateData.address_line1 = body.address_line1
if (body.address_line2 !== undefined) updateData.address_line2 = body.address_line2
if (body.postal_code !== undefined) updateData.postal_code = body.postal_code
if (body.city !== undefined) updateData.city = body.city
if (body.country !== undefined) updateData.country = body.country
if (body.org_number !== undefined) {
updateData.org_number = reroutedPersonalNumber ? null : body.org_number
}
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
if (reroutedPersonalNumber && !(personalNumberSubmitted && body.personal_number)) {
updateData.personal_number = encryptCustomerPersonalNumber(reroutedPersonalNumber)
} else if (personalNumberSubmitted) {
// Stored as ciphertext; customers_personal_number_check accepts that
// shape only (20260726110000).
updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number)
} else if (body.customer_type !== undefined && effectiveType !== 'individual') {
updateData.personal_number = null
}
if (body.language !== undefined) updateData.language = body.language
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
if (body.notes !== undefined) updateData.notes = body.notes
const { data, error } = await supabase
.from('customers')
.update(updateData)
.eq('id', id)
.eq('company_id', companyId)
.select()
.single()
if (error) {
if (error.code === 'PGRST116') {
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
}
if (error.code === '23505') {
return errorResponseFromCode('CUSTOMER_DUPLICATE_ORG_NUMBER', opLog, {
requestId,
details: { orgNumber: body.org_number },
})
}
opLog.error('customer update failed', error)
return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, {
requestId,
details: { reason: getUserErrorMessage(error) },
})
}
// Re-run VIES validation when the VAT number changes on an EU business
// customer (non-blocking).
const isEuBusiness = (body.customer_type || data.customer_type) === 'eu_business'
if (body.vat_number !== undefined && isEuBusiness) {
try {
if (body.vat_number) {
const vatResult = await validateVatNumber(body.vat_number)
const validatedAt = vatResult.valid ? new Date().toISOString() : null
await supabase
.from('customers')
.update({
vat_number_validated: vatResult.valid,
vat_number_validated_at: validatedAt,
})
.eq('id', id)
.eq('company_id', companyId)
data.vat_number_validated = vatResult.valid
data.vat_number_validated_at = validatedAt
} else {
await supabase
.from('customers')
.update({ vat_number_validated: false, vat_number_validated_at: null })
.eq('id', id)
.eq('company_id', companyId)
data.vat_number_validated = false
data.vat_number_validated_at = null
}
} catch (err) {
opLog.warn('auto-VIES validation failed on customer update', err as Error)
}
}
return NextResponse.json({ data: maskCustomerRow(data) })
},
{ requireWrite: true },
)
export const DELETE = withRouteContext(
'customer.delete',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const opLog = log.child({ customerId: id })
const { error, count } = await supabase
.from('customers')
.delete({ count: 'exact' })
.eq('id', id)
.eq('company_id', companyId)
if (error) {
if (error.code === '23503') {
return errorResponseFromCode('CUSTOMER_HAS_INVOICES', opLog, { requestId })
}
opLog.error('customer delete failed', error)
return errorResponseFromCode('CUSTOMER_DELETE_FAILED', opLog, {
requestId,
details: { reason: getUserErrorMessage(error) },
})
}
if (count === 0) {
return errorResponseFromCode('CUSTOMER_NOT_FOUND', opLog, { requestId })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)