fix(vat): veto the ruta 05 rate fallback on a contradicting account label (#1310)

* fix(vat): veto the ruta 05 rate fallback on a contradicting account label

The read-path fallback for #1289 shipped in #1296: fetchDynamicRuta05Accounts
drops the SQL sats filter and inferDomesticSalesRate resolves a NULL
default_vat_rate from the 30x1/30x2/30x3 suffix plus a matching
"25/12/6 % moms" label. That half is already on main and is not touched here.

What it lacks is a veto. Both signals can agree while the rest of the label
says the konto is not domestic taxable sales at all: "Forsaljning
byggtjanster 25 % moms, omvand betalningsskyldighet" is ruta 41, a VMB konto
is ruta 07, an export konto is ruta 36, a momsfri konto is ruta 42. Inferring
0.25 for any of them files the amount in ruta 05, which is a wrong box rather
than a missing one. A contradicting term now stands the fallback down, so the
konto keeps the behaviour it has today (omission) instead of being misfiled.
An explicitly configured rate, including an explicit 0, never reaches this
check and stays authoritative.

Two existing rules are now pinned by tests as deliberate, since a looser
resolver was proposed and rejected: the label must say the word "moms" after
the percent ("Forsaljning konsult 25 %" is a rate of pay, not a sats), and a
label naming two different sats resolves to nothing rather than to whichever
it spells out first.

Fixes #1289

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(vat): anchor the 0 % veto so it stops matching 10/20/30/100 %

The CONTRADICTING_ACCOUNT_NAME alternative `0\s*%` had no left word
boundary, so it also matched the trailing zero of any percentage ending
in zero. An ordinary domestic sales konto named "Forsaljning varor 25 %
moms, rabatt 30 %" was vetoed: agreeing 30x1 suffix, agreeing "25 %
moms" label, none of the veto's documented cases (momsfri, omvand, VMB,
export) applying. It dropped out of the ledger fetch, ruta 05 came out
short, and runVatDeclarationChecks raised a blocking
OUTPUT_VAT_WITHOUT_SALES_BASE: the exact #1261 symptom this module
exists to remove, re-created by the veto.

`\b0\s*%` keeps a genuine "0 % moms" label vetoing and stops matching
inside a longer number. The comment now records why the boundaries
differ per alternative, since the asymmetry is deliberate: "vmb" needs
both, "export" and "utanfor" are bare substrings so Swedish compounds
are caught too.

Both directions are pinned by tests that fail without the anchor.

Fixes #1289

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-30 18:49:39 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent fa394e3759
commit 9bb8b40420
2 changed files with 151 additions and 1 deletions
@@ -1132,6 +1132,124 @@ describe('calculateVatDeclaration: company-specific ruta 05 accounts', () => {
expect(result.rutor.ruta05).toBe(0)
})
it('requires the word "moms" after the percent and refuses an ambiguous label', async () => {
// Two deliberate rules, pinned here so neither is loosened by accident:
// - a bare percent is not a moms-sats. "provision 25 %" and "konsult 25 %"
// are a margin and a rate of pay; reading either as a sats would file
// revenue into ruta 05 off a word the user never wrote.
// - a label naming two different sats resolves to nothing rather than to
// whichever it spells out first: neither figure is trustworthy, and
// picking one silently splits breakdown.invoices.base25/12/6 wrong.
chartAccounts = [
{ account_number: '3011', account_name: 'Försäljning konsult 25 %', default_vat_rate: null },
{
account_number: '3021',
account_name: 'Försäljning varugrupp 1, provision 25 %',
default_vat_rate: null,
},
{
account_number: '3031',
account_name: 'Försäljning 25 % moms och 6 % moms',
default_vat_rate: null,
},
]
seedLedger([
{ account_number: '3011', debit_amount: 0, credit_amount: 4000 },
{ account_number: '3021', debit_amount: 0, credit_amount: 3000 },
{ account_number: '3031', debit_amount: 0, credit_amount: 2000 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
})
it('lets a contradicting label veto the fallback even when number and sats agree', async () => {
// The 30x1 suffix and the "25 % moms" label both point at domestic taxable
// sales, but the rest of the name says the konto is something else: omvänd
// betalningsskyldighet belongs in ruta 41, VMB in ruta 07, export in
// ruta 36 and momsfritt in ruta 42. Ruta 05 is the wrong box for all four,
// so the fallback stands down and the konto keeps its unresolved
// behaviour (omission) rather than being filed somewhere it does not go.
chartAccounts = [
{
account_number: '3011',
account_name: 'Försäljning byggtjänster 25 % moms, omvänd betalningsskyldighet',
default_vat_rate: null,
},
{
account_number: '3021',
account_name: 'Försäljning begagnat 25 % moms (VMB)',
default_vat_rate: null,
},
{
account_number: '3031',
account_name: 'Export utanför EU, tidigare 25 % moms',
default_vat_rate: null,
},
{
account_number: '3041',
account_name: 'Momsfri försäljning, tidigare 25 % moms',
default_vat_rate: null,
},
]
seedLedger([
{ account_number: '3011', debit_amount: 0, credit_amount: 5000 },
{ account_number: '3021', debit_amount: 0, credit_amount: 4000 },
{ account_number: '3031', debit_amount: 0, credit_amount: 3000 },
{ account_number: '3041', debit_amount: 0, credit_amount: 2000 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
})
it('does not let a percentage ending in zero trip the 0 % veto', async () => {
// The veto's "0 %" alternative needs a leading word boundary: without one it
// also matches the trailing zero of "10/20/30/100 %", so an ordinary
// domestic sales konto whose name happens to mention a discount or a share
// would be dropped from ruta 05 and then raise a blocking
// OUTPUT_VAT_WITHOUT_SALES_BASE. Both names below are momspliktig
// försäljning inom Sverige: agreeing 30x1 suffix, agreeing "25 % moms".
chartAccounts = [
{
account_number: '3011',
account_name: 'Försäljning varor 25 % moms, rabatt 30 %',
default_vat_rate: null,
},
{
account_number: '3021',
account_name: 'Försäljning varor 25 % moms, 100 % ägt dotterbolag',
default_vat_rate: null,
},
]
seedLedger([
{ account_number: '3011', debit_amount: 0, credit_amount: 5000 },
{ account_number: '3021', debit_amount: 0, credit_amount: 3000 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(8000)
})
it('still vetoes a konto whose label states a genuine 0 % sats', async () => {
// The other side of the boundary fix: a real "0 %" label must keep vetoing.
chartAccounts = [
{
account_number: '3011',
account_name: 'Försäljning 0 % moms',
default_vat_rate: null,
},
]
seedLedger([{ account_number: '3011', debit_amount: 0, credit_amount: 5000 }])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
})
it('adds the accounts to p_accounts but never to p_ruta_accounts', async () => {
// p_ruta_accounts is the settlement SHAPE detector inside the RPC: an entry
// touching it plus 2650/1650 is classified a momsredovisning and dropped
+33 -1
View File
@@ -53,6 +53,29 @@ const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = {
'3': 0.06,
}
/**
* Labels that contradict "momspliktig försäljning inom Sverige". Any hit vetoes
* the fallback even when the suffix and a "25/12/6 % moms" label agree: a konto
* named "momsfri", "omvänd betalningsskyldighet", VMB or export belongs in
* ruta 07/08/35/36/41, and inferring a sats for it would file the amount in
* ruta 05, i.e. in the wrong box. Omission is the safe failure here, so a
* contradictory label falls back to today's behaviour instead of guessing.
*
* Only consulted for a MISSING rate. An explicitly configured value stays
* authoritative and never reaches this check.
*
* The word boundaries are load-bearing, not decoration. "0 %" needs a leading
* \b or it also matches the trailing zero of "10/20/30/100 %", vetoing a
* perfectly ordinary "Försäljning varor 25 % moms, rabatt 30 %" and recreating
* the #1261 omission this module exists to remove. "vmb" needs both boundaries
* because three letters occur inside unrelated words. "export" and "utanför"
* are matched as bare substrings on purpose, so Swedish compounds
* ("exportförsäljning") are caught too; both are distinctive enough that a
* false hit would have to be contrived.
*/
const CONTRADICTING_ACCOUNT_NAME =
/momsfri|momsfritt|utan moms|omvänd|\bvmb\b|vinstmarginal|export|utanför|eu-land|unionsintern|\b0\s*%/i
/**
* Resolve a missing rate for a company-specific domestic sales sub-account.
*
@@ -63,6 +86,12 @@ const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = {
* Requiring the conventional 30x1/30x2/30x3 suffix and one matching explicit
* "25/12/6 % moms" label keeps the fallback deterministic. A configured value,
* including explicit 0 %, is always authoritative and never reaches here.
*
* Three ways this deliberately answers null:
* - the label states no sats, or states it without the word "moms"
* ("Försäljning konsult 25 %" is a margin or a share, not a moms-sats);
* - the label states two different sats, so neither can be trusted;
* - the label also carries a contradicting term (see above).
*/
function inferDomesticSalesRate(
accountNumber: string,
@@ -71,9 +100,12 @@ function inferDomesticSalesRate(
const accountMatch = /^30\d([123])$/.exec(accountNumber)
if (!accountMatch) return null
const name = accountName ?? ''
if (CONTRADICTING_ACCOUNT_NAME.test(name)) return null
const expectedRate = DOMESTIC_SALES_RATE_BY_SUFFIX[accountMatch[1]]
const namedRates = new Set(
[...(accountName ?? '').matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map(
[...name.matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map(
(match) => Number(match[1]) / 100,
)
)