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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
80b87c55fc
commit
3918ff6620
@@ -129,7 +129,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
|
||||
// 10% is not a Swedish momssats (ML 9 kap: 25 / 12 / 6) for any customer.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 10 }],
|
||||
@@ -149,7 +149,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
|
||||
// Huvudregeln (ML 6 kap. 34 §): taxed where the buyer is established.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Konsult', quantity: 10, unit: 'tim', unit_price: 1000, vat_rate: 0 }],
|
||||
@@ -170,7 +170,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
|
||||
// Widening the permitted set must not change the default: an omitted
|
||||
// vat_rate still falls back to getVatRules().rate === 0.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000 }],
|
||||
@@ -190,7 +190,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
// Stockholm hotel night invoiced to a German company. Restaurang/hotell is
|
||||
// taxed where performed (ML 6 kap. exception), so Swedish 12% applies even
|
||||
// though the buyer is an EU business. This was refused outright before.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Hotellnatt Stockholm', quantity: 2, unit: 'natt', unit_price: 1000, vat_rate: 12 }],
|
||||
@@ -254,7 +254,7 @@ describe('buildInvoiceWriteData', () => {
|
||||
// 0% consulting (huvudregeln, reverse charge) + 12% hotel (taxed where
|
||||
// performed) on one invoice. The buyer IS liable for the consulting line,
|
||||
// so the notation is required; the 12% line still carries Swedish VAT.
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', vat_number_validated: true })
|
||||
const customer = makeCustomer({ customer_type: 'eu_business', country: 'DE', vat_number: 'DE811234567', vat_number_validated: true })
|
||||
const result = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The customer's country gates reverse charge (#2025). An eu_business row
|
||||
* with a validated German VAT number but country SE used to get 0% here,
|
||||
* and the periodisk sammanställning was the first thing to object, after
|
||||
* the invoice had been sent and booked on 3308.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
getArticleVatRateAdoptionSet,
|
||||
getAvailableVatRates,
|
||||
getPermittedVatRates,
|
||||
getVatRules,
|
||||
isReverseChargeCustomer,
|
||||
} from '../vat-rules'
|
||||
|
||||
describe('reverse charge requires an EU country other than Sweden', () => {
|
||||
it('grants reverse charge to a validated EU business in another EU country', () => {
|
||||
expect(isReverseChargeCustomer('eu_business', true, 'DE')).toBe(true)
|
||||
expect(getVatRules('eu_business', true, 'DE')).toMatchObject({ treatment: 'reverse_charge', rate: 0, momsRuta: '39' })
|
||||
expect(getAvailableVatRates('eu_business', true, 'DE').map((r) => r.treatment)).toEqual(['reverse_charge'])
|
||||
expect(getPermittedVatRates('eu_business', true, 'DE')[0].treatment).toBe('reverse_charge')
|
||||
expect(getArticleVatRateAdoptionSet('eu_business', true, 'DE').size).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses reverse charge when the country is Sweden (#2025)', () => {
|
||||
expect(isReverseChargeCustomer('eu_business', true, 'SE')).toBe(false)
|
||||
expect(getVatRules('eu_business', true, 'SE')).toMatchObject({ treatment: 'standard_25', rate: 25, momsRuta: '05' })
|
||||
expect(getAvailableVatRates('eu_business', true, 'SE').map((r) => r.rate)).toEqual([25, 12, 6, 0])
|
||||
// The permitted set is the domestic set, not "0% first plus the Swedish rates".
|
||||
expect(getPermittedVatRates('eu_business', true, 'SE').map((r) => r.treatment)).toEqual([
|
||||
'standard_25',
|
||||
'reduced_12',
|
||||
'reduced_6',
|
||||
'exempt',
|
||||
])
|
||||
expect(getArticleVatRateAdoptionSet('eu_business', true, 'SE')).toEqual(new Set([25, 12, 6, 0]))
|
||||
})
|
||||
|
||||
it('keeps reverse charge for a validated number with a non-EU address', () => {
|
||||
// The VIES-validated number is the stronger evidence of an EU
|
||||
// registration: a Swiss company registered in Germany, Monaco,
|
||||
// Northern Ireland. Only Sweden refuses.
|
||||
expect(getVatRules('eu_business', true, 'CH').treatment).toBe('reverse_charge')
|
||||
expect(getVatRules('eu_business', true, 'MC').treatment).toBe('reverse_charge')
|
||||
expect(getVatRules('eu_business', true, 'GB').treatment).toBe('reverse_charge')
|
||||
})
|
||||
|
||||
it('reads a legacy country name the same way as its code', () => {
|
||||
expect(getVatRules('eu_business', true, 'Sweden').treatment).toBe('standard_25')
|
||||
expect(getVatRules('eu_business', true, 'Germany').treatment).toBe('reverse_charge')
|
||||
})
|
||||
|
||||
it('keeps the pre-2026-09 behaviour when the country is unknown', () => {
|
||||
expect(getVatRules('eu_business', true).treatment).toBe('reverse_charge')
|
||||
expect(getVatRules('eu_business', true, null).treatment).toBe('reverse_charge')
|
||||
expect(getVatRules('eu_business', true, 'Deutschland (Bayern)').treatment).toBe('reverse_charge')
|
||||
})
|
||||
|
||||
it('does not touch export or domestic customers', () => {
|
||||
expect(getVatRules('non_eu_business', false, 'SE').treatment).toBe('export')
|
||||
expect(getVatRules('swedish_business', true, 'DE').treatment).toBe('standard_25')
|
||||
expect(getVatRules('eu_business', false, 'DE').treatment).toBe('standard_25')
|
||||
})
|
||||
})
|
||||
@@ -186,7 +186,7 @@ export async function buildInvoiceWriteData(params: {
|
||||
const { supabase, companyId, customer, documentType, input, existingPersonnummer } = params
|
||||
const items = input.items
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
// Gate on the PERMITTED set, not the picker default. Under huvudregeln
|
||||
// (ML 6 kap. 34 §) a service to a foreign business is taxed where the buyer
|
||||
// is established, so 0% is the default; but the ML 6 kap. exceptions taxed
|
||||
@@ -196,7 +196,7 @@ export async function buildInvoiceWriteData(params: {
|
||||
// Refusing every non-zero rate made a Stockholm hotel night or a conference
|
||||
// ticket impossible to invoice. The default is still 0% (vatRules.rate is
|
||||
// the fallback below), so a Swedish rate only lands here when set explicitly.
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
// VAT registration gate (defense in depth: the invoice form already hides
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CUSTOM_INVOICE_FONT_RENDER_PREFIX } from '@/lib/invoices/pdf-fonts'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { isTextLikeLine } from '@/lib/invoices/display'
|
||||
import { maskedDeductionPersonnummer } from '@/lib/invoices/deduction-personnummer'
|
||||
import { getCountryName } from '@/lib/vat/country-codes'
|
||||
|
||||
type PdfLang = 'sv' | 'en'
|
||||
|
||||
@@ -1007,7 +1008,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text>{customer.postal_code} {customer.city}</Text>
|
||||
)}
|
||||
{customer.country && customer.country !== 'SE' && (
|
||||
<Text>{customer.country}</Text>
|
||||
<Text>{getCountryName(customer.country, lang)}</Text>
|
||||
)}
|
||||
{/* Seller-assigned kundnummer: no per-customer-type guard needed,
|
||||
it identifies the customer in the seller's own register and
|
||||
|
||||
@@ -264,7 +264,7 @@ export async function executeRecurringSchedule(
|
||||
throw new Error(`customer not found for schedule ${schedule.id}`)
|
||||
}
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
// Gate on the PERMITTED set, not the picker default, exactly like
|
||||
// buildInvoiceWriteData: the ML 6 kap. supplies taxed where they are performed
|
||||
// (hotel/restaurang 12%, persontransport and event admission 6%,
|
||||
@@ -272,7 +272,7 @@ export async function executeRecurringSchedule(
|
||||
// foreign business customer. A monthly hotel or catering retainer to a German
|
||||
// company is such a schedule. The default is still 0% (vatRules.rate is the
|
||||
// fallback below), so a Swedish rate only lands here when the schedule set it.
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated, customer.country)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
// 2. Compute amounts (mirrors POST /api/invoices).
|
||||
|
||||
@@ -59,6 +59,8 @@ interface SelfBilledCustomer {
|
||||
name: string
|
||||
customer_type: string
|
||||
vat_number_validated: boolean | null
|
||||
/** ISO 3166-1 alpha-2; gates reverse charge together with the two above. */
|
||||
country?: string | null
|
||||
}
|
||||
|
||||
export interface SelfBilledSaleComputedItem {
|
||||
@@ -104,7 +106,7 @@ export async function resolveSelfBilledSaleDraft(
|
||||
// the fields used (data minimisation).
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, customer_type, vat_number_validated')
|
||||
.select('id, name, customer_type, vat_number_validated, country')
|
||||
.eq('id', input.customer_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
@@ -119,6 +121,7 @@ export async function resolveSelfBilledSaleDraft(
|
||||
const vatRules = getVatRules(
|
||||
c.customer_type as Parameters<typeof getVatRules>[0],
|
||||
c.vat_number_validated ?? undefined,
|
||||
c.country,
|
||||
)
|
||||
// Gate on the PERMITTED set, not the picker default, exactly like
|
||||
// buildInvoiceWriteData: the ML 6 kap. supplies taxed where they are performed
|
||||
@@ -130,6 +133,7 @@ export async function resolveSelfBilledSaleDraft(
|
||||
const permittedRates = getPermittedVatRates(
|
||||
c.customer_type as Parameters<typeof getPermittedVatRates>[0],
|
||||
c.vat_number_validated ?? undefined,
|
||||
c.country,
|
||||
)
|
||||
const allowedRates = new Set(permittedRates.map((r) => r.rate))
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomerType, VatTreatment } from '@/types'
|
||||
import { countryPermitsReverseCharge } from '@/lib/vat/country-codes'
|
||||
|
||||
export interface VatRateOption {
|
||||
rate: number
|
||||
@@ -6,6 +7,25 @@ export interface VatRateOption {
|
||||
treatment: VatTreatment
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse charge (0%, ruta 39) needs all three: EU-business type, a VIES-
|
||||
* validated VAT number, and a country other than Sweden (a buyer
|
||||
* established here owes Swedish VAT whatever foreign number it holds).
|
||||
* `country` undefined means the caller did not have it, which keeps the
|
||||
* pre-2026-09 behaviour (type + validation only).
|
||||
*/
|
||||
export function isReverseChargeCustomer(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
customerType === 'eu_business' &&
|
||||
vatNumberValidated &&
|
||||
countryPermitsReverseCharge(country)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the DEFAULT VAT rates offered for invoice line items, per customer type.
|
||||
*
|
||||
@@ -24,13 +44,20 @@ export interface VatRateOption {
|
||||
* form hides the Moms column entirely when company_settings.vat_registered is
|
||||
* false, and both the create route and the MCP commit force every line to 0%
|
||||
* (momsfri) server-side, so a non-momsregistrerad company never books output VAT.
|
||||
*
|
||||
* `country` is the customer's ISO 3166-1 alpha-2 country. Reverse charge is
|
||||
* refused when it is SE (see countryPermitsReverseCharge). An eu_business
|
||||
* row with a validated German VAT number but country SE used to get 0% here
|
||||
* and only be caught by the periodisk sammanställning after the invoice was
|
||||
* sent (#2025).
|
||||
*/
|
||||
export function getAvailableVatRates(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): VatRateOption[] {
|
||||
// EU business with validated VAT → reverse charge, locked to 0%
|
||||
if (customerType === 'eu_business' && vatNumberValidated) {
|
||||
if (isReverseChargeCustomer(customerType, vatNumberValidated, country)) {
|
||||
return [{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }]
|
||||
}
|
||||
|
||||
@@ -61,8 +88,9 @@ export function getAvailableVatRates(
|
||||
export function getArticleVatRateAdoptionSet(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): ReadonlySet<number> {
|
||||
const offered = getAvailableVatRates(customerType, vatNumberValidated)
|
||||
const offered = getAvailableVatRates(customerType, vatNumberValidated, country)
|
||||
return new Set(offered.length > 1 ? offered.map((r) => r.rate) : [])
|
||||
}
|
||||
|
||||
@@ -102,12 +130,13 @@ export function getArticleVatRateAdoptionSet(
|
||||
export function getPermittedVatRates(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): VatRateOption[] {
|
||||
const offered = getAvailableVatRates(customerType, vatNumberValidated)
|
||||
const offered = getAvailableVatRates(customerType, vatNumberValidated, country)
|
||||
|
||||
const isForeignBusiness =
|
||||
customerType === 'non_eu_business' ||
|
||||
(customerType === 'eu_business' && vatNumberValidated)
|
||||
isReverseChargeCustomer(customerType, vatNumberValidated, country)
|
||||
if (!isForeignBusiness) {
|
||||
return offered
|
||||
}
|
||||
@@ -152,8 +181,8 @@ export interface VatRule {
|
||||
*
|
||||
* Rules:
|
||||
* - Swedish customers: 25% VAT, moms ruta 05
|
||||
* - EU business with validated VAT: 0% reverse charge, moms ruta 39
|
||||
* - EU business without validated VAT: 25% VAT, moms ruta 05
|
||||
* - EU business with validated VAT and a country other than SE: 0% reverse charge, moms ruta 39
|
||||
* - EU business without validated VAT, or with country SE: 25% VAT, moms ruta 05
|
||||
* - Non-EU business: 0% export, moms ruta 40
|
||||
*
|
||||
* Independent of the seller's VAT registration status. A non-momsregistrerad
|
||||
@@ -163,6 +192,7 @@ export interface VatRule {
|
||||
export function getVatRules(
|
||||
customerType: CustomerType,
|
||||
vatNumberValidated: boolean = false,
|
||||
country?: string | null,
|
||||
): VatRule {
|
||||
switch (customerType) {
|
||||
case 'individual':
|
||||
@@ -174,7 +204,7 @@ export function getVatRules(
|
||||
}
|
||||
|
||||
case 'eu_business':
|
||||
if (vatNumberValidated) {
|
||||
if (isReverseChargeCustomer(customerType, vatNumberValidated, country)) {
|
||||
return {
|
||||
treatment: 'reverse_charge',
|
||||
rate: 0,
|
||||
@@ -182,7 +212,8 @@ export function getVatRules(
|
||||
reverseChargeText: 'Omvänd skattskyldighet / Reverse charge - VAT to be accounted for by the recipient as per Article 196, Council Directive 2006/112/EC',
|
||||
}
|
||||
}
|
||||
// EU business without validated VAT number must be charged Swedish VAT
|
||||
// EU business without validated VAT number, or one whose country is
|
||||
// Sweden, must be charged Swedish VAT
|
||||
return {
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
|
||||
Reference in New Issue
Block a user