diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts index 9029b3c2..e36a0b1a 100644 --- a/lib/reports/__tests__/vat-declaration.test.ts +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -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 diff --git a/lib/reports/vat-revenue-accounts.ts b/lib/reports/vat-revenue-accounts.ts index 3d657c4f..4e99c7f2 100644 --- a/lib/reports/vat-revenue-accounts.ts +++ b/lib/reports/vat-revenue-accounts.ts @@ -53,6 +53,29 @@ const DOMESTIC_SALES_RATE_BY_SUFFIX: Record = { '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 = { * 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, ) )