fix(vat): recover ruta 05 for null-rate custom accounts (#1296)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user