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:
co-authored by
Claude Opus 4.7
Emil
parent
cb7eac90b1
commit
ccdfed5fea
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user