* 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>
223 lines
9.3 KiB
TypeScript
223 lines
9.3 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping'
|
|
|
|
/**
|
|
* Resolve which of a company's OWN revenue accounts belong in ruta 05
|
|
* (momspliktig försäljning som inte ingår i någon annan ruta).
|
|
*
|
|
* ACCOUNT_RUTA is a fixed BAS whitelist and ruta 05 is literally 3001/3002/3003
|
|
* there. That is only correct for a company that never touched its kontoplan:
|
|
* the shipped BAS reference (lib/bookkeeping/bas-data/class-3-revenue.ts) has
|
|
* no varugrupp accounts at all, so every 3011/3013/3041-style konto is added by
|
|
* the user. Their revenue was not merely mapped to the wrong ruta, it was never
|
|
* fetched from the ledger: ACCOUNT_RUTA's keys ARE the account filter passed to
|
|
* get_vat_declaration_totals. Ruta 05 came out short and, because
|
|
* runVatDeclarationChecks compares rutor 05-08 against 10-12, a perfectly
|
|
* correct declaration got a blocking OUTPUT_VAT_WITHOUT_SALES error (#1261).
|
|
*
|
|
* The per-account "Standard moms" (chart_of_accounts.default_vat_rate) is the
|
|
* primary resolver: a class 3 konto the user marked 25/12/6 % is by definition
|
|
* domestic taxable sales, which is exactly what ruta 05 collects. For a
|
|
* missing value, the narrow 30x1/30x2/30x3 convention is accepted only when
|
|
* the account label explicitly confirms the same rate. This recovers imported
|
|
* and older custom accounts without guessing from a number or free text alone.
|
|
*/
|
|
|
|
/**
|
|
* Class 3 accounts that carry a moms-sats but belong in a DIFFERENT ruta, so
|
|
* "has a rate" must not be read as "ruta 05". Per the SKV 4700 mapping in
|
|
* .claude/skills/swedish-vat/references/vat-compliance-reference.md §7:
|
|
*
|
|
* 3211/3212/3220 → ruta 07 (vinstmarginalbeskattning)
|
|
* 3231/3232/3233 → ruta 41 (försäljning där köparen är betalningsskyldig)
|
|
* 3913 → ruta 08 (hyresinkomster, frivillig skattskyldighet)
|
|
*
|
|
* Rutor 07, 08 and 41 are not mappable yet (see the note in
|
|
* vat-declaration-checks.ts). Until they are, these accounts stay out of the
|
|
* declaration entirely: that understates one ruta, whereas sweeping them into
|
|
* ruta 05 would file the amount in the wrong box.
|
|
*/
|
|
export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
|
|
'3211', '3212', '3220',
|
|
'3231', '3232', '3233',
|
|
'3913',
|
|
])
|
|
|
|
/** VAT rates that mark a configured konto as momspliktig försäljning. */
|
|
const TAXABLE_RATES = [0.25, 0.12, 0.06]
|
|
|
|
const DOMESTIC_SALES_RATE_BY_SUFFIX: Record<string, number> = {
|
|
'1': 0.25,
|
|
'2': 0.12,
|
|
'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.
|
|
*
|
|
* Neither signal is sufficient by itself:
|
|
* - 3011 is not in the BAS 2026 catalog and custom numbers can be repurposed;
|
|
* - account labels are free text and can be stale or contradictory.
|
|
*
|
|
* 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,
|
|
accountName: string | null | undefined,
|
|
): number | null {
|
|
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(
|
|
[...name.matchAll(/\b(25|12|6)\s*%\s*moms\b/gi)].map(
|
|
(match) => Number(match[1]) / 100,
|
|
)
|
|
)
|
|
|
|
return namedRates.size === 1 && namedRates.has(expectedRate) ? expectedRate : null
|
|
}
|
|
|
|
/**
|
|
* Ruta 05 accounts that ACCOUNT_TO_BOX already sums, but whose per-rate bucket
|
|
* cannot be inferred from the account number.
|
|
*
|
|
* 3000 "Försäljning inom Sverige" is the BAS gruppkonto for the 30xx range. A
|
|
* BAS-conformant company posts to 3001/3002/3003 and never to 3000, but a
|
|
* company that does post to it has genuine domestic taxable sales, so ruta 05
|
|
* stays the right box and the filed figure is already correct. What is missing
|
|
* is only the rate split: unlike 3001/3002/3003 the number carries no sats, so
|
|
* `breakdown.invoices.base25/12/6` would not add up to ruta 05.
|
|
*
|
|
* These accounts therefore contribute a RATE ONLY. Adding them to `accounts`
|
|
* would double-count them, since ACCOUNT_TO_BOX already puts them in the sum.
|
|
*/
|
|
const RUTA_05_STATIC_RATE_ACCOUNTS = new Set(['3000'])
|
|
|
|
export interface DynamicRuta05Accounts {
|
|
/** Accounts to add to the ledger fetch and to the ruta 05 sum. */
|
|
accounts: string[]
|
|
/** account_number → 0.25 | 0.12 | 0.06, for the per-rate base breakdown. */
|
|
rateByAccount: Map<string, number>
|
|
/**
|
|
* Rates for accounts ALREADY counted in ruta 05 by the static map. Feeds the
|
|
* base breakdown only, never the sum. Empty unless the user set a
|
|
* "Standard moms" on one of RUTA_05_STATIC_RATE_ACCOUNTS.
|
|
*/
|
|
staticRateByAccount: Map<string, number>
|
|
}
|
|
|
|
const EMPTY: DynamicRuta05Accounts = {
|
|
accounts: [],
|
|
rateByAccount: new Map(),
|
|
staticRateByAccount: new Map(),
|
|
}
|
|
|
|
/**
|
|
* Fetch the company-specific ruta 05 accounts.
|
|
*
|
|
* Excluded:
|
|
* - every account in ACCOUNT_TO_BOX. This covers all of ACCOUNT_RUTA (the
|
|
* alignment test in lib/vat/__tests__/moms-box-mapping.test.ts fails if the
|
|
* mirror ever stops being a superset) and additionally the accounts only
|
|
* the mirror maps (3106, 3109, 3521, 3522). Filtering on the superset alone
|
|
* keeps this module off vat-declaration.ts, which imports it.
|
|
*
|
|
* This exclusion is what makes the BAS backfill safe: it sets 3001 = 25 %,
|
|
* and without it 3001 would be counted once by ACCOUNT_RUTA and once here,
|
|
* doubling ruta 05.
|
|
* - RUTA_05_EXCLUDED_ACCOUNTS above.
|
|
*
|
|
* The company_id filter is explicit rather than left to RLS:
|
|
* calculateVatDeclaration is also reached from /api/v1/* on a service client,
|
|
* which has no RLS.
|
|
*/
|
|
export async function fetchDynamicRuta05Accounts(
|
|
supabase: SupabaseClient,
|
|
companyId: string
|
|
): Promise<DynamicRuta05Accounts> {
|
|
const rows = await fetchAllRows<{
|
|
account_number: string
|
|
account_name: string
|
|
default_vat_rate: number | string | null
|
|
}>(
|
|
({ from, to }) =>
|
|
supabase
|
|
.from('chart_of_accounts')
|
|
.select('account_number, account_name, default_vat_rate')
|
|
.eq('company_id', companyId)
|
|
.eq('account_class', 3)
|
|
// Deactivated accounts only. is_active is nullable (boolean DEFAULT
|
|
// true, never made NOT NULL) and the accounts API treats only an
|
|
// explicit false as deactivated, so `eq(true)` would silently drop a
|
|
// NULL-flagged konto: the same kind of quiet omission this whole fix
|
|
// exists to remove.
|
|
.not('is_active', 'is', false)
|
|
.order('account_number', { ascending: true })
|
|
.range(from, to)
|
|
)
|
|
|
|
if (rows.length === 0) return EMPTY
|
|
|
|
const accounts: string[] = []
|
|
const rateByAccount = new Map<string, number>()
|
|
const staticRateByAccount = new Map<string, number>()
|
|
for (const row of rows) {
|
|
const account = row.account_number
|
|
const rate = row.default_vat_rate === null
|
|
? inferDomesticSalesRate(account, row.account_name)
|
|
: Number(row.default_vat_rate)
|
|
if (rate === null || !TAXABLE_RATES.includes(rate)) continue
|
|
|
|
// Checked before the ACCOUNT_TO_BOX skip: these accounts ARE in that map,
|
|
// which is precisely why they need the rate surfaced separately.
|
|
if (RUTA_05_STATIC_RATE_ACCOUNTS.has(account)) {
|
|
staticRateByAccount.set(account, rate)
|
|
continue
|
|
}
|
|
|
|
if (ACCOUNT_TO_BOX[account]) continue
|
|
if (RUTA_05_EXCLUDED_ACCOUNTS.has(account)) continue
|
|
|
|
accounts.push(account)
|
|
rateByAccount.set(account, rate)
|
|
}
|
|
|
|
return { accounts, rateByAccount, staticRateByAccount }
|
|
}
|