feat: voucher linking, recovery ops, and salary overrides (#591)

* feat: voucher linking, recovery ops, and salary overrides

Adds reversible/correction-style write paths that customers and agents have
been asking for, plus per-run salary employee overrides.

Invoice → voucher linking
- POST /api/invoices/[id]/link-to-voucher and
  GET /api/invoices/[id]/voucher-candidates
- lib/invoices/voucher-matching.ts with full + pg test coverage
- LinkVoucherPicker UI in PaymentBookingDialog
- pending_operations.operation_type expanded with link_invoice_voucher
  (medium risk) and a (journal_entry_id, invoice_id) unique guard
- MCP: gnubok_find_voucher_candidates_for_invoice and
  gnubok_link_invoice_to_voucher tools

SIE undo
- POST /api/import/sie/[id]/undo + undo_sie_import RPC
- sie_imports.status gains 'undone'
- ImportResultStep surfaces the action; structured error SIE_UNDO_FAILED

Edit-recreate journal entries
- POST /api/bookkeeping/journal-entries/[id]/edit-recreate
- Bookkeeping detail page wires it into the existing edit flow

Delete-last-voucher clears IB link
- Trigger + pg test ensure deleting the last voucher of a period nulls the
  opening_balance_journal_entry_id link so a re-import lands cleanly

Salary employee overrides
- salary_run_employees gains per-run override fields + migration
- lib/salary/effective-values.ts centralises resolved values; all payslip,
  payment, AGI, KU, and booking routes read through it
- SalaryOverridePanel on the employee detail page

Account classifier
- lib/bookkeeping/account-classifier.ts + tests; AddAccountDialog uses it
- backfill-import-accounts script updated

Misc
- toast: minor styling tweak
- AGI generate-declaration: respect effective values
- structured-errors: new LINK_INVOICE_VOUCHER namespace

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

* feat: add link_invoice_voucher operation type to pending_operations

* feat: refactor salary run calculations and update error handling for SIE imports

* fix: PR review feedback on voucher linking and SIE recovery

pg-real (blocking):
- tests/pg/delete-last-voucher-ib: drop posted_at = now() from the seed
  UPDATE — journal_entries has no posted_at column.
- lib/invoices/__tests__/voucher-matching.pg: seed the posted voucher
  before closing the fiscal period so enforce_period_lock doesn't block
  the INSERT during setup.

voucher-matching error codes and rollback:
- Add LINK_VOUCHER_DB_ERROR (HTTP 500) and return it on real invoice
  UPDATE / payment INSERT failures. Previously these returned
  LINK_VOUCHER_VOUCHER_NOT_FOUND (404) which the pending-op dispatcher
  auto-rejects on transient DB errors.
- Log rollback failures explicitly so an invoice left in a half-linked
  state (advanced status, no payment row) surfaces for manual
  reconciliation instead of disappearing silently.

resyncNextPeriodOpeningBalance ordering:
- Create the new IB first, relink the period FK, then storno the old IB.
  Previously the storno ran first; if createJournalEntry failed the next
  period was left with a reversed IB and nothing to replace it, and
  executeSIEImport swallows the error as a non-fatal warning.

replace_period_opening_balance_link:
- Tighten role check to owner/admin (was owner/admin/member). Matches
  delete_last_voucher and undo_sie_import.

Data minimisation:
- /api/invoices/[id]/voucher-candidates and the matching MCP tools now
  project only the invoice and customer fields the matcher reads, instead
  of returning the full customer row.

Schema bounds:
- SalaryEmployeeOverrideSchema caps each numeric override at 10 MSEK to
  catch typos before they reach the ledger or AGI.

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

* fix(tests): supply user_id when seeding voucher_sequences

voucher_sequences.user_id is NOT NULL (per the multi-tenant refactor in
20260330130000). The previous test seed only set company_id /
fiscal_period_id / voucher_series, which made the seed fail with a
constraint violation on the latest pg-real run. Pass the same userId
used elsewhere in the seed helper.

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

* fix(tests): scope delete-last-voucher RPC assertions inside the tx

withUserContext always ROLLBACKs, so any DELETE the RPC performs is
discarded when the callback returns. The previous test then queried
journal_entries via a fresh getPool() connection that only saw the
pre-RPC committed seed state — hence "expected '1' to be '0'".

Move every post-RPC assertion (entry count, period FK clear,
opening_balances_set flip, audit log entry, sie_imports clear) inside
the same withUserContext callback so they observe the uncommitted state
before ROLLBACK fires.

Also fix the sie_imports INSERT: the column is `filename`, not
`file_name`, and `sie_type` is NOT NULL.

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

* fix(tests): assert against the IB-marker audit row directly

DELETE on journal_entries fires two audit_log writes: the generic
write_audit_log() trigger row ("Deleted journal_entries record") and the
delete_last_voucher RPC's explicit "(was period IB)" entry. Both land
at the same statement_timestamp(), so ORDER BY created_at DESC LIMIT 1
returned the trigger row non-deterministically in CI.

Switch to a presence check with a LIKE filter on the IB marker so the
test verifies what it actually cares about — that the RPC's IB-aware
audit row exists.

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

* fix(db): set company_id on delete_last_voucher audit_log rows

20260528120000_delete_last_voucher_clears_ib_link.sql inserts directly
into audit_log without setting company_id. audit_log's SELECT policy
filters company_id IN user_company_ids(), so those rows landed with
company_id=NULL and were invisible to every reader — only the generic
write_audit_log() trigger row remained visible. That broke BFL audit-
trail intent: the "(was period IB)" provenance row was never readable.

Republish delete_last_voucher with p_company_id populated on both
audit_log INSERTs (draft path and posted path). Behavior is otherwise
unchanged; the pg-real test for the IB-clear flow now sees the
RPC-written marker row as expected.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-05-28 21:09:43 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 Emil
parent cb7eac90b1
commit ccdfed5fea
52 changed files with 4192 additions and 206 deletions
+4 -10
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import type { BASAccount } from '@/types'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import type { SIEAccount, SIEAccountMappingRecord } from '../types'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import {
suggestMappings,
validateMappings,
@@ -15,14 +16,7 @@ import {
function makeBASAccount(number: string, name: string): BASAccount {
const classNum = parseInt(number.charAt(0), 10)
const accountType =
classNum <= 1
? 'asset'
: classNum === 2
? 'liability'
: classNum === 3
? 'revenue'
: 'expense'
const classified = classifyAccount(number)
return {
id: `bas-${number}`,
user_id: 'user-1',
@@ -31,8 +25,8 @@ function makeBASAccount(number: string, name: string): BASAccount {
account_name: name,
account_class: classNum,
account_group: number.substring(0, 2),
account_type: accountType,
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
account_type: classified.account_type,
normal_balance: classified.normal_balance,
plan_type: 'k1',
is_active: true,
is_system_account: false,
+269 -12
View File
@@ -7,7 +7,7 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import type {
ParsedSIEFile,
AccountMapping,
@@ -20,6 +20,7 @@ import type { CreateJournalEntryLineInput } from '@/types'
import { mappingsToMap, getMappingStats } from './account-mapper'
import { calculateFileHash } from './sie-parser'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
@@ -196,6 +197,60 @@ export async function replaceSIEImport(
return { success: true, deletedEntries: deletedCount as number }
}
/**
* Undo a completed SIE import by hard-deleting its entries (transaction
* vouchers + opening_balance) and resetting voucher_sequences, without
* requiring a replacement file. Marks sie_imports.status='undone'.
*
* Pre-flight checks mirror replaceSIEImport so the user gets a Swedish
* error message before the RPC raises. The RPC itself is idempotent on
* status — calling twice surfaces the "not in completed status" error.
*/
export async function undoSIEImport(
supabase: SupabaseClient,
companyId: string,
importId: string
): Promise<{ success: boolean; deletedEntries: number; error?: string }> {
const { data: importRecord } = await supabase
.from('sie_imports')
.select('status, fiscal_period_id')
.eq('id', importId)
.eq('company_id', companyId)
.single()
if (!importRecord) {
return { success: false, deletedEntries: 0, error: 'Import hittades inte' }
}
if (importRecord.status !== 'completed') {
return { success: false, deletedEntries: 0, error: `Kan bara ångra slutförda importer (status: ${importRecord.status})` }
}
if (importRecord.fiscal_period_id) {
const { data: period } = await supabase
.from('fiscal_periods')
.select('is_closed, locked_at')
.eq('id', importRecord.fiscal_period_id)
.eq('company_id', companyId)
.single()
if (period?.is_closed || period?.locked_at) {
return { success: false, deletedEntries: 0, error: 'Kan inte ångra import i ett låst eller stängt räkenskapsår. Öppna perioden först.' }
}
}
const { data: deletedCount, error: rpcError } = await supabase.rpc('undo_sie_import', {
p_company_id: companyId,
p_import_id: importId,
})
if (rpcError) {
return { success: false, deletedEntries: 0, error: `Kunde inte ångra import: ${rpcError.message}` }
}
return { success: true, deletedEntries: deletedCount as number }
}
/**
* Clean up orphan in-flight import records for a given file hash.
*
@@ -596,6 +651,170 @@ export async function linkOpeningBalanceEntryToPeriod(
}
}
/**
* Pragmatic IB resync.
*
* Backfill scenario: user already imported 2026 (or set its IB manually),
* then later imports 2025. The previously-set 2026 IB no longer matches
* the 2025 UB we just computed — resync it by stornoing the old IB and
* creating a fresh one from the just-imported #UB.
*
* Returns:
* - { resynced: true, ...details } when storno + new IB succeeded
* - { resynced: false, reason } when there's no next period, no existing
* IB to replace, or the next period is locked/closed
*
* Caller is responsible for surfacing the result in ImportResult.
*/
export async function resyncNextPeriodOpeningBalance(
supabase: SupabaseClient,
companyId: string,
userId: string,
justImportedPeriodEnd: string,
parsed: ParsedSIEFile,
accountMap: Map<string, string>
): Promise<
| {
resynced: true
nextPeriodId: string
nextPeriodName: string
stornoEntryId: string
newOpeningBalanceEntryId: string
}
| { resynced: false; reason: string; nextPeriodName?: string }
> {
const { data: nextPeriod } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at, opening_balance_entry_id, opening_balances_set')
.eq('company_id', companyId)
.gt('period_start', justImportedPeriodEnd)
.order('period_start', { ascending: true })
.limit(1)
.maybeSingle()
if (!nextPeriod) {
return { resynced: false, reason: 'no_next_period' }
}
if (!nextPeriod.opening_balance_entry_id) {
// No existing IB on the next period — caller has nothing to resync; the
// user's first IB for the next period will be derived from the import
// we just completed via getOpeningBalances() fallback.
return { resynced: false, reason: 'next_period_has_no_ib', nextPeriodName: nextPeriod.name }
}
if (nextPeriod.is_closed || nextPeriod.locked_at) {
return {
resynced: false,
reason: 'next_period_locked',
nextPeriodName: nextPeriod.name,
}
}
// Build the new IB lines from the just-imported year's #UB (yearIndex=0
// closing balances). Each balance carries the source account number; map
// through accountMap so chart renames in the target company are honored.
const currentYearUB = parsed.closingBalances.filter((b) => b.yearIndex === 0)
if (currentYearUB.length === 0) {
return { resynced: false, reason: 'no_closing_balances', nextPeriodName: nextPeriod.name }
}
const newLines: CreateJournalEntryLineInput[] = []
for (const balance of currentYearUB) {
const targetAccount = accountMap.get(balance.account) ?? balance.account
if (balance.amount > 0) {
newLines.push({
account_number: targetAccount,
debit_amount: balance.amount,
credit_amount: 0,
line_description: `IB ${balance.account} (resynk efter import)`,
})
} else if (balance.amount < 0) {
newLines.push({
account_number: targetAccount,
debit_amount: 0,
credit_amount: Math.abs(balance.amount),
line_description: `IB ${balance.account} (resynk efter import)`,
})
}
}
if (newLines.length === 0) {
return { resynced: false, reason: 'empty_new_ib', nextPeriodName: nextPeriod.name }
}
// Balance check: if the new IB doesn't balance (excluded accounts, etc.),
// book the difference to 2099 the same way createOpeningBalanceEntry does.
const totalDebit = newLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = newLines.reduce((s, l) => s + l.credit_amount, 0)
const diff = Math.round((totalDebit - totalCredit) * 100) / 100
if (Math.abs(diff) > 0.01) {
if (diff > 0) {
newLines.push({
account_number: '2099',
debit_amount: 0,
credit_amount: diff,
line_description: 'Avrundningsdifferens vid IB-resynk',
})
} else {
newLines.push({
account_number: '2099',
debit_amount: Math.abs(diff),
credit_amount: 0,
line_description: 'Avrundningsdifferens vid IB-resynk',
})
}
}
// Ordering note: create the new IB FIRST, then storno the old one. If we
// stornoed first and the createJournalEntry call failed, the next period
// would be left with a reversed IB and nothing to replace it — and
// executeSIEImport swallows our error as a non-fatal warning. By creating
// first we guarantee the worst case is "new IB exists but not yet linked",
// which getOpeningBalances() can still reason about.
// Build the new IB entry on the next period.
const newEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: nextPeriod.id,
entry_date: nextPeriod.period_start as string,
description: 'Ingående balanser (resynk efter prior-year SIE-import)',
source_type: 'opening_balance',
voucher_series: 'A',
lines: newLines,
})
// Atomically swap the period FK pointer (two-step around the
// immutability trigger).
const { error: relinkError } = await supabase.rpc('replace_period_opening_balance_link', {
p_company_id: companyId,
p_period_id: nextPeriod.id,
p_new_entry_id: newEntry.id,
})
if (relinkError) {
throw new Error(`Failed to relink opening balance on next period: ${relinkError.message}`)
}
// Now that the period points at the new IB, storno the old one. If this
// throws, the period is already on the correct entry — the orphaned old
// entry shows up as a stray verifikat but the FK stays consistent.
const storno = await reverseEntry(
supabase,
companyId,
userId,
nextPeriod.opening_balance_entry_id,
nextPeriod.period_start as string,
)
return {
resynced: true,
nextPeriodId: nextPeriod.id,
nextPeriodName: nextPeriod.name,
stornoEntryId: storno.id,
newOpeningBalanceEntryId: newEntry.id,
}
}
/**
* Create journal entries from vouchers using batch insert for performance.
*
@@ -1342,10 +1561,7 @@ async function ensureAccountExists(
// Fallback: derive metadata from account number
const classNum = parseInt(accountNumber.charAt(0), 10)
const group = accountNumber.substring(0, 2)
const accountType = classNum === 1 ? 'asset'
: classNum === 2 ? (group === '21' ? 'untaxed_reserves' : (group === '20' ? 'equity' : 'liability'))
: classNum === 3 ? 'revenue'
: 'expense'
const classified = classifyAccount(accountNumber)
await supabase.from('chart_of_accounts').insert({
user_id: userId,
@@ -1354,8 +1570,8 @@ async function ensureAccountExists(
account_name: accountName,
account_class: classNum,
account_group: group,
account_type: accountType,
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
account_type: classified.account_type,
normal_balance: classified.normal_balance,
sru_code: computeSRUCode(accountNumber),
plan_type: 'full_bas',
is_active: true,
@@ -1699,9 +1915,7 @@ export async function executeSIEImport(
}
const classNum = parseInt(num.charAt(0), 10)
const group = num.substring(0, 2)
const accountType = classNum === 1 ? 'asset'
: classNum === 2 ? (group === '21' ? 'untaxed_reserves' : (group === '20' ? 'equity' : 'liability'))
: classNum === 3 ? 'revenue' : 'expense'
const classified = classifyAccount(num)
return {
user_id: userId,
company_id: companyId,
@@ -1709,8 +1923,8 @@ export async function executeSIEImport(
account_name: targetNameMap.get(num) || `Konto ${num}`,
account_class: classNum,
account_group: group,
account_type: accountType,
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
account_type: classified.account_type,
normal_balance: classified.normal_balance,
sru_code: computeSRUCode(num),
plan_type: 'full_bas' as const,
is_active: true,
@@ -2063,6 +2277,49 @@ export async function executeSIEImport(
result.warnings.push('Kunde inte spara kontomappningar — påverkar inte importerade data')
}
// Pragmatic IB resync: if a chronologically-later fiscal period already
// exists with its own opening_balance entry, the customer is doing a
// prior-year backfill. Sync the next period's IB to match the UB we
// just imported so reports stay consistent.
if (result.success && fiscalYearEnd && result.fiscalPeriodId && parsed.closingBalances.length > 0) {
try {
const resync = await resyncNextPeriodOpeningBalance(
supabase,
companyId,
userId,
fiscalYearEnd,
parsed,
accountMap,
)
if (resync.resynced) {
result.nextPeriodIBResync = {
nextPeriodId: resync.nextPeriodId,
nextPeriodName: resync.nextPeriodName,
stornoEntryId: resync.stornoEntryId,
newOpeningBalanceEntryId: resync.newOpeningBalanceEntryId,
}
result.journalEntriesCreated += 2 // storno + new IB
result.journalEntryIds.push(resync.stornoEntryId, resync.newOpeningBalanceEntryId)
result.warnings.push(
`Ingående balanser för ${resync.nextPeriodName} synkades om mot den just importerade utgående balansen.`,
)
} else if (resync.reason === 'next_period_locked' && resync.nextPeriodName) {
result.nextPeriodIBResyncSkipped = {
reason: 'locked',
nextPeriodName: resync.nextPeriodName,
}
result.warnings.push(
`Nästa räkenskapsår (${resync.nextPeriodName}) är låst — ingående balanser kunde inte synkas om automatiskt. Lås upp perioden och importera igen för att synka.`,
)
}
} catch (resyncError) {
console.error('[sie-import] IB resync failed (non-fatal):', resyncError)
result.warnings.push(
`Ingående balanser för nästa räkenskapsår kunde inte synkas om automatiskt: ${resyncError instanceof Error ? resyncError.message : 'okänt fel'}. Kontrollera och justera manuellt.`,
)
}
}
// Generate systemdokumentation (MigrationDocumentation)
const mappingStats = getMappingStats(mappings)
const documentation: MigrationDocumentation = {
+14
View File
@@ -301,6 +301,20 @@ export interface ImportResult {
// (Fortnox re-sync flow), the prior import's id and the count of journal
// entries that were deleted as a result.
replacedPriorImport?: { importId: string; deletedEntries: number } | null
// If a prior-year backfill triggered IB resync on the immediately-following
// fiscal period (storno + recreate of its opening_balance entry), the
// details of what happened — populated only when the resync ran.
nextPeriodIBResync?: {
nextPeriodId: string
nextPeriodName: string
stornoEntryId: string
newOpeningBalanceEntryId: string
} | null
// If the next period's IB needed resync but we couldn't do it (locked,
// closed, or no existing IB), the human-readable reason.
nextPeriodIBResyncSkipped?: { reason: string; nextPeriodName: string } | null
}
/**