Files
accounted/components/invoices/line-vat-rates.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

143 lines
5.6 KiB
TypeScript

import {
getAvailableVatRates,
getPermittedVatRates,
type VatRateOption,
} from '@/lib/invoices/vat-rules'
import type { CustomerType } from '@/types'
/**
* The invoice editor's per-line Moms picker derives FOUR different behaviours
* from the customer's VAT rules, and they must not share one set of rates:
*
* - which options are RENDERED → getPermittedVatRates() (the lawful set)
* - what a new line STARTS on → getAvailableVatRates() (the default)
* - whether an article's rate is ADOPTED → the default set
* - what a customer switch SNAPS to → the default
*
* Under huvudregeln (ML 6 kap. 34 §, Article 44 VAT Directive) a B2B service is
* taxed where the buyer is established, so 0% (reverse charge for a VAT-
* validated EU business, export outside the EU) is the right DEFAULT for a
* foreign business customer. It is not the only lawful rate: the ML 6 kap.
* exceptions taxed where the supply is performed (fastighetstjänster 25%,
* persontransporter 6%, korttidsuthyrning of transport vehicles 25%,
* restaurang/catering 12%, admission to cultural and sports events 6%) carry
* Swedish VAT even when the buyer is a German or a US company. A Stockholm
* hotel night sold to a German company is such a supply, so the picker has to
* OFFER 12% while still DEFAULTING to 0%.
*
* Kept in a plain module (no JSX, no hooks) so the rules are unit-testable:
* the repo does not render components in tests.
*/
/** The minimum this module needs to know about the picked customer. */
export interface VatRateCustomer {
customer_type: CustomerType
vat_number_validated?: boolean | null
/** ISO 3166-1 alpha-2; gates reverse charge together with the two above. */
country?: string | null
}
/** One invoice line as the editor's form holds it. */
export interface VatRateLine {
vat_rate?: number | null
line_type?: 'product' | 'text' | null
}
export interface LineVatRatePlan {
/** Rendered picker options: the PERMITTED set. 0% stays first. */
options: VatRateOption[]
/** The DEFAULT set. One locked 0% option for a foreign business customer. */
defaultRates: VatRateOption[]
/** The rate a new line starts on and a customer switch snaps to. */
defaultRate: number
/** True when the DEFAULT set holds exactly one rate (foreign business, 0%). */
hasSingleDefault: boolean
/** True only when there is genuinely nothing to choose between. */
isPickerLocked: boolean
}
/**
* The rate the empty form's first line carries, before any customer is picked.
* Mirrors the editor's defaultValues.
*/
export const FALLBACK_VAT_RATE = 25
export function resolveLineVatRates(
customer: VatRateCustomer | null | undefined,
): LineVatRatePlan {
if (!customer) {
return {
options: [],
defaultRates: [],
defaultRate: FALLBACK_VAT_RATE,
hasSingleDefault: false,
isPickerLocked: false,
}
}
const validated = customer.vat_number_validated ?? false
const defaultRates = getAvailableVatRates(customer.customer_type, validated, customer.country)
const options = getPermittedVatRates(customer.customer_type, validated, customer.country)
return {
options,
defaultRates,
defaultRate: defaultRates[0]?.rate ?? FALLBACK_VAT_RATE,
hasSingleDefault: defaultRates.length === 1,
isPickerLocked: options.length === 1,
}
}
/**
* Which lines a customer switch may move onto the new customer's default rate.
*
* Only lines still sitting on the PREVIOUS customer's default are moved: those
* were never chosen, they were inherited, so leaving them behind is what
* produced the two stale-rate bugs (25% surviving onto a reverse-charge
* invoice, 0% surviving onto a domestic one).
*
* A line the user moved off that default is a deliberate choice and is left
* alone. Snapping every line was the older behaviour and it destroyed the
* lawful case this whole rule exists for: a 12% hotel night or a 6% event
* ticket sold to a foreign business, taxed where the supply is performed.
* Nothing on a line distinguishes those from ordinary consulting, so the
* user's explicit rate is the only signal there is, and overwriting it would
* be guessing.
*
* Free-text rows (line_type 'text') carry no amounts and never book; they are
* left untouched so a switch cannot dirty the form with meaningless rates.
*/
export function planCustomerSwitchVatSnap(params: {
items: VatRateLine[]
previousDefaultRate: number
nextDefaultRate: number
}): Array<{ index: number; rate: number }> {
const { items, previousDefaultRate, nextDefaultRate } = params
if (previousDefaultRate === nextDefaultRate) return []
const snaps: Array<{ index: number; rate: number }> = []
items.forEach((item, index) => {
if (item?.line_type === 'text') return
// An absent rate falls back to the old default, exactly like the server
// does (item.vat_rate ?? getVatRules().rate), so such a line still follows.
const current = item?.vat_rate ?? previousDefaultRate
if (current !== previousDefaultRate) return
snaps.push({ index, rate: nextDefaultRate })
})
return snaps
}
/**
* True when a Swedish rate is actually selected on an invoice whose customer
* defaults to 0% (validated EU business or non-EU business). That is lawful
* only for the supplies taxed where they are performed, so it earns the single
* ochre attention sentence. Silent otherwise: 0% is the normal case and needs
* no commentary.
*/
export function hasSwedishVatToForeignBusiness(params: {
plan: LineVatRatePlan
items: VatRateLine[]
}): boolean {
const { plan, items } = params
if (!plan.hasSingleDefault || plan.defaultRate !== 0) return false
return items.some((item) => item?.line_type !== 'text' && (item?.vat_rate ?? 0) > 0)
}