Files
accounted/lib/reports/__tests__/periodisk-sammanstallning.test.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

489 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock: sequential result queue (mirrors vat-declaration.test.ts)
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
import {
generatePeriodiskSammanstallning,
normalizeVatNumber,
reconcilePsAgainstVatDeclaration,
} from '../periodisk-sammanstallning'
import { calculatePeriodDates, formatPeriodLabel } from '../period-dates'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
// ============================================================
// Pure helpers
// ============================================================
describe('calculatePeriodDates', () => {
it('monthly January', () => {
expect(calculatePeriodDates('monthly', 2025, 1)).toEqual({ start: '2025-01-01', end: '2025-01-31' })
})
it('monthly December', () => {
expect(calculatePeriodDates('monthly', 2025, 12)).toEqual({ start: '2025-12-01', end: '2025-12-31' })
})
it('quarterly Q2', () => {
expect(calculatePeriodDates('quarterly', 2025, 2)).toEqual({ start: '2025-04-01', end: '2025-06-30' })
})
it('quarterly Q4', () => {
expect(calculatePeriodDates('quarterly', 2025, 4)).toEqual({ start: '2025-10-01', end: '2025-12-31' })
})
})
describe('formatPeriodLabel', () => {
it('monthly', () => expect(formatPeriodLabel('monthly', 2025, 5)).toBe('Maj 2025'))
it('quarterly', () => expect(formatPeriodLabel('quarterly', 2025, 2)).toBe('Kvartal 2 2025'))
})
describe('normalizeVatNumber', () => {
it('strips Swedish country prefix', () => {
expect(normalizeVatNumber('SE556677889901')).toBe('556677889901')
})
it('strips whitespace and uppercases', () => {
expect(normalizeVatNumber(' de 123456789 ')).toBe('123456789')
})
it('handles EL prefix', () => {
expect(normalizeVatNumber('EL123456789')).toBe('123456789')
})
it('handles already-stripped numbers', () => {
expect(normalizeVatNumber('556677889901')).toBe('556677889901')
})
it('handles null/empty', () => {
expect(normalizeVatNumber(null)).toBe('')
expect(normalizeVatNumber('')).toBe('')
})
})
// ============================================================
// Generator
// ============================================================
interface InvoiceFx {
id: string
customer: {
id: string
name: string
country: string | null
vat_number: string | null
vat_number_validated?: boolean
vat_number_validated_at?: string | null
} | null
}
// Recent validation so VIES_UNVALIDATED warnings don't fire by default.
const RECENT = new Date().toISOString()
// The generator fetches lines via the two-step entry-lines helper
// (lib/bookkeeping/entry-lines.ts): journal_entries first, then
// journal_entry_lines by entry id with the parent reattached under
// `journal_entries`. Each fixture invoice gets one entry (je-<sourceId>).
function je(sourceId: string) {
return `je-${sourceId}`
}
function entryEU(sourceId: string) {
return {
id: je(sourceId),
company_id: 'c1',
entry_date: '2025-05-15',
status: 'posted',
source_type: 'invoice_created',
source_id: sourceId,
}
}
function entryCredit(sourceId: string) {
return {
id: je(sourceId),
company_id: 'c1',
entry_date: '2025-05-20',
status: 'posted',
source_type: 'credit_note',
source_id: sourceId,
}
}
function lineEU(account: string, credit: number, sourceId: string) {
return {
account_number: account,
debit_amount: 0,
credit_amount: credit,
journal_entry_id: je(sourceId),
}
}
function lineCredit(account: string, debit: number, sourceId: string) {
return {
account_number: account,
debit_amount: debit,
credit_amount: 0,
journal_entry_id: je(sourceId),
}
}
function invDE(id = 'inv-de', customer = 'cust-de', name = 'DE Customer', vat = 'DE123456789'): InvoiceFx {
return {
id,
customer: {
id: customer,
name,
country: 'DE',
vat_number: vat,
vat_number_validated: true,
vat_number_validated_at: RECENT,
},
}
}
describe('generatePeriodiskSammanstallning', () => {
it('empty period returns zero rows and zero warnings', async () => {
// journal_entries: none match → the line fetch is skipped entirely.
results = [{ data: [], error: null }]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toEqual([])
expect(report.warnings).toEqual([])
expect(report.totals.rowCount).toBe(0)
expect(report.totals.grand).toBe(0)
expect(report.period.label).toBe('Maj 2025')
})
it('single EU service sale → 1 row, type 3 only', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv-de')], error: null },
{ data: [lineEU('3308', 10000, 'inv-de')], error: null },
{ data: [invDE()], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({
country: 'DE',
vatNumber: '123456789',
services: 10000,
goods: 0,
triangulation: 0,
})
expect(report.totals).toMatchObject({ services: 10000, goods: 0, triangulation: 0, grand: 10000, rowCount: 1 })
expect(report.warnings).toEqual([])
})
it('aggregates multiple invoices to same customer', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1'), entryEU('inv2'), entryEU('inv3')], error: null },
{
data: [
lineEU('3308', 4000, 'inv1'),
lineEU('3308', 3500, 'inv2'),
lineEU('3308', 2500, 'inv3'),
],
error: null,
},
{
data: [
{ ...invDE('inv1') },
{ ...invDE('inv2') },
{ ...invDE('inv3') },
],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0].services).toBe(10000)
})
it('one customer with both services and goods → 1 row with both filled', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1'), entryEU('inv2')], error: null },
{
data: [
lineEU('3308', 7000, 'inv1'),
lineEU('3108', 5000, 'inv2'),
],
error: null,
},
{
data: [invDE('inv1'), invDE('inv2')],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({ services: 7000, goods: 5000, triangulation: 0 })
})
it('credit invoice nets against original in same period', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1'), entryCredit('cn1')], error: null },
{
data: [
lineEU('3308', 10000, 'inv1'),
lineCredit('3308', 3000, 'cn1'),
],
error: null,
},
{
data: [invDE('inv1'), invDE('cn1')],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(1)
expect(report.rows[0].services).toBe(7000)
})
it('credit fully cancels → row excluded with ZERO_NET_EXCLUDED warning', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1'), entryCredit('cn1')], error: null },
{
data: [
lineEU('3308', 10000, 'inv1'),
lineCredit('3308', 10000, 'cn1'),
],
error: null,
},
{ data: [invDE('inv1'), invDE('cn1')], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toHaveLength(0)
expect(report.warnings.some(w => w.code === 'ZERO_NET_EXCLUDED')).toBe(true)
})
it('customer missing country → MISSING_COUNTRY error and row blocked', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1')], error: null },
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'No Country', country: null, vat_number: 'DE123', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'MISSING_COUNTRY' && w.level === 'error')).toBe(true)
expect(report.rows[0]?.hasBlockingIssue).toBe(true)
})
it('customer missing vat_number → MISSING_VAT_NUMBER error', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1')], error: null },
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'No VAT', country: 'DE', vat_number: null, vat_number_validated: false, vat_number_validated_at: null },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'MISSING_VAT_NUMBER' && w.level === 'error')).toBe(true)
expect(report.rows[0]?.hasBlockingIssue).toBe(true)
})
it('VAT prefix mismatch surfaces COUNTRY_PREFIX_MISMATCH warning', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1')], error: null },
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'Mixed', country: 'DE', vat_number: 'FR123456', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'COUNTRY_PREFIX_MISMATCH')).toBe(true)
expect(report.rows).toHaveLength(1)
})
it('non-EU country on EU account → NON_EU_COUNTRY_ON_EU_ACCOUNT and excluded from CSV', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1')], error: null },
{ data: [lineEU('3308', 5000, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'US Co', country: 'US', vat_number: 'US123', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.some(w => w.code === 'NON_EU_COUNTRY_ON_EU_ACCOUNT')).toBe(true)
expect(report.rows[0].hasBlockingIssue).toBe(true)
})
it('Greek customer → country code emitted as EL', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1')], error: null },
{ data: [lineEU('3308', 4200, 'inv1')], error: null },
{
data: [{
id: 'inv1',
customer: { id: 'c1', name: 'Hellas', country: 'GR', vat_number: 'EL123456', vat_number_validated: true, vat_number_validated_at: RECENT },
}],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows[0].country).toBe('EL')
})
it('goods sold in quarterly period → GOODS_SOLD_WITH_QUARTERLY_PERIOD warning', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv1')], error: null },
{ data: [lineEU('3108', 9000, 'inv1')], error: null },
{ data: [{ ...invDE('inv1') }], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'quarterly', 2025, 2)
expect(report.warnings.some(w => w.code === 'GOODS_SOLD_WITH_QUARTERLY_PERIOD')).toBe(true)
})
it('sorts rows by country then vat_number', async () => {
results = [
// journal_entries page for the two-step entry-lines fetch
{ data: [entryEU('inv-fr'), entryEU('inv-de'), entryEU('inv-at')], error: null },
{
data: [
lineEU('3308', 1000, 'inv-fr'),
lineEU('3308', 2000, 'inv-de'),
lineEU('3308', 3000, 'inv-at'),
],
error: null,
},
{
data: [
{ id: 'inv-fr', customer: { id: 'fr', name: 'FR', country: 'FR', vat_number: 'FR999', vat_number_validated: true, vat_number_validated_at: RECENT } },
{ id: 'inv-de', customer: { id: 'de', name: 'DE', country: 'DE', vat_number: 'DE888', vat_number_validated: true, vat_number_validated_at: RECENT } },
{ id: 'inv-at', customer: { id: 'at', name: 'AT', country: 'AT', vat_number: 'ATU111', vat_number_validated: true, vat_number_validated_at: RECENT } },
],
error: null,
},
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows.map(r => r.country)).toEqual(['AT', 'DE', 'FR'])
})
it('rejects yearly period type', async () => {
await expect(
generatePeriodiskSammanstallning(supabase, 'c1', 'yearly' as 'monthly', 2025, 1),
).rejects.toThrow()
})
})
// ============================================================
// Reconciliation
// ============================================================
describe('reconcilePsAgainstVatDeclaration', () => {
it('returns null matches when periods do not coincide', async () => {
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'quarterly', 2025, 2)
// No data calls expected: function bails before invoking calculateVatDeclaration.
results = []
const reconciled = await reconcilePsAgainstVatDeclaration(supabase, 'c1', report, 'monthly')
expect(reconciled.reconciliation.matches).toBeNull()
})
})
describe('legacy country names on customers (#2028)', () => {
it('reads a stored country name as its ISO code: no false warnings, correct CSV country', async () => {
const legacy = invDE()
legacy.customer!.country = 'Germany'
results = [
{ data: [entryEU('inv-de')], error: null },
{ data: [lineEU('3308', 15000, 'inv-de')], error: null },
{ data: [legacy], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings).toEqual([])
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: 15000 })
})
it('still names an unmapped country in the warning', async () => {
const legacy = invDE()
legacy.customer!.country = 'Atlantis'
results = [
{ data: [entryEU('inv-de')], error: null },
{ data: [lineEU('3308', 15000, 'inv-de')], error: null },
{ data: [legacy], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings.map((w) => w.code)).toContain('NON_EU_COUNTRY_ON_EU_ACCOUNT')
expect(report.warnings.find((w) => w.code === 'NON_EU_COUNTRY_ON_EU_ACCOUNT')?.message).toContain('ATLANTIS')
})
})