Files
accounted/app/api/customers/[id]/route.ts
T
e6c4fe2cf8 fix(customers): personnummer guard + personal_number on v1 + payment terms from settings (#1724)
* fix(customers): stop personnummer landing unmasked as org_number, persist personal_number on v1, default payment terms from settings

Closes #1707. Closes #1708.

Personnummer (#1707, Discord kalletoxic):
- CreateCustomerSchema rejects an org_number shaped like a Swedish
  personal identity number on business customer_types. Only
  customer_type=individual rows are masked in lists, so accepting one
  stored an unmasked personal identifier (GDPR art. 5.1 c). The shape
  check uses the month-position rule (legal-entity orgnr always
  carries >= 20), so real orgnr can never false-positive.
- The v1 create, v1 PATCH and bulk-create endpoints accepted
  personal_number through the shared schema but silently dropped it.
  They now store it encrypted, expose it masked (********-1234) on the
  single-customer surfaces, and treat the masked form as unchanged,
  mirroring the internal routes.
- Route-level guards on both PATCH routes (new 400
  CUSTOMER_ORG_NUMBER_IS_PERSONAL) plus a client-side message in
  CustomerForm (sv + en).

Payment terms (#1708, Discord kalletoxic):
- New resolveDefaultPaymentTerms: provided value, else
  company_settings.invoice_default_days, else 30. Wired into the UI
  new-customer dialog, the internal POST, v1 create (incl. dry-run),
  bulk-create and the MCP staged create_customer.

apiskill regenerated; no migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record what the CI build OOM actually was

main raised the build heap to 8192 in parallel with this branch, so the
fix itself is already in and this keeps it untouched. What was missing
is the diagnosis.

Measured with tsc --noEmit --extendedDiagnostics, type-checking the repo
needs 4 192 550 K at 506d030b and 4 187 096 K on this branch, 5 MB less
and 0.26% more instantiations. So the ceiling is the type-check pass at
steady state against Node 20's ~4 GB default old-space, not bundle
growth and not any single PR. Worth writing down so the next person who
sees "Ineffective mark-compacts near heap limit" does not go looking for
it in their own diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:41:24 +02:00

236 lines
9.5 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 } from '@/lib/customers/personal-number-shape'
import { isMaskedPersonalNumber } from '@/lib/customers/mask-personal-number'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
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')
.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 })
}
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 = body.org_number
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
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 },
)