fix(webshop-orders): shared effective-rate helper and order-context refusal for the rate-0 slot (#1912) (#2008)

* fix(webshop): share rate classification and check rate-0 order context in bulk book (#1912)

The bulk revenue template's guard copied fetchDynamicVatAccounts'
effective-rate precedence (explicit momssats > treatment > class-3
number+name inference), so the two could drift. Both now call one
exported helper, resolveEffectiveVatRate, and a sibling
resolveRevenueVatBox resolves the momsdeklaration box for a revenue
account (treatment ruta first, then the static BAS map).

The rate-0 slot also ignored order context: a domestic 0% order could be
routed to an export account (ruta 36) and vice versa, misstating rutor
35-42 with no VAT amount to catch it. The sweep now refuses, per order,
a 0% bucket whose billing country contradicts the chosen account's box:
ruta 36 vs SE or an EU country, ruta 40 vs SE, ruta 35/38/39 vs SE or a
non-EU country. Unknown country (Shopify), domestic boxes (42/41/07) and
unclassified accounts are unchanged; the domestic-account + foreign-
country direction stays advisory in the dialog.

Item 1 of the issue (require a positive momsfri/export/EU classification
for the slot) is deferred: most such accounts are unconfigured today and
the strict rule needs a configure path first (DECISIONS.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(webshop): address review findings (#1912)

- Finding 1: the rate-0 context guard keys on customer_country, which the
  WooCommerce sync stores from the billing address; the goods boxes 35/36/38
  follow the delivery destination, so a Swedish-billed order shipped outside
  the EU is a legitimate ruta 36 export the sweep refuses. Soften the
  WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH copy (sv/en) to say the check is
  based on the billing country and the account may still be right for the
  delivery address, and ask the user to confirm rather than change the
  account. Document the limitation in the route comment; storing shipping
  country in the sync is a follow-up. Test pins the new wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-28 17:22:38 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 22f0647d6c
commit 33a58bec51
8 changed files with 691 additions and 24 deletions
+7
View File
@@ -3967,6 +3967,13 @@ const WEBSHOP_ORDERS: Record<string, StructuredErrorEntry> = {
message_en:
'A chosen revenue account is not configured for the VAT rate it would receive, so the sale would drop out of ruta 05 in the VAT declaration. Set the account VAT rate in the chart of accounts (or pick an account for the right rate) and try again.',
},
WEBSHOP_ORDER_ZERO_RATE_CONTEXT_MISMATCH: {
httpStatus: 422,
message_sv:
'Ordern har en momsfri del men faktureringslandet stämmer inte med det valda 0 %-kontot (export- eller EU-konto). Kontrollen bygger på faktureringsadressen, inte leveransadressen: går varorna till ett annat land kan kontot ändå vara rätt. Bokför ordern enskilt och bekräfta kontot för ruta 35-42.',
message_en:
'The order has a 0 % part but the billing country does not match the chosen 0 % account (export or EU account). The check uses the billing address, not the delivery address: if the goods ship to another country the account may still be right. Book the order individually and confirm the account for the right box (ruta 35-42).',
},
WEBSHOP_ORDER_REVENUE_ACCOUNT_UNKNOWN: {
httpStatus: 422,
message_sv:
@@ -0,0 +1,246 @@
/**
* Tests for the shared VAT classification helpers (#1912): the effective
* rate precedence (explicit momssats > treatment > class-3 number+name
* inference) and the revenue box resolver, plus a regression that
* fetchDynamicVatAccounts still produces the same rateByAccount after
* switching to the shared helper.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const fetchAllRowsMock = vi.fn()
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: (...args: unknown[]) => fetchAllRowsMock(...args),
}))
import {
fetchDynamicVatAccounts,
resolveEffectiveVatRate,
resolveRevenueVatBox,
} from '../vat-revenue-accounts'
const supabase = {} as SupabaseClient
function row(
account_number: string,
account_name: string,
overrides: {
account_class?: number
default_vat_rate?: number | string | null
default_vat_treatment?: string | null
} = {},
) {
return {
account_number,
account_name,
account_class: overrides.account_class ?? Number(account_number.charAt(0)),
default_vat_rate: overrides.default_vat_rate ?? null,
default_vat_treatment: overrides.default_vat_treatment ?? null,
}
}
describe('resolveEffectiveVatRate', () => {
it('lets an explicit momssats win over a rate-conforming name', () => {
expect(
resolveEffectiveVatRate(
row('3041', 'Försäljning tjänster 25 % moms', { default_vat_rate: 0.06 }),
),
).toBe(0.06)
})
it('accepts a numeric-string momssats (Postgres numeric)', () => {
expect(
resolveEffectiveVatRate(row('3050', 'Försäljning', { default_vat_rate: '0.12' })),
).toBe(0.12)
})
it('lets an explicit momssats win over the treatment default', () => {
expect(
resolveEffectiveVatRate(
row('3050', 'Försäljning', {
default_vat_rate: 0.06,
default_vat_treatment: 'standard_25',
}),
),
).toBe(0.06)
})
it('falls back to the treatment default rate', () => {
expect(
resolveEffectiveVatRate(
row('3050', 'Försäljning', { default_vat_treatment: 'reduced_12' }),
),
).toBe(0.12)
expect(
resolveEffectiveVatRate(
row('3060', 'Konsultarvode utland', { default_vat_treatment: 'export_goods' }),
),
).toBe(0)
expect(
resolveEffectiveVatRate(
row('3060', 'Momsfri försäljning', { default_vat_treatment: 'exempt' }),
),
).toBe(0)
})
it('returns null for treatments without a single Swedish sats (vmb, oss)', () => {
expect(
resolveEffectiveVatRate(row('3200', 'VMB', { default_vat_treatment: 'vmb' })),
).toBeNull()
expect(
resolveEffectiveVatRate(row('3106', 'OSS', { default_vat_treatment: 'oss' })),
).toBeNull()
})
it('infers the rate from number + name for an unconfigured class 3 account', () => {
expect(
resolveEffectiveVatRate(row('3041', 'Försäljning tjänster 25 % moms')),
).toBe(0.25)
expect(
resolveEffectiveVatRate(row('3042', 'Försäljning tjänster 12 % moms')),
).toBe(0.12)
})
it('returns null for an unconfigured class 3 account without a conforming name', () => {
expect(
resolveEffectiveVatRate(row('3105', 'Försäljning varor till land utanför EU')),
).toBeNull()
expect(resolveEffectiveVatRate(row('3051', 'Försäljning tjänster'))).toBeNull()
})
it('never infers for purchase classes: only an explicit momssats counts', () => {
expect(resolveEffectiveVatRate(row('4011', 'Inköp varor 25 % moms'))).toBeNull()
expect(
resolveEffectiveVatRate(row('4011', 'Inköp varor', { default_vat_rate: 0.25 })),
).toBe(0.25)
})
it('derives the class from the account number when the row lacks it', () => {
expect(
resolveEffectiveVatRate({
account_number: '3041',
account_name: 'Försäljning tjänster 25 % moms',
default_vat_rate: null,
default_vat_treatment: null,
}),
).toBe(0.25)
expect(
resolveEffectiveVatRate({
account_number: '5010',
account_name: 'Lokalhyra',
default_vat_rate: null,
default_vat_treatment: 'reverse_charge_eu_services',
}),
).toBe(0.25)
})
})
describe('resolveRevenueVatBox', () => {
it('maps a configured treatment to its momsdeklaration box', () => {
expect(
resolveRevenueVatBox(row('3060', 'Momsfri', { default_vat_treatment: 'exempt' })),
).toBe('42')
expect(
resolveRevenueVatBox(
row('3060', 'Konsult utland', { default_vat_treatment: 'export_services' }),
),
).toBe('40')
expect(
resolveRevenueVatBox(
row('3060', 'Varor utanför EU', { default_vat_treatment: 'export_goods' }),
),
).toBe('36')
expect(
resolveRevenueVatBox(
row('3060', 'Varor EU', { default_vat_treatment: 'reverse_charge_eu_goods' }),
),
).toBe('35')
expect(
resolveRevenueVatBox(
row('3060', 'Tjänster EU', { default_vat_treatment: 'reverse_charge_eu_services' }),
),
).toBe('39')
expect(
resolveRevenueVatBox(row('3060', 'Standard', { default_vat_treatment: 'standard_25' })),
).toBe('05')
})
it('returns null for OSS (declared outside the momsdeklaration)', () => {
expect(
resolveRevenueVatBox(row('3106', 'OSS', { default_vat_treatment: 'oss' })),
).toBeNull()
})
it('lets the treatment override the static BAS box', () => {
// 3105 is statically ruta 36, but the company configured it as exempt.
expect(
resolveRevenueVatBox(row('3105', 'Export', { default_vat_treatment: 'exempt' })),
).toBe('42')
})
it('falls back to the static BAS map without a treatment', () => {
expect(resolveRevenueVatBox(row('3105', 'Export varor'))).toBe('36')
expect(resolveRevenueVatBox(row('3305', 'Export tjänster'))).toBe('40')
expect(resolveRevenueVatBox(row('3108', 'Varor EU'))).toBe('35')
expect(resolveRevenueVatBox(row('3109', 'Trepartshandel'))).toBe('38')
expect(resolveRevenueVatBox(row('3308', 'Tjänster EU'))).toBe('39')
expect(resolveRevenueVatBox(row('3004', 'Momsfri försäljning'))).toBe('42')
expect(resolveRevenueVatBox(row('3001', 'Försäljning 25 %'))).toBe('05')
})
it('returns null for an account neither the treatment nor the BAS map classifies', () => {
expect(resolveRevenueVatBox(row('3050', 'Försäljning'))).toBeNull()
expect(resolveRevenueVatBox(row('3060', 'Konsultarvode utland'))).toBeNull()
})
})
describe('fetchDynamicVatAccounts (shared helper regression)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the same rate precedence as before the helper extraction', async () => {
fetchAllRowsMock.mockResolvedValue([
// Explicit 6% wins over the "25 % moms" name.
row('3041', 'Försäljning tjänster 25 % moms', { default_vat_rate: 0.06 }),
// Unconfigured, inferred from number + name.
row('3042', 'Försäljning tjänster 12 % moms'),
// Static export account, unconfigured: no rate, never in ruta 05.
row('3105', 'Försäljning varor till land utanför EU'),
// Treatment-driven rate on a custom account.
row('3050', 'Försäljning', { default_vat_treatment: 'standard_25' }),
// Treatment with explicit rate on a purchase account: RC basis rate.
row('4056', 'Inköp tjänster EU', {
default_vat_rate: 0.12,
default_vat_treatment: 'reverse_charge_eu_services',
}),
// Class 3 without any classification: excluded.
row('3051', 'Försäljning tjänster'),
])
const result = await fetchDynamicVatAccounts(supabase, 'company-1')
expect(result.rateByAccount.get('3041')).toBe(0.06)
expect(result.rateByAccount.get('3042')).toBe(0.12)
expect(result.rateByAccount.has('3105')).toBe(false)
expect(result.rateByAccount.get('3050')).toBe(0.25)
expect(result.rateByAccount.has('3051')).toBe(false)
expect(result.rcBasisRateByAccount.get('4056')).toBe(0.12)
expect(result.explicitAccounts.has('3050')).toBe(true)
expect(result.explicitAccounts.has('3041')).toBe(false)
expect(result.mappingByAccount.get('3041')).toEqual({ box: 'ruta05', side: 'credit' })
expect(result.accounts).toEqual(['3041', '3042', '3050', '4056'])
})
it('keeps static ruta 05 accounts out of the dynamic set but records the 3000 rate', async () => {
fetchAllRowsMock.mockResolvedValue([
row('3000', 'Försäljning inom Sverige', { default_vat_rate: 0.25 }),
row('3001', 'Försäljning 25 %', { default_vat_rate: 0.25 }),
])
const result = await fetchDynamicVatAccounts(supabase, 'company-1')
expect(result.accounts).toEqual([])
expect(result.staticRateByAccount.get('3000')).toBe(0.25)
expect(result.staticRateByAccount.has('3001')).toBe(false)
expect(result.rateByAccount.size).toBe(0)
})
})
+63 -4
View File
@@ -1,6 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping'
import { ACCOUNT_TO_BOX, BOX_LABELS, type MomsBox } from '@/lib/vat/moms-box-mapping'
import {
defaultRateForVatTreatment,
isAccountVatTreatment,
@@ -34,6 +34,66 @@ export function inferDomesticSalesRate(accountNumber: string, accountName: strin
return namedRates.size === 1 && namedRates.has(expectedRate) ? expectedRate : null
}
/**
* The chart_of_accounts columns that classify an account for VAT purposes.
* account_class is optional so callers holding only the number can pass a
* row straight from a narrower select; it then falls back to the number's
* leading digit (BAS class).
*/
export interface VatAccountClassificationRow {
account_number: string
account_name: string
account_class?: number
default_vat_rate: number | string | null
default_vat_treatment: string | null
}
function accountClassOf(row: VatAccountClassificationRow): number {
return row.account_class ?? Number(row.account_number.charAt(0))
}
/**
* The single source of truth for an account's effective VAT rate, precedence
* included: an explicit momssats always wins, then the rate implied by a
* configured treatment, and number+name inference (class 3 only) when
* nothing is configured. fetchDynamicVatAccounts (ruta 05 arithmetic) and
* the webshop bulk sweep's revenue-template guard both call this, so an
* account is accepted for a rate exactly when the declaration would count
* it toward that rate (#1912). Returns null when no rate can be resolved.
*/
export function resolveEffectiveVatRate(row: VatAccountClassificationRow): number | null {
const accountClass = accountClassOf(row)
const configured = row.default_vat_rate === null ? null : Number(row.default_vat_rate)
if (isAccountVatTreatment(row.default_vat_treatment)) {
return configured ?? defaultRateForVatTreatment(row.default_vat_treatment, accountClass)
}
if (accountClass === 3) {
return configured ?? inferDomesticSalesRate(row.account_number, row.account_name)
}
return configured
}
/**
* The momsdeklaration box a revenue (class 3) account feeds: a configured
* treatment wins (its ruta, or null for OSS which is declared outside the
* momsdeklaration), otherwise the static BAS map. Null when neither
* classifies the account, which is the common case for company-specific
* momsfri/export accounts that were never configured.
*/
export function resolveRevenueVatBox(row: VatAccountClassificationRow): MomsBox | null {
if (isAccountVatTreatment(row.default_vat_treatment)) {
const mapping = resolveVatTreatmentRuta(
row.default_vat_treatment,
accountClassOf(row),
row.account_number,
)
if (!mapping) return null
const code = mapping.box.replace(/^ruta/, '')
return code in BOX_LABELS ? (code as MomsBox) : null
}
return ACCOUNT_TO_BOX[row.account_number] ?? null
}
export interface DynamicVatAccounts {
accounts: string[]
mappingByAccount: Map<string, AccountVatRutaMapping>
@@ -72,7 +132,6 @@ export async function fetchDynamicVatAccounts(
const result = emptyDynamicVatAccounts()
for (const row of rows) {
const account = row.account_number
const configuredRate = row.default_vat_rate === null ? null : Number(row.default_vat_rate)
if (isAccountVatTreatment(row.default_vat_treatment)) {
result.explicitAccounts.add(account)
@@ -87,7 +146,7 @@ export async function fetchDynamicVatAccounts(
// fetchVatAccountTotals deduplicates static BAS accounts, while this also
// covers accounts that exist only in the separate moms-box mirror.
result.accounts.push(account)
const rate = configuredRate ?? defaultRateForVatTreatment(row.default_vat_treatment, row.account_class)
const rate = resolveEffectiveVatRate(row)
if (mapping.box === 'ruta05' && rate !== null && TAXABLE_RATES.includes(rate)) {
const target = ACCOUNT_TO_BOX[account] ? result.staticRateByAccount : result.rateByAccount
target.set(account, rate)
@@ -102,7 +161,7 @@ export async function fetchDynamicVatAccounts(
}
if (row.account_class !== 3) continue
const rate = configuredRate ?? inferDomesticSalesRate(account, row.account_name)
const rate = resolveEffectiveVatRate(row)
if (rate === null || !TAXABLE_RATES.includes(rate)) continue
if (ACCOUNT_TO_BOX[account]) {
if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) {
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { EU_COUNTRIES, isEuMemberCountry } from '../eu-countries'
describe('isEuMemberCountry', () => {
it('is true for every listed member state, case insensitive', () => {
for (const country of EU_COUNTRIES) {
expect(isEuMemberCountry(country.code)).toBe(true)
expect(isEuMemberCountry(country.code.toLowerCase())).toBe(true)
}
})
it('counts Sweden as a member (callers test SE separately)', () => {
expect(isEuMemberCountry('SE')).toBe(true)
})
it('is false for non-EU countries and unknown codes', () => {
expect(isEuMemberCountry('US')).toBe(false)
expect(isEuMemberCountry('NO')).toBe(false)
expect(isEuMemberCountry('GB')).toBe(false)
expect(isEuMemberCountry('CH')).toBe(false)
expect(isEuMemberCountry('')).toBe(false)
})
})
+11
View File
@@ -49,3 +49,14 @@ export const EU_COUNTRIES: EUCountry[] = [
{ code: 'ES', name: 'Spanien', nameEn: 'Spain', vatPrefix: 'ES', currency: 'EUR' },
{ code: 'SE', name: 'Sverige', nameEn: 'Sweden', vatPrefix: 'SE', currency: 'SEK' },
]
const EU_COUNTRY_CODES = new Set(EU_COUNTRIES.map((c) => c.code))
/**
* True when the ISO 3166-1 alpha-2 code is an EU member state. Case
* insensitive. Sweden (SE) counts as a member: callers that need "another
* EU country" must test SE separately first.
*/
export function isEuMemberCountry(code: string): boolean {
return EU_COUNTRY_CODES.has(code.toUpperCase())
}