fix(vat): recover ruta 05 for null-rate custom accounts (#1296)

This commit is contained in:
Mattsson
2026-07-30 11:28:50 +02:00
committed by GitHub
parent 392e847c1e
commit 6318501b71
7 changed files with 154 additions and 33 deletions
+2
View File
@@ -699,3 +699,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-29] Booking-feedback parity: extracted runCategorize's success tail into one finishBooking() rather than copying the toast into the counterparty branch. The counterparty path was already a second, thinner implementation of the same tail (the reason it silently lacked confirmation, undo and the count decrement), so a third copy was the wrong shape. Also caught while wiring the parity test: handleTransactionBooked (manual booking dialog / voucher match) never decremented totalUncategorizedCount either, so the header count stayed one high until the next refetch; fixed. Deliberately NOT given an Ångra action: its `matched` branch links the transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses whatever journal_entry_id the transaction points at, so an undo there would reverse a voucher the user never created in that flow.
[2026-07-30] InvoiceMatchDialog classifies stale targets as matchable, settled, or not open instead of calling every invalid status fully paid: paid and zero-balance targets need different copy from cancelled, credited, disputed, reversed, draft, or malformed targets, while valid partially paid invoices keep the existing amount-difference flow. Blocked targets do not fetch or show a voucher preview or a confirm-outcome panel because neither match route has a reachable success path for them.
[2026-07-30] Issue #1289 ruta 05 null-rate fallback is report-local and requires both the 30x1/30x2/30x3 suffix and a matching 25/12/6 % moms account label; explicit configured values win. Declined historical backfill, account-creation derivation, and a DB NOT NULL guard: 3011 is custom rather than BAS 2026, NULL is valid across class 3, and history or mixed vouchers cannot safely set future defaults.
@@ -28,7 +28,11 @@ interface SupabaseShape {
function buildSupabase(
linesResult: { data: unknown; error: unknown },
fiscalPeriodResult: { data: unknown; error: unknown } = { data: null, error: null },
chartAccounts: Array<{ account_number: string; default_vat_rate: number }> = []
chartAccounts: Array<{
account_number: string
account_name?: string
default_vat_rate: number | null
}> = []
): SupabaseShape {
const chartResult = { data: chartAccounts, error: null }
return {
@@ -253,6 +257,18 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: ruta 05 accounts
expect(accounts).toContain('3001') // static mapping still there
})
it('drills into a null-rate account when its number and label resolve the rate (#1289)', async () => {
const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [{
account_number: '3011',
account_name: 'Försäljning tjänster inom Sverige, 25 % moms',
default_vat_rate: null,
}])
authOk(supabase)
expect((await get('05')).status).toBe(200)
expect(rpcAccounts(supabase)).toContain('3011')
})
it('leaves other rutor on the static mapping alone', async () => {
const supabase = buildSupabase({ data: [], error: null }, { data: null, error: null }, [
{ account_number: '3013', default_vat_rate: 0.06 },
@@ -38,12 +38,18 @@ interface MockLine {
source_type?: string | null
}
interface MockChartAccount {
account_number: string
account_name: string
default_vat_rate: number | null
}
/**
* Table-routed Supabase double. journal_entries / journal_entry_lines serve the
* fixture; everything else (transactions, supplier_invoices, company_settings)
* comes back empty so no unrelated blocker fires.
*/
function mockSupabase(lines: MockLine[]) {
function mockSupabase(lines: MockLine[], chartAccounts: MockChartAccount[] = []) {
const entries = [
...new Map(
lines.map((l, i) => {
@@ -91,6 +97,7 @@ function mockSupabase(lines: MockLine[]) {
from: (table: string) => {
if (table === 'journal_entries') return makeChain(entries)
if (table === 'journal_entry_lines') return makeChain(bareLines)
if (table === 'chart_of_accounts') return makeChain(chartAccounts)
return makeChain([])
},
// The missing-underlag blocker reads the verifikat_without_documents RPC,
@@ -107,6 +114,31 @@ function mockSupabase(lines: MockLine[]) {
const PERIOD = { period_type: 'monthly', year: 2026, period: 1 }
describe('gnubok_vat_close_check: declaration completeness', () => {
it('includes a null-rate 3011 with matching domestic VAT evidence (#1289)', async () => {
const result = await computeVatCloseCheck(
PERIOD,
'company-1',
mockSupabase(
[
{ entry: 'e1', account_number: '3011', credit_amount: 9725 },
{ entry: 'e1', account_number: '2611', credit_amount: 2431.25 },
{ entry: 'e1', account_number: '1510', debit_amount: 12156.25 },
],
[{
account_number: '3011',
account_name: 'Försäljning tjänster inom Sverige, 25 % moms',
default_vat_rate: null,
}],
),
)
expect(result.rutor.ruta05).toBe(9725)
expect(result.rutor.ruta10).toBe(2431.25)
expect(result.declaration_checks.map((finding) => finding.code))
.not.toContain('OUTPUT_VAT_WITHOUT_SALES_BASE')
expect(result.ready_to_close).toBe(true)
})
it('refuses the #1164 declaration: fiktiv moms on 2614/2645 with no basbelopp on 44xx/45xx', async () => {
// Both VAT legs of a reverse-charge purchase booked, but the cost went
// straight to 6540 instead of the 4535 basis account, so rutor 20-24 stay
@@ -423,7 +423,11 @@ describe('runVatDeclarationChecks', () => {
ruta49: 2500,
}
const findings = runVatDeclarationChecks(rutor)
expect(findings.find((f) => f.code === 'OUTPUT_VAT_WITHOUT_SALES_BASE')?.status).toBe('ERROR')
const finding = findings.find((f) => f.code === 'OUTPUT_VAT_WITHOUT_SALES_BASE')
expect(finding?.status).toBe('ERROR')
expect(finding?.message).toContain('momspliktiga intäktskonton')
expect(finding?.message).toContain('Standard moms')
expect(finding?.message).not.toContain('3001/3002/3003')
})
// SKV §4.1.1.4 rule 5: import base without import output VAT.
+43 -18
View File
@@ -8,13 +8,16 @@ let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
/**
* The company's own class 3 accounts carrying a "Standard moms", as
* fetchDynamicRuta05Accounts reads them. Answered off a table-routed builder
* rather than the sequential queue: every calculateVatDeclaration test would
* otherwise have to seed one, and a missing seed would silently hand the chart
* query the ledger result.
* The company's own class 3 accounts, as fetchDynamicRuta05Accounts reads
* them. Answered off a table-routed builder rather than the sequential queue:
* every calculateVatDeclaration test would otherwise have to seed one, and a
* missing seed would silently hand the chart query the ledger result.
*/
let chartAccounts: Array<{ account_number: string; default_vat_rate: number | null }>
let chartAccounts: Array<{
account_number: string
account_name?: string
default_vat_rate: number | null
}>
function makeBuilder() {
const b: Record<string, unknown> = {}
@@ -28,9 +31,9 @@ function makeBuilder() {
}
/**
* chart_of_accounts builder. Applies the same filters the real query relies on
* (account_class = 3, default_vat_rate in the taxable sats) so a fixture can
* assert that a rate-less or non-revenue konto never reaches ruta 05.
* chart_of_accounts builder. The real query returns all active class 3 rows:
* fetchDynamicRuta05Accounts applies configured-rate and narrow missing-rate
* fallback rules in memory.
*/
function makeChartBuilder() {
const b: Record<string, unknown> = {}
@@ -39,9 +42,7 @@ function makeChartBuilder() {
}
b.then = (resolve: (v: unknown) => void) =>
resolve({
data: chartAccounts.filter(
(a) => a.default_vat_rate != null && [0.25, 0.12, 0.06].includes(a.default_vat_rate)
),
data: chartAccounts.map((account) => ({ account_name: '', ...account })),
error: null,
})
return b
@@ -939,6 +940,26 @@ describe('calculateVatDeclaration: parent/summary accounts', () => {
// ============================================================
describe('calculateVatDeclaration: company-specific ruta 05 accounts', () => {
it('infers a missing rate only from a matching domestic-sales number and label (#1289)', async () => {
chartAccounts = [{
account_number: '3011',
account_name: 'Försäljning tjänster inom Sverige, 25 % moms',
default_vat_rate: null,
}]
seedLedger([
{ account_number: '3011', debit_amount: 0, credit_amount: 9725 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2431.25 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const findings = runVatDeclarationChecks(result.rutor)
expect(result.rutor.ruta05).toBe(9725)
expect(result.rutor.ruta10).toBe(2431.25)
expect(result.breakdown.invoices.base25).toBe(9725)
expect(findings.map((f) => f.code)).not.toContain('OUTPUT_VAT_WITHOUT_SALES_BASE')
})
it('includes a user-added revenue account carrying a moms-sats', async () => {
chartAccounts = [{ account_number: '3013', default_vat_rate: 0.06 }]
seedLedger([
@@ -1090,16 +1111,20 @@ describe('calculateVatDeclaration: company-specific ruta 05 accounts', () => {
expect(result.breakdown.invoices.base25).toBe(2000)
})
it('ignores revenue accounts with no sats or an explicit 0 %', async () => {
// "Ingen standard" and "Ingen moms" both mean the konto is not declared
// momspliktig: momsfri revenue belongs in ruta 42, not 05.
it('ignores missing rates without matching evidence and keeps explicit 0 % authoritative', async () => {
// A number or a free-text label alone is not enough, and an explicit
// "Ingen moms" always wins over the fallback convention.
chartAccounts = [
{ account_number: '3013', default_vat_rate: null },
{ account_number: '3014', default_vat_rate: 0 },
{ account_number: '3013', account_name: 'Varugrupp C', default_vat_rate: null },
{ account_number: '3011', account_name: 'Varugrupp A, 25 % moms', default_vat_rate: 0 },
{ account_number: '3098', account_name: 'Försäljning 25 % moms', default_vat_rate: null },
{ account_number: '3023', account_name: 'Försäljning 25 % moms', default_vat_rate: null },
]
seedLedger([
{ account_number: '3013', debit_amount: 0, credit_amount: 8000 },
{ account_number: '3014', debit_amount: 0, credit_amount: 2000 },
{ account_number: '3011', debit_amount: 0, credit_amount: 2000 },
{ account_number: '3098', debit_amount: 0, credit_amount: 1000 },
{ account_number: '3023', debit_amount: 0, credit_amount: 500 },
])
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
+4 -2
View File
@@ -267,8 +267,10 @@ export function runVatDeclarationChecks(
message:
'Du har redovisat utgående moms (ruta 10-12) men ingen momspliktig ' +
'försäljning (ruta 05-08). Skatteverket kräver att utgående moms ' +
'matchas med ett försäljningsunderlag. Kontrollera att intäktskonton ' +
'(3001/3002/3003) är bokförda för varje VAT-rad.',
'matchas med ett försäljningsunderlag. Kontrollera att momspliktiga ' +
'intäktskonton är bokförda för varje momsrad. Om ett försäljningskonto ' +
'saknas i ruta 05, kontrollera att kontots "Standard moms" är rätt ' +
'inställd i kontoplanen.',
rutor: ['ruta05', 'ruta06', 'ruta07', 'ruta08', 'ruta10', 'ruta11', 'ruta12'],
})
}
+50 -10
View File
@@ -17,10 +17,11 @@ import { ACCOUNT_TO_BOX } from '@/lib/vat/moms-box-mapping'
* correct declaration got a blocking OUTPUT_VAT_WITHOUT_SALES error (#1261).
*
* The per-account "Standard moms" (chart_of_accounts.default_vat_rate) is the
* resolver: a class 3 konto the user marked 25/12/6 % is by definition domestic
* taxable sales, which is exactly what ruta 05 collects. The account dialogs
* say so, since the field now carries declaration weight and not just line
* prefill.
* 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.
*/
/**
@@ -43,9 +44,43 @@ export const RUTA_05_EXCLUDED_ACCOUNTS = new Set([
'3913',
])
/** VAT rates that mark a konto as momspliktig försäljning. 0 and NULL do not. */
/** 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,
}
/**
* 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.
*/
function inferDomesticSalesRate(
accountNumber: string,
accountName: string | null | undefined,
): number | null {
const accountMatch = /^30\d([123])$/.exec(accountNumber)
if (!accountMatch) 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(
(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.
@@ -104,11 +139,15 @@ export async function fetchDynamicRuta05Accounts(
supabase: SupabaseClient,
companyId: string
): Promise<DynamicRuta05Accounts> {
const rows = await fetchAllRows<{ account_number: string; default_vat_rate: number | string | null }>(
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, default_vat_rate')
.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
@@ -117,7 +156,6 @@ export async function fetchDynamicRuta05Accounts(
// NULL-flagged konto: the same kind of quiet omission this whole fix
// exists to remove.
.not('is_active', 'is', false)
.in('default_vat_rate', TAXABLE_RATES)
.order('account_number', { ascending: true })
.range(from, to)
)
@@ -129,8 +167,10 @@ export async function fetchDynamicRuta05Accounts(
const staticRateByAccount = new Map<string, number>()
for (const row of rows) {
const account = row.account_number
const rate = Number(row.default_vat_rate)
if (!TAXABLE_RATES.includes(rate)) continue
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.