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
+47
View File
@@ -498,6 +498,16 @@ export const MatchInvoiceSchema = z
path: ['expected_journal_entry_id'],
})
/**
* Link an existing posted verifikat as payment for an invoice. No new
* journal entry is created — only an invoice_payments row pointing at the
* supplied journal_entry_id, plus the invoice's paid/remaining are advanced.
*/
export const LinkInvoiceToVoucherSchema = z.object({
journal_entry_id: uuid,
notes: z.string().max(2000).optional(),
})
export const LinkTransactionJournalEntrySchema = z.object({
journal_entry_id: uuid,
// Optional invoice to settle alongside the link. When provided, the
@@ -1420,3 +1430,40 @@ export const UpdateShiftPremiumRuleSchema = z
},
)
/**
* Per-employee override on a salary run (advanced mode).
*
* Each field is independently nullable. `null` clears a previously-set
* override; `undefined` leaves it unchanged. `reason` is required whenever
* any non-null override is being applied — the DB CHECK constraint
* enforces this at the storage layer too.
*/
// Upper bound on per-employee override values. 10 MSEK is well above any
// plausible single-period gross/tax/avgifter figure for a salary run and
// catches typos (e.g. an extra zero) before they reach the ledger or AGI.
const SALARY_OVERRIDE_MAX = 10_000_000
export const SalaryEmployeeOverrideSchema = z
.object({
tax_withheld_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
avgifter_amount_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
avgifter_basis_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
reason: z.string().min(1).max(500).nullable().optional(),
})
.refine(
(data) => {
const hasOverride =
(data.tax_withheld_override !== undefined && data.tax_withheld_override !== null) ||
(data.avgifter_amount_override !== undefined && data.avgifter_amount_override !== null) ||
(data.avgifter_basis_override !== undefined && data.avgifter_basis_override !== null)
if (hasOverride && (data.reason === undefined || data.reason === null || data.reason.trim() === '')) {
return false
}
return true
},
{
message: 'Ange en anledning till justeringen (krävs av BFL för manuella skattejusteringar)',
path: ['reason'],
},
)
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { classifyAccount } from '../account-classifier'
describe('classifyAccount — BAS-known accounts delegate to bas-reference', () => {
it.each([
['1930', 'asset', 'debit'],
['2110', 'untaxed_reserves', 'credit'],
['2440', 'liability', 'credit'],
['3001', 'revenue', 'credit'],
['8016', 'revenue', 'credit'],
['8310', 'revenue', 'credit'],
['8420', 'expense', 'debit'],
['8811', 'revenue', 'debit'],
['8910', 'expense', 'debit'],
] as const)('%s -> %s/%s', (num, type, balance) => {
expect(classifyAccount(num)).toEqual({ account_type: type, normal_balance: balance })
})
})
describe('classifyAccount — non-BAS accounts use heuristic fallback', () => {
it.each([
['1355', 'asset', 'debit'],
['2199', 'untaxed_reserves', 'credit'],
['2999', 'liability', 'credit'],
['3099', 'revenue', 'credit'],
['4995', 'expense', 'debit'],
['7095', 'expense', 'debit'],
['8015', 'revenue', 'credit'],
['8025', 'revenue', 'credit'],
['8195', 'revenue', 'credit'],
['8213', 'revenue', 'credit'],
['8499', 'expense', 'debit'],
['8895', 'revenue', 'credit'],
['8995', 'expense', 'debit'],
] as const)('%s -> %s/%s', (num, type, balance) => {
expect(classifyAccount(num)).toEqual({ account_type: type, normal_balance: balance })
})
})
+64
View File
@@ -0,0 +1,64 @@
import { getBASReference } from './bas-reference'
export type AccountType =
| 'asset'
| 'liability'
| 'equity'
| 'revenue'
| 'expense'
| 'untaxed_reserves'
export type NormalBalance = 'debit' | 'credit'
export interface ClassifiedAccount {
account_type: AccountType
normal_balance: NormalBalance
}
/**
* Map a 4-digit BAS account number to its account_type and normal_balance.
*
* Strategy:
* 1. If the number is in BAS_REFERENCE, return that authoritative entry.
* 2. Otherwise fall back to a group-based heuristic aligned with BAS 2026.
*
* Class-8 groups are subtle: 80/81/82/83/87/88 are intäkter (revenue), 84/89 are
* kostnader (expense). The legacy heuristic defaulted everything not in 83/84 to
* expense, which silently misclassified dividends, capital gains, and
* bokslutsdispositioner.
*/
export function classifyAccount(accountNumber: string): ClassifiedAccount {
const ref = getBASReference(accountNumber)
if (ref) {
return { account_type: ref.account_type, normal_balance: ref.normal_balance }
}
const cls = parseInt(accountNumber[0], 10)
const group = parseInt(accountNumber.substring(0, 2), 10)
switch (cls) {
case 1:
return { account_type: 'asset', normal_balance: 'debit' }
case 2:
if (group === 20) return { account_type: 'equity', normal_balance: 'credit' }
if (group === 21) return { account_type: 'untaxed_reserves', normal_balance: 'credit' }
return { account_type: 'liability', normal_balance: 'credit' }
case 3:
return { account_type: 'revenue', normal_balance: 'credit' }
case 4:
case 5:
case 6:
case 7:
return { account_type: 'expense', normal_balance: 'debit' }
case 8:
if (group >= 80 && group <= 83) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 84) return { account_type: 'expense', normal_balance: 'debit' }
if (group === 85) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 86) return { account_type: 'expense', normal_balance: 'debit' }
if (group === 87 || group === 88) return { account_type: 'revenue', normal_balance: 'credit' }
if (group === 89) return { account_type: 'expense', normal_balance: 'debit' }
return { account_type: 'expense', normal_balance: 'debit' }
default:
return { account_type: 'expense', normal_balance: 'debit' }
}
}
+68
View File
@@ -994,6 +994,11 @@ const SIE_IMPORT: Record<string, StructuredErrorEntry> = {
message_sv: 'SIE-importen kunde inte ersättas.',
message_en: 'Failed to replace SIE import.',
},
SIE_UNDO_FAILED: {
httpStatus: 400,
message_sv: 'SIE-importen kunde inte ångras.',
message_en: 'Failed to undo SIE import.',
},
}
const BANK_FILE: Record<string, StructuredErrorEntry> = {
@@ -1658,6 +1663,68 @@ const PROVIDER: Record<string, StructuredErrorEntry> = {
},
}
// ─────────────────────────────────────────────────────────────────
// Link invoice to an existing posted verifikat (no new JE)
// ─────────────────────────────────────────────────────────────────
const LINK_INVOICE_VOUCHER: Record<string, StructuredErrorEntry> = {
LINK_VOUCHER_INVOICE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Fakturan kunde inte hittas.',
message_en: 'Invoice not found.',
},
LINK_VOUCHER_VOUCHER_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Verifikationen kunde inte hittas.',
message_en: 'Journal entry not found.',
},
LINK_VOUCHER_NOT_POSTED: {
httpStatus: 409,
message_sv: 'Verifikationen är inte bokförd. Endast bokförda verifikationer kan länkas som betalning.',
message_en: 'Journal entry is not posted. Only posted entries can be linked as a payment.',
},
LINK_VOUCHER_NO_AR_CREDIT: {
httpStatus: 400,
message_sv:
'Verifikationen krediterar inte ett kundfordringskonto (151x). Bokföringen behöver först rättas med en stornoverifikation som krediterar 1510, t.ex. via gnubok_correct_entry.',
message_en:
'The journal entry does not credit an accounts-receivable account (151x). Correct the booking first via a storno+correction (gnubok_correct_entry) that credits 1510.',
remediation: {
description:
'Use gnubok_correct_entry to storno the existing voucher and re-book the receipt as Dr 1930 / Cr 1510, then link the corrected voucher.',
tool: 'gnubok_correct_entry',
},
},
LINK_VOUCHER_ALREADY_LINKED: {
httpStatus: 409,
message_sv: 'Verifikationen är redan länkad till den här fakturan.',
message_en: 'This journal entry is already linked to this invoice.',
},
LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING: {
httpStatus: 400,
message_sv:
'Verifikationens kundfordringskreditering är större än fakturans återstående belopp. Verifikationen täcker fler fakturor — välj en annan verifikation eller rätta beloppet först.',
message_en:
'The voucher\'s AR credit exceeds the invoice\'s remaining balance. Split the voucher across multiple invoices via gnubok_correct_entry first, or pick a different voucher.',
},
LINK_VOUCHER_CURRENCY_MISMATCH: {
httpStatus: 400,
message_sv:
'Verifikationens valuta matchar inte fakturans. Endast verifikationer i fakturans valuta kan länkas.',
message_en: 'The voucher\'s currency does not match the invoice currency.',
},
LINK_VOUCHER_INVOICE_FULLY_PAID: {
httpStatus: 409,
message_sv: 'Fakturan har redan slutbetalats. Inget mer behöver länkas.',
message_en: 'Invoice is already fully paid.',
},
LINK_VOUCHER_DB_ERROR: {
httpStatus: 500,
message_sv: 'Databasfel under länkning. Försök igen.',
message_en: 'Database error while linking the voucher. Please retry.',
},
}
// ─────────────────────────────────────────────────────────────────
// Combined registry
// ─────────────────────────────────────────────────────────────────
@@ -1668,6 +1735,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...TRANSACTIONS,
...MATCH_INVOICE,
...LINK_TX_JE,
...LINK_INVOICE_VOUCHER,
...MATCH_SI,
...INVOICE,
...SUPPLIER_INVOICE,
+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
}
/**
@@ -0,0 +1,244 @@
/**
* pg-real test for the link-invoice-voucher feature's DB-side guards.
*
* Covers what the TypeScript service can't verify on its own:
* - The partial unique index idx_invoice_payments_je_inv_unique blocks
* linking the same voucher to the same invoice twice while still
* allowing the voucher to settle other invoices.
* - The link_invoice_voucher operation_type passes the pending_operations
* CHECK constraint.
* - Invoice + invoice_payments writes survive RLS for the owning user and
* are rejected for a different user.
*
* Asserts behaviour the migration 20260528120000 introduced.
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from '@/tests/pg/setup'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
async function seedCustomer(params: {
userId: string
companyId: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
VALUES ($1, $2, $3, 'Test Kund AB', 'swedish_business')`,
[id, params.userId, params.companyId],
)
return id
}
async function seedInvoice(params: {
userId: string
companyId: string
customerId: string
total?: number
status?: 'sent' | 'overdue' | 'partially_paid'
}): Promise<string> {
const id = randomUUID()
const total = params.total ?? 1000
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
paid_amount, remaining_amount)
VALUES ($1, $2, $3, $4, $5, '2026-04-01', '2026-05-01', 'SEK',
$6, 0, $6, 'standard_25', 25, $7, 0, $6)`,
[id, params.userId, params.companyId, params.customerId, `F-${id.slice(0, 8)}`, total, params.status ?? 'sent'],
)
return id
}
async function seedPostedVoucher(params: {
userId: string
companyId: string
fiscalPeriodId: string
amount?: number
}): Promise<string> {
const id = randomUUID()
const amount = params.amount ?? 1000
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, $5, 'A', '2026-05-05', 'Inbetalning', 'manual', 'posted')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, Math.floor(Math.random() * 100000)],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', $2, 0),
($1, '1510', 0, $2)`,
[id, amount],
)
return id
}
describe('link_invoice_voucher pg-real guards', () => {
it('partial unique index blocks linking the same voucher to the same invoice twice', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId })
// First link — should succeed.
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
VALUES ($1, $2, $3, '2026-05-05', 1000, 'SEK', $4)`,
[userId, companyId, invoiceId, voucherId],
)
// Second identical link — should be rejected by the partial unique index.
await expect(
getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
VALUES ($1, $2, $3, '2026-05-05', 1000, 'SEK', $4)`,
[userId, companyId, invoiceId, voucherId],
),
).rejects.toMatchObject({ code: '23505' })
})
it('one voucher can be linked to multiple distinct invoices', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceAId = await seedInvoice({ userId, companyId, customerId, total: 500 })
const invoiceBId = await seedInvoice({ userId, companyId, customerId, total: 500 })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId, amount: 1000 })
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
VALUES ($1, $2, $3, '2026-05-05', 500, 'SEK', $4),
($1, $2, $5, '2026-05-05', 500, 'SEK', $4)`,
[userId, companyId, invoiceAId, voucherId, invoiceBId],
)
const { rows } = await getPool().query<{ count: string }>(
`SELECT COUNT(*) FROM public.invoice_payments WHERE journal_entry_id = $1`,
[voucherId],
)
expect(Number(rows[0].count)).toBe(2)
})
it('partial unique index does NOT collide when journal_entry_id is NULL', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId })
// Two transaction-keyed payment rows for the same invoice with NULL JE
// must coexist (until 2026-05-28 partial index, this would have been a
// false positive if the index were unconditional).
const txId1 = randomUUID()
const txId2 = randomUUID()
await getPool().query(
`INSERT INTO public.transactions (id, user_id, company_id, account_id, date, description, amount, currency)
VALUES ($1, $2, $3, $4, '2026-05-05', 'Payment 1', 500, 'SEK'),
($5, $2, $3, $4, '2026-05-06', 'Payment 2', 500, 'SEK')`,
[txId1, userId, companyId, randomUUID(), txId2],
).catch(async () => {
// transactions table also requires account_id pointing at bank_connections;
// skip seeding txs if FK doesn't allow NULL — and assert against the
// invoice_payments table directly.
})
// Insert two rows with no journal_entry_id and no transaction_id — the
// partial index excludes them and the (transaction_id, invoice_id) unique
// index allows NULL transaction_id duplicates.
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency)
VALUES ($1, $2, $3, '2026-05-05', 500, 'SEK'),
($1, $2, $3, '2026-05-06', 500, 'SEK')`,
[userId, companyId, invoiceId],
)
const { rows } = await getPool().query<{ count: string }>(
`SELECT COUNT(*) FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(rows[0].count)).toBe(2)
})
it('link_invoice_voucher passes the operation_type CHECK constraint', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId })
const opId = randomUUID()
await getPool().query(
`INSERT INTO public.pending_operations
(id, user_id, company_id, operation_type, title, params, preview_data, status, risk_level)
VALUES ($1, $2, $3, 'link_invoice_voucher', 'test', $4::jsonb, $5::jsonb, 'pending', 'medium')`,
[
opId,
userId,
companyId,
JSON.stringify({ invoice_id: invoiceId, journal_entry_id: voucherId }),
JSON.stringify({ voucher_label: 'A-1' }),
],
)
const { rows } = await getPool().query<{ status: string }>(
`SELECT status FROM public.pending_operations WHERE id = $1`,
[opId],
)
expect(rows[0]?.status).toBe('pending')
})
it('linking a voucher whose period is locked does NOT trigger enforce_period_lock', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
// Period must be open while we seed the voucher — enforce_period_lock
// fires on INSERT, so close it only after the JE rows exist.
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
const customerId = await seedCustomer({ userId, companyId })
const invoiceId = await seedInvoice({ userId, companyId, customerId })
const voucherId = await seedPostedVoucher({ userId, companyId, fiscalPeriodId })
await getPool().query(
`UPDATE public.fiscal_periods
SET is_closed = true, closed_at = now()
WHERE id = $1`,
[fiscalPeriodId],
)
// No journal_entries write happens in the link flow — only
// invoice_payments + invoices, neither of which is gated by
// enforce_period_lock. The insert below must succeed even though the
// voucher's fiscal period is closed.
await getPool().query(
`INSERT INTO public.invoice_payments
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
VALUES ($1, $2, $3, '2026-05-05', 1000, 'SEK', $4)`,
[userId, companyId, invoiceId, voucherId],
)
const { rows } = await getPool().query<{ count: string }>(
`SELECT COUNT(*) FROM public.invoice_payments WHERE invoice_id = $1`,
[invoiceId],
)
expect(Number(rows[0].count)).toBe(1)
})
})
@@ -0,0 +1,359 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
findMatchingVouchersForInvoice,
validateVoucherForInvoiceLink,
linkInvoiceToVoucher,
} from '../voucher-matching'
import {
makeInvoice,
makeCustomer,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
// ============================================================
// validateVoucherForInvoiceLink — happy path + reject codes
// ============================================================
describe('validateVoucherForInvoiceLink', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function setup(invoice = makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' })) {
return invoice
}
it('rejects when the invoice has nothing remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(
makeInvoice({ remaining_amount: 0, paid_amount: 1000, total: 1000, currency: 'SEK' }),
)
enqueue({ data: null }) // unused — we short-circuit before querying
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_INVOICE_FULLY_PAID')
})
it('rejects when the voucher is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: null, error: null }) // journal_entries.maybeSingle → null
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-missing',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_VOUCHER_NOT_FOUND')
})
it('rejects when the voucher is not posted', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'draft',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NOT_POSTED')
})
it('rejects when the voucher has no AR credit', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_NO_AR_CREDIT')
})
it('rejects when the voucher amount exceeds the remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 5000, credit_amount: 0, currency: 'SEK' },
{ account_number: '1510', debit_amount: 0, credit_amount: 5000, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING')
})
it('rejects when the line currency does not match the invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'EUR' }))
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_CURRENCY_MISMATCH')
})
it('rejects when the voucher is already linked to this invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
enqueue({ data: [{ id: 'pmt-1' }] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_ALREADY_LINKED')
})
it('returns ok=true with full-pay flag when amount equals remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
enqueue({ data: [] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.arCreditAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.isFullyPaid).toBe(true)
expect(result.remainingAfter).toBe(0)
}
})
it('returns ok=true with partial-pay flag when amount is less than remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(makeInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' }))
enqueue({
data: {
id: 'je-1',
voucher_series: 'A',
voucher_number: 5,
entry_date: '2026-05-01',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '1510', debit_amount: 0, credit_amount: 400, currency: 'SEK' },
],
})
enqueue({ data: [] })
const result = await validateVoucherForInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.paymentAmount).toBe(400)
expect(result.isFullyPaid).toBe(false)
expect(result.remainingAfter).toBe(600)
}
})
})
// ============================================================
// findMatchingVouchersForInvoice — empty + ranking smoke test
// ============================================================
describe('findMatchingVouchersForInvoice', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty when the invoice has nothing remaining', async () => {
const { supabase } = createQueuedMockSupabase()
const invoice = makeInvoice({ remaining_amount: 0, paid_amount: 1000, total: 1000 })
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
invoice as never,
)
expect(result).toEqual([])
})
it('returns empty when the journal lines query errors', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = makeInvoice({
remaining_amount: 1000,
total: 1000,
due_date: '2026-05-01',
})
enqueue({ data: null, error: { message: 'db error' } })
const result = await findMatchingVouchersForInvoice(
supabase as never,
'company-1',
invoice as never,
)
expect(result).toEqual([])
})
})
// ============================================================
// linkInvoiceToVoucher — outcome shape & event emission
// ============================================================
describe('linkInvoiceToVoucher', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('rejects when the invoice is not in a payable status', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { ...makeInvoice({ status: 'paid' }), customer: makeCustomer() },
})
const result = await linkInvoiceToVoucher(
supabase as never,
'user-1',
'company-1',
{ invoiceId: 'inv-1', journalEntryId: 'je-1' },
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_INVOICE_FULLY_PAID')
})
it('rejects when the invoice is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'not found' } })
const result = await linkInvoiceToVoucher(
supabase as never,
'user-1',
'company-1',
{ invoiceId: 'inv-1', journalEntryId: 'je-1' },
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_VOUCHER_INVOICE_NOT_FOUND')
})
})
+4 -2
View File
@@ -8,9 +8,11 @@ export interface InvoiceMatch {
}
/**
* Confidence thresholds for invoice matching
* Confidence thresholds for invoice matching. Shared with voucher-matching.ts
* so the two flows (transaction→invoice and existing-verifikat→invoice) rank
* candidates on the same scale.
*/
const CONFIDENCE = {
export const CONFIDENCE = {
OCR_REFERENCE_MATCH: 0.99,
EXACT_AMOUNT_CUSTOMER: 0.95,
EXACT_AMOUNT_ONLY: 0.80,
+651
View File
@@ -0,0 +1,651 @@
/**
* Link an existing posted verifikat to a customer invoice as its payment row.
*
* Used when the GL already contains a verifikat that credits AR (default
* 1510) — e.g. a SIE-imported payment voucher, a manually-entered cash
* receipt, or any flow where the bookkeeping landed without invoice linkage.
* No new journal entry is created. Only an invoice_payments row is inserted
* pointing at the existing journal_entry_id, plus the invoice's
* paid_amount/remaining_amount/status are advanced.
*
* Vouchers that book income directly (credit 30xx instead of 1510) are
* rejected here with VOUCHER_NO_AR_CREDIT. The proper fix for those is a
* storno+correction via gnubok_correct_entry — out of scope for this V1.
*
* Both the web API route and the MCP commit handler call into the same
* `linkInvoiceToVoucher()` function so behaviour stays in lockstep.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import {
CONFIDENCE,
amountsMatchExact,
amountsMatchFuzzy,
customerNameMatches,
} from './invoice-matching'
import type { Invoice, Customer } from '@/types'
const log = createLogger('voucher-matching')
/** AR account range. Default 1510 (Kundfordringar) — covers all 151x. */
const AR_ACCOUNT_PREFIX = '151'
/** ±90 days from the invoice's due_date as the default search window. */
const DEFAULT_DATE_WINDOW_DAYS = 90
/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
const AMOUNT_TOLERANCE = 0.005
/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
const DATE_PROXIMITY_BUMP = 0.05
export interface VoucherCandidate {
journal_entry_id: string
voucher_series: string | null
voucher_number: number | null
entry_date: string
description: string
/** Total credit to the AR account on this voucher (always positive). */
ar_credit_amount: number
currency: string
/** Currency of the AR-credit line; nullable when the line stores SEK only. */
ar_line_currency: string | null
/** True when the voucher's fiscal period is closed or locked. */
period_locked: boolean
/** Confidence score 0..1 (or 0.99 for OCR match). */
confidence: number
/** Localized reason in Swedish (mirrors invoice-matching.ts conventions). */
match_reason: string
}
interface JournalEntryLine {
id: string
journal_entry_id: string
account_number: string
debit_amount: number | null
credit_amount: number | null
currency: string | null
}
interface VoucherRow {
id: string
voucher_series: string | null
voucher_number: number | null
entry_date: string
description: string
status: string
source_type: string | null
fiscal_period_id: string
}
interface FiscalPeriodRow {
id: string
status: string
}
interface CandidateContext {
invoice: Invoice & { customer?: Customer }
remainingAmount: number
}
/** Internal: SQL-side filter for posted, non-storno, non-opening entries. */
const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
/**
* Find posted journal entries whose lines credit an AR account and could
* plausibly be the payment for this invoice. Returns up to `limit` ranked
* candidates.
*
* The query is intentionally generous on filtering — we let the validator
* make the final call at commit time. Ranking mirrors
* `findMatchingInvoices()`: exact amount + customer match wins, then exact,
* then fuzzy (±1% capped at 500 SEK), with a small bump for date proximity
* to the invoice's due_date.
*/
export async function findMatchingVouchersForInvoice(
supabase: SupabaseClient,
companyId: string,
invoice: Invoice & { customer?: Customer },
options: { limit?: number; dateWindowDays?: number } = {}
): Promise<VoucherCandidate[]> {
const limit = options.limit ?? 10
const windowDays = options.dateWindowDays ?? DEFAULT_DATE_WINDOW_DAYS
const remainingAmount = computeRemaining(invoice)
if (remainingAmount <= AMOUNT_TOLERANCE) return []
const dueDate = new Date(invoice.due_date)
const dateFrom = new Date(dueDate)
dateFrom.setDate(dateFrom.getDate() - windowDays)
const dateTo = new Date(dueDate)
dateTo.setDate(dateTo.getDate() + windowDays)
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select(
`
id,
journal_entry_id,
account_number,
debit_amount,
credit_amount,
currency,
journal_entries!inner (
id,
voucher_series,
voucher_number,
entry_date,
description,
status,
source_type,
fiscal_period_id,
company_id
)
`
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.like('account_number', `${AR_ACCOUNT_PREFIX}%`)
.gt('credit_amount', 0)
.gte('journal_entries.entry_date', dateFrom.toISOString().slice(0, 10))
.lte('journal_entries.entry_date', dateTo.toISOString().slice(0, 10))
.limit(limit * 10)
if (error || !lines) return []
// Group lines by journal_entry_id so we sum the AR credit per voucher.
const byEntry = new Map<
string,
{ entry: VoucherRow; arCreditTotal: number; lineCurrency: string | null }
>()
for (const raw of lines) {
const line = raw as unknown as JournalEntryLine & {
journal_entries: VoucherRow
}
const entry = line.journal_entries
if (!entry) continue
if (EXCLUDED_SOURCE_TYPES.includes(entry.source_type ?? '')) continue
const credit = Number(line.credit_amount ?? 0)
if (credit <= 0) continue
const existing = byEntry.get(entry.id)
if (existing) {
existing.arCreditTotal += credit
} else {
byEntry.set(entry.id, {
entry,
arCreditTotal: credit,
lineCurrency: line.currency,
})
}
}
if (byEntry.size === 0) return []
// Drop entries already fully linked to *this* invoice.
const candidateEntryIds = Array.from(byEntry.keys())
const { data: existingLinks } = await supabase
.from('invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('invoice_id', invoice.id)
.in('journal_entry_id', candidateEntryIds)
const alreadyLinked = new Set(
(existingLinks ?? [])
.map((row) => (row as { journal_entry_id: string | null }).journal_entry_id)
.filter((id): id is string => !!id)
)
for (const id of alreadyLinked) byEntry.delete(id)
if (byEntry.size === 0) return []
// Resolve fiscal period locks in one batched query so we can surface a
// "period locked" flag in the candidate preview. Linking is allowed in
// locked periods (no JE mutation) — this is just informational.
const periodIds = Array.from(
new Set(Array.from(byEntry.values()).map((v) => v.entry.fiscal_period_id))
)
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id, status')
.in('id', periodIds)
const lockedPeriods = new Set(
(periods ?? [])
.filter(
(p) =>
(p as FiscalPeriodRow).status === 'closed' ||
(p as FiscalPeriodRow).status === 'locked'
)
.map((p) => (p as FiscalPeriodRow).id)
)
// Score and rank.
const ctx: CandidateContext = { invoice, remainingAmount }
const candidates: VoucherCandidate[] = []
for (const { entry, arCreditTotal, lineCurrency } of byEntry.values()) {
const scored = scoreCandidate(entry, arCreditTotal, lineCurrency, ctx)
if (!scored) continue
candidates.push({
journal_entry_id: entry.id,
voucher_series: entry.voucher_series,
voucher_number: entry.voucher_number,
entry_date: entry.entry_date,
description: entry.description,
ar_credit_amount: round2(arCreditTotal),
currency: invoice.currency,
ar_line_currency: lineCurrency,
period_locked: lockedPeriods.has(entry.fiscal_period_id),
confidence: scored.confidence,
match_reason: scored.match_reason,
})
}
candidates.sort((a, b) => b.confidence - a.confidence || a.entry_date.localeCompare(b.entry_date))
return candidates.slice(0, limit)
}
function scoreCandidate(
entry: VoucherRow,
arCreditTotal: number,
lineCurrency: string | null,
ctx: CandidateContext
): { confidence: number; match_reason: string } | null {
// OCR-style: invoice number appears in entry description.
if (
ctx.invoice.invoice_number &&
descriptionMentionsInvoice(entry.description, ctx.invoice.invoice_number)
) {
return {
confidence: CONFIDENCE.OCR_REFERENCE_MATCH,
match_reason: `Fakturanummer ${ctx.invoice.invoice_number} omnämnt i verifikatets beskrivning`,
}
}
// Currency mismatch is a hard filter at validation time; candidate listing
// still surfaces near-misses so the user sees them, but we only score them
// for now if the line currency is absent (treated as invoice currency) or
// matches the invoice currency.
const lineCurrencyEffective = lineCurrency ?? ctx.invoice.currency
if (lineCurrencyEffective !== ctx.invoice.currency) {
return null
}
const exactRemaining = amountsMatchExact(arCreditTotal, ctx.remainingAmount)
const exactTotal =
!exactRemaining && amountsMatchExact(arCreditTotal, ctx.invoice.total)
const fuzzyRemaining =
!exactRemaining && !exactTotal && amountsMatchFuzzy(arCreditTotal, ctx.remainingAmount)
const customerMatch = customerNameMatches(
ctx.invoice.customer?.name,
entry.description,
null
)
let confidence = 0
let reason = ''
if (exactRemaining && customerMatch) {
confidence = CONFIDENCE.EXACT_AMOUNT_CUSTOMER
reason = `Exakt belopp (${formatNumber(arCreditTotal)} ${ctx.invoice.currency}) och kundnamn matchar`
} else if (exactRemaining) {
confidence = CONFIDENCE.EXACT_AMOUNT_ONLY
reason = `Exakt belopp (${formatNumber(arCreditTotal)} ${ctx.invoice.currency})`
} else if (exactTotal && customerMatch) {
confidence = CONFIDENCE.FUZZY_AMOUNT_CUSTOMER
reason = `Fakturans totalbelopp och kundnamn matchar`
} else if (exactTotal) {
confidence = CONFIDENCE.FUZZY_AMOUNT_ONLY + 0.05
reason = `Fakturans totalbelopp matchar`
} else if (fuzzyRemaining && customerMatch) {
confidence = CONFIDENCE.FUZZY_AMOUNT_CUSTOMER
reason = `Belopp nära (±1%) och kundnamn matchar`
} else if (fuzzyRemaining) {
confidence = CONFIDENCE.FUZZY_AMOUNT_ONLY
reason = `Belopp nära (±1%)`
} else {
return null
}
// Bump for date proximity to due_date.
if (isDateWithinDays(entry.entry_date, ctx.invoice.due_date, 7)) {
confidence = Math.min(CONFIDENCE.OCR_REFERENCE_MATCH - 0.001, confidence + DATE_PROXIMITY_BUMP)
}
return { confidence, match_reason: reason }
}
export type ValidateResult =
| {
ok: true
arCreditAmount: number
arLineCurrency: string | null
voucher: VoucherRow
remainingAfter: number
isFullyPaid: boolean
paymentAmount: number
}
| {
ok: false
code: VoucherLinkErrorCode
details?: Record<string, unknown>
}
export type VoucherLinkErrorCode =
| 'LINK_VOUCHER_INVOICE_NOT_FOUND'
| 'LINK_VOUCHER_VOUCHER_NOT_FOUND'
| 'LINK_VOUCHER_NOT_POSTED'
| 'LINK_VOUCHER_NO_AR_CREDIT'
| 'LINK_VOUCHER_ALREADY_LINKED'
| 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING'
| 'LINK_VOUCHER_CURRENCY_MISMATCH'
| 'LINK_VOUCHER_INVOICE_FULLY_PAID'
| 'LINK_VOUCHER_DB_ERROR'
/**
* Validate that a journal entry can be linked as payment for an invoice.
* Used by both the staging path (MCP tool) and the commit path (web route +
* MCP commit handler) so the guards stay identical.
*/
export async function validateVoucherForInvoiceLink(
supabase: SupabaseClient,
companyId: string,
invoice: Invoice & { customer?: Customer },
journalEntryId: string
): Promise<ValidateResult> {
const remainingAmount = computeRemaining(invoice)
if (remainingAmount <= AMOUNT_TOLERANCE) {
return { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID' }
}
const { data: voucher, error: voucherError } = await supabase
.from('journal_entries')
.select('id, voucher_series, voucher_number, entry_date, description, status, source_type, fiscal_period_id, company_id')
.eq('id', journalEntryId)
.eq('company_id', companyId)
.maybeSingle()
if (voucherError || !voucher) {
return { ok: false, code: 'LINK_VOUCHER_VOUCHER_NOT_FOUND' }
}
const v = voucher as VoucherRow & { company_id: string }
if (v.status !== 'posted') {
return { ok: false, code: 'LINK_VOUCHER_NOT_POSTED', details: { status: v.status } }
}
if (EXCLUDED_SOURCE_TYPES.includes(v.source_type ?? '')) {
return { ok: false, code: 'LINK_VOUCHER_NO_AR_CREDIT', details: { source_type: v.source_type } }
}
const { data: lines, error: linesError } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, currency')
.eq('journal_entry_id', journalEntryId)
if (linesError || !lines || lines.length === 0) {
return { ok: false, code: 'LINK_VOUCHER_NO_AR_CREDIT' }
}
let arCreditTotal = 0
let lineCurrency: string | null = null
for (const raw of lines) {
const line = raw as { account_number: string; debit_amount: number | null; credit_amount: number | null; currency: string | null }
if (!line.account_number?.startsWith(AR_ACCOUNT_PREFIX)) continue
const credit = Number(line.credit_amount ?? 0)
if (credit <= 0) continue
arCreditTotal += credit
if (!lineCurrency) lineCurrency = line.currency
}
arCreditTotal = round2(arCreditTotal)
if (arCreditTotal <= 0) {
return { ok: false, code: 'LINK_VOUCHER_NO_AR_CREDIT' }
}
const lineCurrencyEffective = lineCurrency ?? invoice.currency
if (lineCurrencyEffective !== invoice.currency) {
return {
ok: false,
code: 'LINK_VOUCHER_CURRENCY_MISMATCH',
details: { invoice_currency: invoice.currency, line_currency: lineCurrencyEffective },
}
}
if (arCreditTotal > remainingAmount + AMOUNT_TOLERANCE) {
return {
ok: false,
code: 'LINK_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
details: { ar_credit: arCreditTotal, remaining: round2(remainingAmount) },
}
}
// Already linked to this invoice? (Final, authoritative check — the DB
// partial unique index is the last line of defence at insert time.)
const { data: existingLinks } = await supabase
.from('invoice_payments')
.select('id')
.eq('company_id', companyId)
.eq('invoice_id', invoice.id)
.eq('journal_entry_id', journalEntryId)
.limit(1)
if (existingLinks && existingLinks.length > 0) {
return { ok: false, code: 'LINK_VOUCHER_ALREADY_LINKED' }
}
const paymentAmount = Math.min(arCreditTotal, round2(remainingAmount))
const remainingAfter = Math.max(0, round2(remainingAmount - paymentAmount))
const isFullyPaid = remainingAfter <= AMOUNT_TOLERANCE
return {
ok: true,
arCreditAmount: arCreditTotal,
arLineCurrency: lineCurrency,
voucher: v,
remainingAfter,
isFullyPaid,
paymentAmount,
}
}
export interface LinkInvoiceToVoucherParams {
invoiceId: string
journalEntryId: string
notes?: string
}
export interface LinkInvoiceToVoucherResult {
paymentId: string
invoiceStatus: 'paid' | 'partially_paid'
paidAmount: number
remainingAmount: number
paymentAmount: number
journalEntryId: string
}
/**
* Atomically link an existing posted verifikat to an invoice. Inserts an
* invoice_payments row, advances the invoice's paid_amount/remaining_amount,
* and emits invoice.match_confirmed (reusing the existing event so reminder
* cancellation + automations fire without a new event channel).
*
* Re-validates inside the same call to defend against stage→commit drift —
* voucher reversed, invoice paid by another flow, etc. Any structured
* rejection is returned as { ok: false, code } so callers can map it to a
* stable HTTP status + auto-reject the pending op.
*/
export async function linkInvoiceToVoucher(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: LinkInvoiceToVoucherParams
): Promise<
| { ok: true; result: LinkInvoiceToVoucherResult }
| { ok: false; code: VoucherLinkErrorCode; details?: Record<string, unknown> }
> {
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', params.invoiceId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
return { ok: false, code: 'LINK_VOUCHER_INVOICE_NOT_FOUND', details: { invoice_id: params.invoiceId } }
}
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
return { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID', details: { status: invoice.status } }
}
const validation = await validateVoucherForInvoiceLink(
supabase,
companyId,
invoice as Invoice & { customer?: Customer },
params.journalEntryId
)
if (!validation.ok) return validation
const now = new Date().toISOString()
const newPaidAmount = round2((invoice.paid_amount ?? 0) + validation.paymentAmount)
const newRemaining = validation.remainingAfter
const newStatus: 'paid' | 'partially_paid' = validation.isFullyPaid ? 'paid' : 'partially_paid'
const { data: updatedRows, error: updateInvError } = await supabase
.from('invoices')
.update({
status: newStatus,
paid_at: validation.isFullyPaid ? now : invoice.paid_at,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
.eq('id', params.invoiceId)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue', 'partially_paid'])
.select('id')
if (updateInvError) {
// Real DB failure (RLS, network, constraint) — distinct from "voucher not
// found" so the pending-op dispatcher retries instead of auto-rejecting.
return { ok: false, code: 'LINK_VOUCHER_DB_ERROR', details: { reason: updateInvError.message } }
}
if (!updatedRows || updatedRows.length === 0) {
return { ok: false, code: 'LINK_VOUCHER_INVOICE_FULLY_PAID' }
}
const { data: payment, error: insertError } = await supabase
.from('invoice_payments')
.insert({
user_id: userId,
company_id: companyId,
invoice_id: params.invoiceId,
payment_date: validation.voucher.entry_date,
amount: validation.paymentAmount,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
journal_entry_id: params.journalEntryId,
transaction_id: null,
notes: params.notes ?? null,
})
.select('id')
.single()
if (insertError) {
// Roll back the invoice update so we don't leave the row in a half-linked
// state. The partial unique index raises 23505 if another linker won the
// race between validation and insert.
const { error: rollbackError } = await supabase
.from('invoices')
.update({
status: invoice.status,
paid_at: invoice.paid_at,
paid_amount: invoice.paid_amount,
remaining_amount: invoice.remaining_amount,
})
.eq('id', params.invoiceId)
.eq('company_id', companyId)
if (rollbackError) {
// Rollback failed — the invoice is stuck advanced with no payment row.
// Surface loudly so ops can reconcile manually; the insert error code
// below still goes back to the caller for the original failure cause.
log.error('voucher link rollback failed — invoice left in advanced state without payment row', {
companyId,
userId,
invoiceId: params.invoiceId,
journalEntryId: params.journalEntryId,
insertError: insertError.message,
rollbackError: rollbackError.message,
})
}
if (insertError.code === '23505') {
return { ok: false, code: 'LINK_VOUCHER_ALREADY_LINKED' }
}
return {
ok: false,
code: 'LINK_VOUCHER_DB_ERROR',
details: { reason: insertError.message },
}
}
try {
await eventBus.emit({
type: 'invoice.paid',
payload: {
invoice: invoice as Invoice,
paymentAmount: validation.paymentAmount,
paymentDate: validation.voucher.entry_date,
userId,
companyId,
},
})
} catch {
/* non-critical */
}
return {
ok: true,
result: {
paymentId: (payment as { id: string }).id,
invoiceStatus: newStatus,
paidAmount: newPaidAmount,
remainingAmount: newRemaining,
paymentAmount: validation.paymentAmount,
journalEntryId: params.journalEntryId,
},
}
}
// ── Helpers ─────────────────────────────────────────────────
function computeRemaining(invoice: Invoice): number {
if (typeof invoice.remaining_amount === 'number' && invoice.remaining_amount > 0) {
return invoice.remaining_amount
}
const paid = invoice.paid_amount ?? 0
return Math.max(0, round2(invoice.total - paid))
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
function isDateWithinDays(a: string, b: string, days: number): boolean {
const ad = new Date(a).getTime()
const bd = new Date(b).getTime()
if (Number.isNaN(ad) || Number.isNaN(bd)) return false
return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
}
function descriptionMentionsInvoice(description: string | null, invoiceNumber: string): boolean {
if (!description || !invoiceNumber) return false
const normalizedDesc = description.replace(/\s+/g, '').toLowerCase()
const normalizedNum = invoiceNumber.replace(/\s+/g, '').toLowerCase()
return normalizedDesc.includes(normalizedNum)
}
function formatNumber(n: number): string {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n)
}
+49
View File
@@ -39,6 +39,8 @@ import {
createSupplierCreditNoteEntry,
createSupplierInvoiceRegistrationEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
@@ -980,6 +982,50 @@ async function commitMatchTransactionInvoice(
return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } }
}
async function commitLinkInvoiceVoucher(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const invoiceId = params.invoice_id as string | undefined
const journalEntryId = params.journal_entry_id as string | undefined
const notes = (params.notes as string | undefined) ?? undefined
if (!invoiceId || !journalEntryId) {
return { error: 'invoice_id and journal_entry_id are required', status: 400 }
}
const outcome = await linkInvoiceToVoucher(supabase, userId, companyId, {
invoiceId,
journalEntryId,
notes,
})
if (!outcome.ok) {
const entry = getErrorEntry(outcome.code)
const httpStatus = entry?.httpStatus ?? 500
// 404/409 are auto-rejected by the dispatcher (the user can re-stage with
// adjusted inputs); 400 surfaces as a normal failure so the UI can
// explain what went wrong.
return {
error: entry?.message_en ?? outcome.code,
status: httpStatus,
}
}
return {
data: {
invoice_status: outcome.result.invoiceStatus,
paid_amount: outcome.result.paidAmount,
remaining_amount: outcome.result.remainingAmount,
payment_amount: outcome.result.paymentAmount,
payment_id: outcome.result.paymentId,
journal_entry_id: outcome.result.journalEntryId,
},
}
}
// ── Stream 1 Phase 1 + follow-up executors ───────────────────────
async function commitClosePeriod(
@@ -2606,6 +2652,9 @@ export async function commitPendingOperation(
case 'match_transaction_invoice':
result = await commitMatchTransactionInvoice(supabase, userId, companyId, pendingOp.params)
break
case 'link_invoice_voucher':
result = await commitLinkInvoiceVoucher(supabase, userId, companyId, pendingOp.params)
break
case 'close_period':
result = await commitClosePeriod(supabase, userId, companyId, pendingOp.params)
break
+5
View File
@@ -25,6 +25,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// ── Medium: reversible booking ─────────────────────────────────────
categorize_transaction: 'medium',
match_transaction_invoice: 'medium',
// Link an existing posted verifikat as payment for an invoice. Reversible by
// deleting the invoice_payments row and reverting invoice status; no journal
// entry is created or modified. Sits next to match_transaction_invoice
// semantically — both attach an existing booking to an invoice.
link_invoice_voucher: 'medium',
create_invoice: 'medium', // creates as draft; sending is a separate op
create_transaction: 'medium', // ingests an uncategorized row; reversible by delete
// Supplier master data carries payment-routing fields (IBAN, BIC, bankgiro,
+14 -6
View File
@@ -64,8 +64,11 @@ const SalaryRunEmployeeRowSchema = z
employee_id: z.string().uuid(),
gross_salary: z.number(),
tax_withheld: z.number(),
tax_withheld_override: z.number().nullable().optional(),
avgifter_basis: z.number(),
avgifter_basis_override: z.number().nullable().optional(),
avgifter_amount: z.number(),
avgifter_amount_override: z.number().nullable().optional(),
avgifter_rate: z.number(),
avgifter_category: z.string().nullable().optional(),
removed_from_agi: z.boolean().nullable().optional(),
@@ -318,13 +321,16 @@ export async function generateAgiDeclaration(
}
const isFSkatt = emp?.f_skatt_status === 'f_skatt'
// Honor advanced-mode per-employee overrides set during review.
const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld
const effectiveAvgifterBasis = sre.avgifter_basis_override ?? sre.avgifter_basis
return {
personnummer: emp?.personnummer ?? '',
specificationNumber: emp?.specification_number ?? 0,
removed: Boolean(sre.removed_from_agi),
grossSalary: sre.gross_salary,
taxWithheld: sre.tax_withheld,
avgifterBasis: sre.avgifter_basis,
taxWithheld: effectiveTax,
avgifterBasis: effectiveAvgifterBasis,
fSkattPayment: isFSkatt ? sre.gross_salary : undefined,
// F-skatt payees: cash goes to FK131 and benefits to the ej-UlagSA
// variants (FK132/FK133/FK134/FK137/FK138/FK139). Regular employees
@@ -368,8 +374,8 @@ export async function generateAgiDeclaration(
const cat = (avgifterByCategory as Record<string, { basis: number; amount: number }>)[
category
] || { basis: 0, amount: 0 }
cat.basis += sre.avgifter_basis
cat.amount += sre.avgifter_amount
cat.basis += sre.avgifter_basis_override ?? sre.avgifter_basis
cat.amount += sre.avgifter_amount_override ?? sre.avgifter_amount
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
}
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
@@ -400,15 +406,17 @@ export async function generateAgiDeclaration(
// FK497 SummaSkatteavdr must equal the sum of FK001 on active IUs (not
// run.total_tax, which includes removed rows). Same for FK487.
// Coalesce override → computed so manual jämkning/FoU adjustments flow
// into the filed declaration.
const totalTax = activeEmployees.reduce(
(sum, sre) => sum + (sre.tax_withheld || 0),
(sum, sre) => sum + ((sre.tax_withheld_override ?? sre.tax_withheld) || 0),
0,
)
const totals: AGITotals = {
totalTax: Math.round(totalTax * 100) / 100,
totalAvgifterBasis: activeEmployees.reduce(
(s, e) => s + (e.avgifter_basis || 0),
(s, e) => s + ((e.avgifter_basis_override ?? e.avgifter_basis) || 0),
0,
),
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
+28
View File
@@ -0,0 +1,28 @@
/**
* Effective salary values — coalesce per-employee overrides over the
* engine-computed defaults.
*
* Used by booking (salary-entries.ts) and AGI (agi/generate-declaration.ts)
* so a manual adjustment for FoU-avdrag or jämkning flows through to both
* the ledger and the Skatteverket declaration.
*/
export interface SalaryRunEmployeeWithOverrides {
tax_withheld: number
tax_withheld_override?: number | null
avgifter_amount: number
avgifter_amount_override?: number | null
avgifter_basis: number
avgifter_basis_override?: number | null
}
export function effectiveTax(sre: SalaryRunEmployeeWithOverrides): number {
return sre.tax_withheld_override ?? sre.tax_withheld
}
export function effectiveAvgifter(sre: SalaryRunEmployeeWithOverrides): number {
return sre.avgifter_amount_override ?? sre.avgifter_amount
}
export function effectiveAvgifterBasis(sre: SalaryRunEmployeeWithOverrides): number {
return sre.avgifter_basis_override ?? sre.avgifter_basis
}