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:
co-authored by
Claude Opus 4.7
parent
eb77ad50b5
commit
980f29dae8
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user