Bug/momsdeklaration skv (#449)

* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix

Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so
the sensitive 4-digit suffix is hidden while the (public) birthdate stays
visible. Affects the employees list/detail, salary run, payslip PDF, payslip
email, and the MCP server tools (list_employees, get_salary_run). Each call
site now decrypts the stored personnummer before masking.

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

* fix(transactions): allow deleting unbooked transactions from "Alla transaktioner"

The history list only let users delete via the inbox card; once a category or
mall was picked but the verifikation hadn't been created, the row showed
"Ej bokförd" with no way to remove it. The API already permits delete while
journal_entry_id is null, so the gap was purely a missing UI affordance.

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

* fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK"

Three connected issues caused Skatteverket to reject momsdeklarationer
with FK004 even after our local "Validera"-knapp returned OK.

1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647)
   on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx.
   Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004
   ("silent netting prohibited", ML 13 kap kräver båda sidor).

   Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel
   45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it
   from registration, cash, and credit-note paths. Skipped when the user
   booked the expense directly on a basis account to avoid double-counting.
   4598 added to BAS reference (no migration needed; account_number is
   plain text on journal_entry_lines).

2. rutorToMomsuppgift rounded each ruta independently but computed
   summaMoms from the unrounded ruta49. SKV recomputes the sum from
   integer rutor on their side, so fractional öres caused ±1 SEK drift
   and SKV rejected with FK009.

   Fix: derive summaMoms from the already-rounded VAT-amount rutor.

3. "Validera"-knappen only confirmed SKV's internal arithmetic — a
   declaration with ruta 30-32 populated and ruta 20-24 empty validated
   fine until /utkast hit FK004. Users got a false green light.

   Fix: vat-declaration-checks.ts runs locally before the SKV call,
   blocks Validera/Spara when ERROR-level findings exist, and surfaces
   them in a separate "Lokala kontroller"-section. Success message
   reworded so SKV's OK is no longer presented as filing-ready.

Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster
RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight
codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH,
SUMMA_MOMS_DRIFT).

Backfill for already-posted entries follows in the next commit.

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

* feat: add skattekonto matching functionality

- Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers.
- Implement match suggestions for skattekonto transactions in the backend.
- Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries.
- Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox.
- Introduce skattekonto-match utility functions for candidate matching and linking.
- Update types to include match suggestions and enriched transaction responses.

* refactor: reorganize skattekonto types and implement bank counterpart matching logic

* docs: update CLAUDE.md to streamline integrations and clarify architecture details

* refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-12 18:04:48 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent eb77ad50b5
commit 980f29dae8
33 changed files with 3337 additions and 493 deletions
+6 -5
View File
@@ -38,6 +38,7 @@ import {
import { toToolError } from './tool-result'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
@@ -4237,7 +4238,7 @@ export const tools: McpTool[] = [
// ── Payroll (Lönehantering) ──────────────────────────────────
{
name: 'gnubok_list_employees',
description: 'List employees for the active company. Personnummer returned masked (XXXXXXXX-NNNN).',
description: 'List employees for the active company. Personnummer returned masked (YYYYMMDD-XXXX).',
inputSchema: {
type: 'object',
properties: {
@@ -4257,12 +4258,12 @@ export const tools: McpTool[] = [
const activeOnly = args.active_only !== false
let query = supabase
.from('employees')
.select('id, first_name, last_name, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active')
.select('id, first_name, last_name, personnummer, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active')
.eq('company_id', companyId)
if (activeOnly) query = query.eq('is_active', true)
const { data, error } = await query.order('last_name')
if (error) throw new Error(`Database error: ${error.message}`)
const employees = (data || []).map(e => ({ ...e, personnummer: `XXXXXXXX-${e.personnummer_last4}` }))
const employees = (data || []).map(e => ({ ...e, personnummer: maskPersonnummer(decryptPersonnummer(e.personnummer as string)) }))
return { employees, count: employees.length }
},
},
@@ -4289,9 +4290,9 @@ export const tools: McpTool[] = [
if (error || !run) throw new Error('Salary run not found')
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, personnummer_last4)')
.select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4)')
.eq('salary_run_id', id)
return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record<string, unknown>), personnummer: `XXXXXXXX-${(e.employee as Record<string, unknown>).personnummer_last4}` } : null })) }
return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record<string, unknown>), personnummer: maskPersonnummer(decryptPersonnummer((e.employee as Record<string, unknown>).personnummer as string)) } : null })) }
},
},
{
@@ -94,6 +94,57 @@ describe('rutorToMomsuppgift', () => {
expect(result.import).toBe(2000)
expect(result.momsImportUtgaendeHog).toBe(500)
})
// FK009 regression: SKV recomputes summaMoms from rounded rutor and
// compares against ours. If we round ruta49 from unrounded inputs while
// rounding each ruta independently we drift by ±1 SEK per fractional ruta.
it('summaMoms equals Σ(rounded output VAT rutor) - rounded ruta48', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
// Fractional öres on every VAT-amount ruta so banker's rounding can
// disagree between the orundad ruta49 and the rundade individual fält.
ruta10: 100.49,
ruta11: 50.51,
ruta12: 25.49,
ruta30: 10.51,
ruta31: 5.49,
ruta32: 2.51,
ruta60: 7.49,
ruta61: 3.51,
ruta62: 1.49,
ruta48: 80.51,
ruta49: 100.49 + 50.51 + 25.49 + 10.51 + 5.49 + 2.51 + 7.49 + 3.51 + 1.49 - 80.51,
}
const result = rutorToMomsuppgift(rutor)
const expectedSumma =
(result.momsForsaljningUtgaendeHog ?? 0) +
(result.momsForsaljningUtgaendeMedel ?? 0) +
(result.momsForsaljningUtgaendeLag ?? 0) +
(result.momsInkopUtgaendeHog ?? 0) +
(result.momsInkopUtgaendeMedel ?? 0) +
(result.momsInkopUtgaendeLag ?? 0) +
(result.momsImportUtgaendeHog ?? 0) +
(result.momsImportUtgaendeMedel ?? 0) +
(result.momsImportUtgaendeLag ?? 0) -
(result.ingaendeMomsAvdrag ?? 0)
expect(result.summaMoms).toBe(expectedSumma)
// Sanity-check: result is an integer (SKV requires whole kronor)
expect(Number.isInteger(result.summaMoms)).toBe(true)
})
it('summaMoms is negative when input VAT exceeds output VAT', () => {
const rutor: VatDeclarationRutor = {
...emptyRutor,
ruta48: 5000,
ruta49: -5000,
}
const result = rutorToMomsuppgift(rutor)
expect(result.summaMoms).toBe(-5000)
})
})
describe('formatRedovisare', () => {
@@ -0,0 +1,412 @@
import { describe, it, expect, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
matchSkattekontoToEntry,
SkattekontoMatchError,
} from '../lib/skattekonto-match'
const COMPANY = 'company-1'
const TX_ID = 'skv-tx-1'
function txRow(overrides: Record<string, unknown> = {}) {
return {
id: TX_ID,
company_id: COMPANY,
transaktionsdatum: '2026-03-17',
belopp_skatteverket: 5000,
journal_entry_id: null,
transaktionstext: 'Inbetalning bokförd',
status: 'booked',
...overrides,
}
}
function lineRow(opts: {
entryId: string
debit?: number
credit?: number
voucherNumber?: number | null
entryDate?: string
description?: string
status?: 'draft' | 'posted' | 'reversed'
}) {
return {
debit_amount: opts.debit ?? 0,
credit_amount: opts.credit ?? 0,
journal_entries: {
id: opts.entryId,
voucher_number: opts.voucherNumber ?? 12,
voucher_series: 'A',
entry_date: opts.entryDate ?? '2026-03-16',
description: opts.description ?? 'Test verifikat',
status: opts.status ?? 'posted',
company_id: COMPANY,
},
}
}
// ──────────────────────────────────────────────────────────────────────
// findMatchCandidates
// ──────────────────────────────────────────────────────────────────────
describe('findMatchCandidates', () => {
it('returns candidate verifikat that debits 1630 with matching amount (positive SKV → looks for debit on 1630)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: [lineRow({ entryId: 'je-1', debit: 5000, credit: 0 })] })
enqueue({ data: [] }) // no already-linked
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
expect(result.candidates).toHaveLength(1)
expect(result.candidates[0]).toMatchObject({
journal_entry_id: 'je-1',
matched_amount: 5000,
matched_side: 'debit',
})
})
it('uses credit 1630 lookup when SKV amount is negative (money leaving skattekontot)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow({ belopp_skatteverket: -8333, transaktionstext: 'Debiterad F-skatt' }) })
enqueue({ data: [lineRow({ entryId: 'je-7', debit: 0, credit: 8333 })] })
enqueue({ data: [] })
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
expect(result.candidates).toHaveLength(1)
expect(result.candidates[0].matched_side).toBe('credit')
expect(result.candidates[0].matched_amount).toBe(8333)
})
it('throws TRANSACTION_NOT_FOUND when the SKV row does not exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'not found' } })
await expect(findMatchCandidates(supabase as never, COMPANY, TX_ID)).rejects.toMatchObject({
code: 'TRANSACTION_NOT_FOUND',
})
})
it('throws ALREADY_BOOKED when the SKV row is already linked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow({ journal_entry_id: 'je-existing' }) })
await expect(findMatchCandidates(supabase as never, COMPANY, TX_ID)).rejects.toMatchObject({
code: 'ALREADY_BOOKED',
})
})
it('filters out entries already linked to another SKV row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({
data: [
lineRow({ entryId: 'je-1', debit: 5000, credit: 0 }),
lineRow({ entryId: 'je-2', debit: 5000, credit: 0 }),
],
})
enqueue({ data: [{ journal_entry_id: 'je-1' }] }) // je-1 already linked
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
expect(result.candidates.map(c => c.journal_entry_id)).toEqual(['je-2'])
})
it('returns an empty list when no candidate lines match', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: [] }) // no candidate lines
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
expect(result.candidates).toEqual([])
})
it('throws a SkattekontoMatchError (not a plain Error) so callers can switch on code', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow({ journal_entry_id: 'je-existing' }) })
await expect(findMatchCandidates(supabase as never, COMPANY, TX_ID)).rejects.toBeInstanceOf(
SkattekontoMatchError,
)
})
it('orders candidates by date proximity to the SKV row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow({ transaktionsdatum: '2026-03-17' }) })
enqueue({
data: [
lineRow({ entryId: 'je-far', debit: 5000, entryDate: '2026-03-08' }), // 9 days
lineRow({ entryId: 'je-close', debit: 5000, entryDate: '2026-03-16' }), // 1 day
lineRow({ entryId: 'je-mid', debit: 5000, entryDate: '2026-03-12' }), // 5 days
],
})
enqueue({ data: [] })
const result = await findMatchCandidates(supabase as never, COMPANY, TX_ID)
expect(result.candidates.map(c => c.journal_entry_id)).toEqual([
'je-close',
'je-mid',
'je-far',
])
})
})
// ──────────────────────────────────────────────────────────────────────
// matchSkattekontoToEntry
// ──────────────────────────────────────────────────────────────────────
describe('matchSkattekontoToEntry', () => {
it('writes the journal_entry_id when the candidate has a valid 1630 line', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({
data: {
id: 'je-1',
status: 'posted',
lines: [
{ account_number: '1630', debit_amount: 5000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 5000 },
],
},
})
enqueue({ data: null }) // not already linked
enqueue({ data: null }) // update result
await expect(
matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'),
).resolves.toBeUndefined()
})
it('throws TRANSACTION_NOT_FOUND when the SKV row is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'not found' } })
await expect(
matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'),
).rejects.toMatchObject({ code: 'TRANSACTION_NOT_FOUND' })
})
it('throws ALREADY_BOOKED when the SKV row already has a journal_entry_id', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow({ journal_entry_id: 'je-other' }) })
await expect(
matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'),
).rejects.toMatchObject({ code: 'ALREADY_BOOKED' })
})
it('throws ENTRY_NOT_FOUND when the candidate verifikat does not exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: null, error: { message: 'not found' } })
await expect(
matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-missing'),
).rejects.toMatchObject({ code: 'ENTRY_NOT_FOUND' })
})
it('throws INVALID_CANDIDATE when the verifikat has no 1630 line matching amount + side', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() }) // expects debit 5000 on 1630
enqueue({
data: {
id: 'je-1',
status: 'posted',
lines: [
// Wrong side: credit 5000 on 1630 (doesn't match a positive SKV)
{ account_number: '1630', debit_amount: 0, credit_amount: 5000 },
],
},
})
await expect(
matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'),
).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' })
})
it('throws INVALID_CANDIDATE when the verifikat is reversed (makulerat)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({
data: {
id: 'je-1',
status: 'reversed',
lines: [{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }],
},
})
await expect(
matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'),
).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' })
})
it('throws ENTRY_ALREADY_LINKED when another SKV row is already linked to this verifikat', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({
data: {
id: 'je-1',
status: 'posted',
lines: [{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }],
},
})
enqueue({ data: { id: 'skv-other' } }) // already linked
await expect(
matchSkattekontoToEntry(supabase as never, COMPANY, TX_ID, 'je-1'),
).rejects.toMatchObject({ code: 'ENTRY_ALREADY_LINKED' })
})
})
// ──────────────────────────────────────────────────────────────────────
// findMatchSuggestionsBulk
// ──────────────────────────────────────────────────────────────────────
describe('findMatchSuggestionsBulk', () => {
it('returns a suggestion only when exactly one candidate matches per row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
lineRow({ entryId: 'je-unique', debit: 5000, entryDate: '2026-03-16' }),
// unrelated different-amount line that should not match
lineRow({ entryId: 'je-other', debit: 9999, entryDate: '2026-03-16' }),
],
})
enqueue({ data: [] }) // none linked
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-03-17',
belopp_skatteverket: 5000,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(1)
expect(suggestions.get('skv-1')).toMatchObject({ journal_entry_id: 'je-unique' })
})
it('returns no suggestion when there are TWO candidates (ambiguous)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
lineRow({ entryId: 'je-a', debit: 5000, entryDate: '2026-03-15' }),
lineRow({ entryId: 'je-b', debit: 5000, entryDate: '2026-03-16' }),
],
})
enqueue({ data: [] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-03-17',
belopp_skatteverket: 5000,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(0)
})
it('returns no suggestion when zero candidates match', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-03-17',
belopp_skatteverket: 5000,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(0)
})
it('skips rows that are already linked to a verifikat', async () => {
// already-linked rows shouldn't even reach the candidate query — but
// verify by passing no other unmatched rows; the queue stays empty.
const { supabase } = createQueuedMockSupabase()
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-03-17',
belopp_skatteverket: 5000,
journal_entry_id: 'je-existing',
},
])
expect(suggestions.size).toBe(0)
})
it('skips candidates whose entry_date is outside the per-row ±14 day window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
// 20 days before the SKV row — too far
lineRow({ entryId: 'je-far', debit: 5000, entryDate: '2026-02-25' }),
],
})
enqueue({ data: [] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-03-17',
belopp_skatteverket: 5000,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(0)
})
it('excludes entries that are already linked to a different SKV row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [lineRow({ entryId: 'je-linked', debit: 5000, entryDate: '2026-03-16' })],
})
enqueue({ data: [{ journal_entry_id: 'je-linked' }] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-03-17',
belopp_skatteverket: 5000,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(0)
})
it('respects sign convention per row: negative SKV needs a credit on 1630', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
// Debit-side line — wrong side for a -8333 SKV row
lineRow({ entryId: 'je-wrong-side', debit: 8333, entryDate: '2026-03-16' }),
],
})
enqueue({ data: [] })
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [
{
id: 'skv-1',
transaktionsdatum: '2026-03-17',
belopp_skatteverket: -8333,
journal_entry_id: null,
},
])
expect(suggestions.size).toBe(0)
})
it('returns empty map immediately when no unmatched rows are provided', async () => {
// No queue interaction expected — function should short-circuit.
const { supabase } = createQueuedMockSupabase()
const fromSpy = vi.spyOn(supabase, 'from')
const suggestions = await findMatchSuggestionsBulk(supabase as never, COMPANY, [])
expect(suggestions.size).toBe(0)
expect(fromSpy).not.toHaveBeenCalled()
})
})
+114 -2
View File
@@ -20,6 +20,12 @@ import {
} from './lib/agi-client'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
matchSkattekontoToEntry,
SkattekontoMatchError,
} from './lib/skattekonto-match'
import type { SkattekontoBalanceSnapshot } from './types'
import type { VatPeriodType } from '@/types'
@@ -1380,10 +1386,32 @@ export const skatteverketExtension: Extension = {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const rows = data ?? []
const booked = rows.filter(r => r.status === 'booked')
// Enrich obokförda rader with a single-best-candidate suggestion.
// Only attached when there's exactly one match — avoids the UI
// confidently pointing at the wrong verifikat.
const suggestions = await findMatchSuggestionsBulk(
ctx.supabase,
ctx.companyId,
booked.map(r => ({
id: r.id,
transaktionsdatum: r.transaktionsdatum,
belopp_skatteverket: Number(r.belopp_skatteverket),
journal_entry_id: r.journal_entry_id,
})),
)
const bookedEnriched = booked.map(r => ({
...r,
match_suggestion: suggestions.get(r.id) ?? null,
}))
return NextResponse.json({
data: {
booked: (data ?? []).filter(r => r.status === 'booked'),
upcoming: (data ?? []).filter(r => r.status === 'upcoming'),
booked: bookedEnriched,
upcoming: rows.filter(r => r.status === 'upcoming'),
},
})
},
@@ -1452,6 +1480,90 @@ export const skatteverketExtension: Extension = {
}
},
},
// ── Matcha mot befintligt verifikat ──────────────────────────────
// List candidate journal entries already touching 1630 with the right
// amount/side near the transaction date. Lets the user link the SKV
// row to a manually-booked bank transfer instead of creating a duplicate
// verifikat. Returns at most 25 candidates.
{
method: 'GET',
path: '/skattekonto/transaktioner/:id/match-candidates',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
try {
const { candidates } = await findMatchCandidates(ctx.supabase, ctx.companyId, id)
return NextResponse.json({ data: { candidates } })
} catch (err) {
if (err instanceof SkattekontoMatchError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: 400
return NextResponse.json({ error: err.message, code: err.code }, { status })
}
return handleSkvError(err)
}
},
},
// Link the SKV row to a chosen candidate. No new verifikat is created
// — we just write journal_entry_id onto skattekonto_transactions. The
// candidate is re-validated server-side (matching 1630 line, not already
// linked) to catch races and a malicious client.
{
method: 'POST',
path: '/skattekonto/transaktioner/:id/match',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
let body: { journal_entry_id?: string }
try {
body = (await request.json()) as { journal_entry_id?: string }
} catch {
return NextResponse.json({ error: 'Ogiltig request body' }, { status: 400 })
}
if (!body.journal_entry_id || typeof body.journal_entry_id !== 'string') {
return NextResponse.json(
{ error: 'Saknar journal_entry_id' },
{ status: 400 },
)
}
try {
await matchSkattekontoToEntry(
ctx.supabase,
ctx.companyId,
id,
body.journal_entry_id,
)
return NextResponse.json({ data: { ok: true } })
} catch (err) {
if (err instanceof SkattekontoMatchError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ENTRY_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: err.code === 'ENTRY_ALREADY_LINKED' ? 409
: 422
return NextResponse.json({ error: err.message, code: err.code }, { status })
}
return handleSkvError(err)
}
},
},
],
}
+23 -3
View File
@@ -9,6 +9,14 @@ export { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/f
*
* Fields with value 0 are omitted (Skatteverket treats absent fields as 0).
* This keeps the payload clean and avoids sending unnecessary data.
*
* Rounding: every ruta is rounded to whole kronor (SKV's schema is integers).
* summaMoms is recomputed from the rounded VAT-amount rutor — not from the
* pre-rounding ruta49 — so the payload is internally consistent with how
* Skatteverket recomputes the sum on their side. Rounding ruta49 separately
* from the components causes ±1 SEK drift on fractional-öres inputs and
* triggers SKV's FK009 ("summaMoms stämmer inte överens med övriga
* momsuppgifter") even when the underlying ledger arithmetic is correct.
*/
export function rutorToMomsuppgift(rutor: VatDeclarationRutor): SkatteverketMomsuppgift {
const result: SkatteverketMomsuppgift = {}
@@ -54,15 +62,27 @@ export function rutorToMomsuppgift(rutor: VatDeclarationRutor): SkatteverketMoms
// Input VAT
set('ingaendeMomsAvdrag', rutor.ruta48)
// Net VAT (must always be present, whole kronor)
result.summaMoms = Math.round(rutor.ruta49)
// Import
set('import', rutor.ruta50)
set('momsImportUtgaendeHog', rutor.ruta60)
set('momsImportUtgaendeMedel', rutor.ruta61)
set('momsImportUtgaendeLag', rutor.ruta62)
// Net VAT must always be present, whole kronor. Compute from the already-
// rounded VAT-amount rutor so SKV's reconciliation (sum of integer rutor)
// never disagrees with our summaMoms by ±1 SEK.
result.summaMoms =
(result.momsForsaljningUtgaendeHog ?? 0) +
(result.momsForsaljningUtgaendeMedel ?? 0) +
(result.momsForsaljningUtgaendeLag ?? 0) +
(result.momsInkopUtgaendeHog ?? 0) +
(result.momsInkopUtgaendeMedel ?? 0) +
(result.momsInkopUtgaendeLag ?? 0) +
(result.momsImportUtgaendeHog ?? 0) +
(result.momsImportUtgaendeMedel ?? 0) +
(result.momsImportUtgaendeLag ?? 0) -
(result.ingaendeMomsAvdrag ?? 0)
return result
}
@@ -0,0 +1,440 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { StoredSkattekontoTransaction } from '../types'
/**
* "Matcha mot befintligt verifikat"-flöde för skattekonto-rader.
*
* Jacob's use case:
* 16/3: User books a manual transfer (D 1630 / C 1930, X kr) when they
* pay preliminärskatt from the bank.
* 17/3: Skatteverket reports the same payment landing on skattekontot.
*
* Without matching, the per-row Bokför button would create a *second*
* verifikat with the same 1630-leg → double-counted cash flow. This module
* finds the existing entry and links the SKV row to it, no new draft.
*
* The candidate query is intentionally strict (exact amount, exact side,
* unused entry) — false positives would be silently destructive. False
* negatives just fall back to "Bokför / Skapa manuellt".
*/
const SKATTEKONTO_ACCOUNT = '1630'
const DATE_WINDOW_DAYS = 14
export class SkattekontoMatchError extends Error {
constructor(
message: string,
public readonly code:
| 'TRANSACTION_NOT_FOUND'
| 'ALREADY_BOOKED'
| 'ENTRY_NOT_FOUND'
| 'ENTRY_ALREADY_LINKED'
| 'INVALID_CANDIDATE',
) {
super(message)
this.name = 'SkattekontoMatchError'
}
}
export interface SkattekontoMatchCandidate {
journal_entry_id: string
voucher_number: number | null
voucher_series: string | null
entry_date: string
description: string
status: 'draft' | 'posted' | 'reversed'
matched_amount: number
matched_side: 'debit' | 'credit'
}
/**
* Bulk-enrich a list of unmatched SKV rows with a `match_suggestion` field
* pointing to a "high confidence" candidate verifikat. We only attach the
* suggestion when there is EXACTLY ONE candidate — multiple matches means
* we can't auto-suggest without risking the wrong link. The user can still
* open the full Matcha-dialog manually in that case.
*
* Done in a single SQL pass to keep listing performance reasonable:
* fetch all 1630-lines for entries in the widest possible date window
* covering all rows, then match in-memory.
*/
export async function findMatchSuggestionsBulk(
supabase: SupabaseClient,
companyId: string,
rows: Array<{
id: string
transaktionsdatum: string
belopp_skatteverket: number
journal_entry_id: string | null
}>,
): Promise<Map<string, SkattekontoMatchCandidate>> {
const unmatched = rows.filter(r => !r.journal_entry_id)
if (unmatched.length === 0) return new Map()
const dates = unmatched.map(r => r.transaktionsdatum).sort()
const from = addDays(dates[0], -DATE_WINDOW_DAYS)
const to = addDays(dates[dates.length - 1], DATE_WINDOW_DAYS)
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
`
debit_amount,
credit_amount,
journal_entries!inner (
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id
)
`,
)
.eq('account_number', SKATTEKONTO_ACCOUNT)
.eq('journal_entries.company_id', companyId)
.gte('journal_entries.entry_date', from)
.lte('journal_entries.entry_date', to)
.neq('journal_entries.status', 'reversed')
if (error || !data) return new Map()
type Row = {
debit_amount: number
credit_amount: number
journal_entries: {
id: string
voucher_number: number | null
voucher_series: string | null
entry_date: string
description: string
status: 'draft' | 'posted' | 'reversed'
company_id: string
}
}
const lines = data as unknown as Row[]
// Filter out entries already linked to another SKV row.
const candidateEntryIds = Array.from(new Set(lines.map(l => l.journal_entries.id)))
const { data: linked } = candidateEntryIds.length
? await supabase
.from('skattekonto_transactions')
.select('journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', candidateEntryIds)
: { data: [] }
const linkedSet = new Set(
(linked ?? [])
.map((l: { journal_entry_id: string | null }) => l.journal_entry_id)
.filter((id): id is string => !!id),
)
const suggestions = new Map<string, SkattekontoMatchCandidate>()
for (const row of unmatched) {
const amount = Math.round(Math.abs(Number(row.belopp_skatteverket)) * 100) / 100
const side = expectedSide(Number(row.belopp_skatteverket))
const rowFrom = addDays(row.transaktionsdatum, -DATE_WINDOW_DAYS)
const rowTo = addDays(row.transaktionsdatum, DATE_WINDOW_DAYS)
const matches: SkattekontoMatchCandidate[] = []
const seen = new Set<string>()
for (const line of lines) {
const e = line.journal_entries
if (linkedSet.has(e.id)) continue
if (seen.has(e.id)) continue
if (e.entry_date < rowFrom || e.entry_date > rowTo) continue
const debit = Math.round(Number(line.debit_amount) * 100) / 100
const credit = Math.round(Number(line.credit_amount) * 100) / 100
const lineMatches =
side === 'debit'
? debit === amount && credit === 0
: credit === amount && debit === 0
if (!lineMatches) continue
seen.add(e.id)
matches.push({
journal_entry_id: e.id,
voucher_number: e.voucher_number,
voucher_series: e.voucher_series,
entry_date: e.entry_date,
description: e.description,
status: e.status,
matched_amount: amount,
matched_side: side,
})
if (matches.length > 1) break
}
// Auto-suggest only when there's a single unambiguous match.
if (matches.length === 1) {
suggestions.set(row.id, matches[0])
}
}
return suggestions
}
function addDays(iso: string, days: number): string {
const d = new Date(iso + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
function expectedSide(beloppSkatteverket: number): 'debit' | 'credit' {
// Positive SKV amount = money INTO skattekonto = 1630 increases = DEBIT 1630
// Negative SKV amount = money OUT of skattekonto = 1630 decreases = CREDIT 1630
return beloppSkatteverket > 0 ? 'debit' : 'credit'
}
/**
* Find existing journal entries that look like the bank side of this
* skattekonto row.
*
* Returns up to 25 candidates ordered by date proximity to the SKV row.
*/
export async function findMatchCandidates(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
): Promise<{ tx: StoredSkattekontoTransaction; candidates: SkattekontoMatchCandidate[] }> {
const { data: tx, error: txError } = await supabase
.from('skattekonto_transactions')
.select('*')
.eq('id', transactionId)
.eq('company_id', companyId)
.single<StoredSkattekontoTransaction>()
if (txError || !tx) {
throw new SkattekontoMatchError(
'Skattekonto-transaktionen hittades inte.',
'TRANSACTION_NOT_FOUND',
)
}
if (tx.journal_entry_id) {
throw new SkattekontoMatchError(
'Transaktionen är redan kopplad till ett verifikat.',
'ALREADY_BOOKED',
)
}
const amount = Math.round(Math.abs(Number(tx.belopp_skatteverket)) * 100) / 100
const side = expectedSide(Number(tx.belopp_skatteverket))
const from = addDays(tx.transaktionsdatum, -DATE_WINDOW_DAYS)
const to = addDays(tx.transaktionsdatum, DATE_WINDOW_DAYS)
// Query 1630-lines with the right amount + side, joined to entries in
// the date window. `!inner` filters out rows whose joined entry doesn't
// match (Supabase pg-rest convention).
let q = supabase
.from('journal_entry_lines')
.select(
`
debit_amount,
credit_amount,
journal_entries!inner (
id,
voucher_number,
voucher_series,
entry_date,
description,
status,
company_id
)
`,
)
.eq('account_number', SKATTEKONTO_ACCOUNT)
.eq('journal_entries.company_id', companyId)
.gte('journal_entries.entry_date', from)
.lte('journal_entries.entry_date', to)
.neq('journal_entries.status', 'reversed')
if (side === 'debit') {
q = q.eq('debit_amount', amount).eq('credit_amount', 0)
} else {
q = q.eq('credit_amount', amount).eq('debit_amount', 0)
}
const { data: rows, error: rowsError } = await q.limit(50)
if (rowsError) {
throw new Error(`Kunde inte söka kandidater: ${rowsError.message}`)
}
type Row = {
debit_amount: number
credit_amount: number
journal_entries: {
id: string
voucher_number: number | null
voucher_series: string | null
entry_date: string
description: string
status: 'draft' | 'posted' | 'reversed'
company_id: string
}
}
const typedRows = (rows ?? []) as unknown as Row[]
if (typedRows.length === 0) {
return { tx, candidates: [] }
}
// Filter out entries already linked to another skattekonto_transactions
// row — those represent payments we've already accounted for.
const candidateEntryIds = Array.from(new Set(typedRows.map(r => r.journal_entries.id)))
const { data: linked } = await supabase
.from('skattekonto_transactions')
.select('journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', candidateEntryIds)
const linkedSet = new Set(
(linked ?? [])
.map((l: { journal_entry_id: string | null }) => l.journal_entry_id)
.filter((id): id is string => !!id),
)
const seen = new Set<string>()
const candidates: SkattekontoMatchCandidate[] = []
for (const row of typedRows) {
const e = row.journal_entries
if (linkedSet.has(e.id)) continue
if (seen.has(e.id)) continue
seen.add(e.id)
candidates.push({
journal_entry_id: e.id,
voucher_number: e.voucher_number,
voucher_series: e.voucher_series,
entry_date: e.entry_date,
description: e.description,
status: e.status,
matched_amount: amount,
matched_side: side,
})
}
// Order by date proximity to the SKV row, then by voucher number desc.
const target = new Date(tx.transaktionsdatum + 'T00:00:00Z').getTime()
candidates.sort((a, b) => {
const da = Math.abs(new Date(a.entry_date + 'T00:00:00Z').getTime() - target)
const db = Math.abs(new Date(b.entry_date + 'T00:00:00Z').getTime() - target)
if (da !== db) return da - db
return (b.voucher_number ?? 0) - (a.voucher_number ?? 0)
})
return { tx, candidates: candidates.slice(0, 25) }
}
/**
* Link a skattekonto_transactions row to an existing journal entry.
*
* Re-validates the candidate server-side: the entry must still belong to
* the company, still have a 1630-line on the expected side with the
* expected amount, and must not have been linked in the meantime.
*/
export async function matchSkattekontoToEntry(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
journalEntryId: string,
): Promise<void> {
const { data: tx, error: txError } = await supabase
.from('skattekonto_transactions')
.select('*')
.eq('id', transactionId)
.eq('company_id', companyId)
.single<StoredSkattekontoTransaction>()
if (txError || !tx) {
throw new SkattekontoMatchError(
'Skattekonto-transaktionen hittades inte.',
'TRANSACTION_NOT_FOUND',
)
}
if (tx.journal_entry_id) {
throw new SkattekontoMatchError(
'Transaktionen är redan kopplad till ett verifikat.',
'ALREADY_BOOKED',
)
}
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select(
`
id,
status,
lines:journal_entry_lines (
account_number,
debit_amount,
credit_amount
)
`,
)
.eq('id', journalEntryId)
.eq('company_id', companyId)
.single()
if (entryError || !entry) {
throw new SkattekontoMatchError(
'Verifikatet hittades inte.',
'ENTRY_NOT_FOUND',
)
}
if (entry.status === 'reversed') {
throw new SkattekontoMatchError(
'Verifikatet är makulerat och kan inte matchas.',
'INVALID_CANDIDATE',
)
}
const amount = Math.round(Math.abs(Number(tx.belopp_skatteverket)) * 100) / 100
const side = expectedSide(Number(tx.belopp_skatteverket))
type Line = { account_number: string; debit_amount: number; credit_amount: number }
const hasMatchingLine = (entry.lines as Line[] | null)?.some(l => {
if (l.account_number !== SKATTEKONTO_ACCOUNT) return false
const debit = Math.round(Number(l.debit_amount) * 100) / 100
const credit = Math.round(Number(l.credit_amount) * 100) / 100
return side === 'debit'
? debit === amount && credit === 0
: credit === amount && debit === 0
})
if (!hasMatchingLine) {
throw new SkattekontoMatchError(
'Verifikatet saknar en matchande rad på 1630.',
'INVALID_CANDIDATE',
)
}
const { data: alreadyLinked } = await supabase
.from('skattekonto_transactions')
.select('id')
.eq('company_id', companyId)
.eq('journal_entry_id', journalEntryId)
.maybeSingle()
if (alreadyLinked) {
throw new SkattekontoMatchError(
'Verifikatet är redan kopplat till en annan skattekonto-transaktion.',
'ENTRY_ALREADY_LINKED',
)
}
const { error: updateError } = await supabase
.from('skattekonto_transactions')
.update({ journal_entry_id: journalEntryId })
.eq('id', transactionId)
.eq('company_id', companyId)
.is('journal_entry_id', null) // guard against concurrent updates
if (updateError) {
throw new Error(`Kunde inte koppla transaktionen: ${updateError.message}`)
}
}
+8 -17
View File
@@ -299,23 +299,14 @@ export interface SkatteverketFel {
felmeddelande: string
}
/** Row shape for the skattekonto_transactions table (DB → app) */
export interface StoredSkattekontoTransaction {
id: string
company_id: string
transaktionsidentitet: number | null
dedup_key: string
transaktionsdatum: string
forfallodatum: string | null
ranteberakningsdatum: string | null
transaktionstext: string
belopp_skatteverket: number
belopp_kronofogden: number | null
status: 'booked' | 'upcoming'
journal_entry_id: string | null
imported_at: string
updated_at: string
}
// Re-exported from core because the table lives in core migrations and
// the /transactions page (core) needs to render its rows. Extension-internal
// code continues to import from this module for backwards compatibility.
export type {
StoredSkattekontoTransaction,
SkattekontoMatchSuggestion,
SkattekontoTransactionWithSuggestion,
} from '@/types/skatteverket'
/** Cached snapshot stored in extension_data under key skattekonto_balance_snapshot */
export interface SkattekontoBalanceSnapshot {