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
@@ -245,8 +245,8 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('create_correction')}
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Pencil className="mr-2 h-4 w-4" />}
|
||||
{t('edit_entry')}
|
||||
</Button>
|
||||
)}
|
||||
{entry.status === 'posted' && (
|
||||
|
||||
@@ -462,6 +462,43 @@ function SIEImportWizard() {
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
const handleUndo = useCallback(async (importId: string) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/import/sie/${importId}/undo`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte ångra import', description: getErrorMessage(data), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Import ångrad',
|
||||
description: `${data.deletedEntries} verifikation${data.deletedEntries === 1 ? '' : 'er'} raderades.`,
|
||||
})
|
||||
|
||||
// Reset wizard to upload step so the user can re-import a corrected file
|
||||
setStep('upload')
|
||||
setFile(null)
|
||||
setParsed(null)
|
||||
setMappings([])
|
||||
setPreview(null)
|
||||
setIssues([])
|
||||
setImportResult(null)
|
||||
setError(null)
|
||||
setErrorType(undefined)
|
||||
setValidationErrors([])
|
||||
setValidationWarnings([])
|
||||
setDuplicateImportId(null)
|
||||
setSieAccounts([])
|
||||
} catch {
|
||||
toast({ title: 'Anslutningsfel', description: 'Kunde inte nå servern.', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
const handleReplace = useCallback(async (importId: string) => {
|
||||
if (!file) return
|
||||
|
||||
@@ -591,17 +628,20 @@ function SIEImportWizard() {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.error === 'duplicate') {
|
||||
setError(data.message || 'Denna fil har redan importerats')
|
||||
toast({ title: 'Filen har redan importerats', description: data.message, variant: 'destructive' })
|
||||
const code = data?.error?.code as string | undefined
|
||||
const message = getErrorMessage(data)
|
||||
const failedResult = data?.error?.details?.result as typeof data.result | undefined
|
||||
|
||||
if (code === 'SIE_DUPLICATE_FILE' || code === 'SIE_DUPLICATE_PERIOD') {
|
||||
setError(message)
|
||||
toast({ title: 'Filen har redan importerats', description: message, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (data.result) {
|
||||
setImportResult(data.result)
|
||||
if (failedResult) {
|
||||
setImportResult(failedResult)
|
||||
} else {
|
||||
const msg = data.message || data.error || 'Importen misslyckades.'
|
||||
setError(msg)
|
||||
toast({ title: 'Import misslyckades', description: msg, variant: 'destructive' })
|
||||
setError(message)
|
||||
toast({ title: 'Import misslyckades', description: message, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -683,7 +723,7 @@ function SIEImportWizard() {
|
||||
<ImportReviewStep preview={preview} mappings={mappings}
|
||||
onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading} />
|
||||
)}
|
||||
{step === 'result' && importResult && <ImportResultStep result={importResult} onNewImport={handleNewImport} />}
|
||||
{step === 'result' && importResult && <ImportResultStep result={importResult} onNewImport={handleNewImport} onUndo={handleUndo} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, Calculator, Loader2 } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
|
||||
import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types'
|
||||
|
||||
@@ -173,30 +174,62 @@ export default function SalaryRunEmployeeDetailPage({
|
||||
{employee.personnummer} · Lönespecifikation {periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCalculate}
|
||||
disabled={calculating || readOnly}
|
||||
>
|
||||
{calculating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Calculator className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Beräkna
|
||||
</Button>
|
||||
{run.status === 'draft' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCalculate}
|
||||
disabled={calculating}
|
||||
>
|
||||
{calculating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Calculator className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Beräkna
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<SummaryCard label="Brutto" value={runEmployee.gross_salary} />
|
||||
<SummaryCard label="Skatt" value={runEmployee.tax_withheld} />
|
||||
<SummaryCard label="Netto" value={runEmployee.net_salary} accent />
|
||||
<SummaryCard label="Avgifter" value={runEmployee.avgifter_amount} />
|
||||
<SummaryCard
|
||||
label="Skatt"
|
||||
value={runEmployee.tax_withheld_override ?? runEmployee.tax_withheld}
|
||||
overridden={runEmployee.tax_withheld_override !== null}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Netto"
|
||||
value={runEmployee.net_salary + (runEmployee.tax_withheld - (runEmployee.tax_withheld_override ?? runEmployee.tax_withheld))}
|
||||
accent
|
||||
overridden={runEmployee.tax_withheld_override !== null}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Avgifter"
|
||||
value={runEmployee.avgifter_amount_override ?? runEmployee.avgifter_amount}
|
||||
overridden={runEmployee.avgifter_amount_override !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced mode — per-employee override of tax / arbetsgivaravgift */}
|
||||
{run.status === 'review' && (
|
||||
<SalaryOverridePanel
|
||||
runId={runId}
|
||||
employeeId={employeeId}
|
||||
taxWithheld={runEmployee.tax_withheld}
|
||||
taxOverride={runEmployee.tax_withheld_override}
|
||||
avgifterAmount={runEmployee.avgifter_amount}
|
||||
avgifterOverride={runEmployee.avgifter_amount_override}
|
||||
avgifterBasis={runEmployee.avgifter_basis}
|
||||
avgifterBasisOverride={runEmployee.avgifter_basis_override}
|
||||
reason={runEmployee.override_reason}
|
||||
onSaved={load}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unified calendar — worked time (for hourly) + absence on the same grid */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -264,10 +297,13 @@ export default function SalaryRunEmployeeDetailPage({
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||
function SummaryCard({ label, value, accent, overridden }: { label: string; value: number; accent?: boolean; overridden?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-md border bg-card p-3 ${accent ? 'ring-1 ring-primary/40' : ''}`}>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className={`rounded-md border bg-card p-3 ${accent ? 'ring-1 ring-primary/40' : ''} ${overridden ? 'ring-1 ring-warning/40' : ''}`}>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{label}
|
||||
{overridden && <span className="text-[10px] uppercase tracking-wider text-warning">Justerat</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 text-lg font-medium tabular-nums">{formatCurrency(value)}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft, Calculator, Eye, Check, CreditCard, BookOpen,
|
||||
ArrowLeftCircle, Loader2, Download,
|
||||
ArrowLeftCircle, Loader2, Download, FileDown,
|
||||
} from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -181,6 +181,49 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
setActionLoading(null)
|
||||
}
|
||||
|
||||
async function handleBulkPayslipDownload() {
|
||||
setActionLoading('bulk_payslip')
|
||||
try {
|
||||
const { default: JSZip } = await import('jszip')
|
||||
const zip = new JSZip()
|
||||
const periodLabel = `${run!.period_year}-${String(run!.period_month).padStart(2, '0')}`
|
||||
let added = 0
|
||||
for (const sre of employees) {
|
||||
const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string } }).employee
|
||||
const res = await fetch(`/api/salary/runs/${id}/payslips/${sre.employee_id}/pdf`)
|
||||
if (!res.ok) continue
|
||||
const blob = await res.blob()
|
||||
const name = employee
|
||||
? `${employee.last_name}_${employee.first_name}`.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
: sre.employee_id.slice(0, 8)
|
||||
zip.file(`Lonespec_${periodLabel}_${name}.pdf`, blob)
|
||||
added++
|
||||
}
|
||||
if (added === 0) {
|
||||
toast({ title: 'Inga lönespecifikationer kunde laddas ner', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
const archive = await zip.generateAsync({ type: 'blob' })
|
||||
const url = URL.createObjectURL(archive)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Lonespec_${periodLabel}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast({ title: 'Lönespecifikationer nedladdade', description: `${added} stycken i zip-arkiv.` })
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte skapa zip-fil',
|
||||
description: err instanceof Error ? err.message : 'Okänt fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadAgi() {
|
||||
setActionLoading('agi-download')
|
||||
const res = await fetch(`/api/salary/runs/${id}/agi/xml`)
|
||||
@@ -249,15 +292,29 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{/* Summary cards — recompute from per-employee rows so manual overrides
|
||||
(avancerat läge) are reflected immediately, without relying on
|
||||
run.total_* columns which are frozen at calculate-time. */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: 'Brutto', value: run.total_gross },
|
||||
{ label: 'Skatt', value: run.total_tax },
|
||||
{ label: 'Netto', value: run.total_net, accent: true },
|
||||
{ label: 'Avgifter', value: run.total_avgifter },
|
||||
{ label: 'Total kostnad', value: run.total_employer_cost },
|
||||
].map(({ label, value, accent }) => (
|
||||
{(() => {
|
||||
const effTax = employees.reduce((s, e) => s + (e.tax_withheld_override ?? e.tax_withheld), 0)
|
||||
const effAvgifter = employees.reduce((s, e) => s + (e.avgifter_amount_override ?? e.avgifter_amount), 0)
|
||||
const effNet = employees.reduce(
|
||||
(s, e) => s + (e.net_salary + (e.tax_withheld - (e.tax_withheld_override ?? e.tax_withheld))),
|
||||
0,
|
||||
)
|
||||
const effEmployerCost = employees.reduce(
|
||||
(s, e) => s + e.gross_salary + (e.avgifter_amount_override ?? e.avgifter_amount) + e.vacation_accrual + e.vacation_accrual_avgifter,
|
||||
0,
|
||||
)
|
||||
return [
|
||||
{ label: 'Brutto', value: run.total_gross },
|
||||
{ label: 'Skatt', value: effTax },
|
||||
{ label: 'Netto', value: effNet, accent: true },
|
||||
{ label: 'Avgifter', value: effAvgifter },
|
||||
{ label: 'Total kostnad', value: effEmployerCost },
|
||||
]
|
||||
})().map(({ label, value, accent }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">{label}</p>
|
||||
@@ -273,24 +330,42 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Anställda ({employees.length})</CardTitle>
|
||||
{run.status === 'draft' && canWrite && notAdded.length > 0 && (
|
||||
<Select
|
||||
key={addEmployeeKey}
|
||||
onValueChange={(value) => {
|
||||
handleAddEmployee(value)
|
||||
setAddEmployeeKey(k => k + 1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] h-8 text-sm">
|
||||
<SelectValue placeholder="Lägg till anställd..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notAdded.map(emp => (
|
||||
<SelectItem key={emp.id} value={emp.id}>{emp.first_name} {emp.last_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{employees.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkPayslipDownload}
|
||||
disabled={actionLoading === 'bulk_payslip'}
|
||||
className="h-8 text-sm"
|
||||
>
|
||||
{actionLoading === 'bulk_payslip' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Ladda ner alla
|
||||
</Button>
|
||||
)}
|
||||
{run.status === 'draft' && canWrite && notAdded.length > 0 && (
|
||||
<Select
|
||||
key={addEmployeeKey}
|
||||
onValueChange={(value) => {
|
||||
handleAddEmployee(value)
|
||||
setAddEmployeeKey(k => k + 1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] h-8 text-sm">
|
||||
<SelectValue placeholder="Lägg till anställd..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notAdded.map(emp => (
|
||||
<SelectItem key={emp.id} value={emp.id}>{emp.first_name} {emp.last_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{employees.length === 0 ? (
|
||||
@@ -307,6 +382,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
<TableHead className="text-right">Netto</TableHead>
|
||||
<TableHead className="hidden lg:table-cell text-right">Avgifter</TableHead>
|
||||
<TableHead className="hidden md:table-cell text-right">Semester</TableHead>
|
||||
<TableHead className="text-right w-[80px]">Lönespec</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -315,6 +391,8 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
const name = employee
|
||||
? `${employee.first_name} ${employee.last_name}`
|
||||
: `Anställd ${sre.employee_id.slice(0, 8)}...`
|
||||
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
|
||||
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
|
||||
return (
|
||||
<TableRow
|
||||
key={sre.id}
|
||||
@@ -334,10 +412,23 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-right tabular-nums">{formatCurrency(sre.gross_salary)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-right tabular-nums">{formatCurrency(sre.tax_withheld)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(sre.net_salary)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-right tabular-nums">{formatCurrency(sre.avgifter_amount)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-right tabular-nums">{formatCurrency(taxValue)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(sre.net_salary + (sre.tax_withheld - taxValue))}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-right tabular-nums">{formatCurrency(avgifterValue)}</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-right tabular-nums">{formatCurrency(sre.vacation_accrual)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<a
|
||||
href={`/api/salary/runs/${id}/payslips/${sre.employee_id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Visa lönespecifikation"
|
||||
>
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
Visa PDF
|
||||
</a>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -84,6 +84,12 @@ export async function POST(request: Request) {
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json(
|
||||
{ error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { undoSIEImport } from '@/lib/import/sie-import'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* DELETE /api/import/sie/[id]/undo
|
||||
*
|
||||
* Undo a completed SIE import — hard-deletes all journal entries created
|
||||
* by the import (transaction vouchers + the opening_balance entry),
|
||||
* detaches any user-attached documents, resets voucher_sequences, and
|
||||
* marks the sie_imports row as 'undone'. Period must be open and not
|
||||
* locked. Owner/admin only (enforced by the RPC).
|
||||
*/
|
||||
export const DELETE = withRouteContext(
|
||||
'sie_import.undo',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ sieImportId: id })
|
||||
|
||||
const result = await undoSIEImport(supabase, companyId!, id)
|
||||
|
||||
if (!result.success) {
|
||||
return errorResponseFromCode('SIE_UNDO_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: result.error },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, deletedEntries: result.deletedEntries })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { LinkInvoiceToVoucherSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/link-to-voucher
|
||||
*
|
||||
* Marks an invoice as paid by linking an existing posted verifikat whose
|
||||
* lines already credit AR (1510). Creates no new journal entry — only an
|
||||
* invoice_payments row + invoice status advance.
|
||||
*
|
||||
* Rejects with LINK_VOUCHER_NO_AR_CREDIT for vouchers that book income
|
||||
* directly (e.g. 1930→3001) — those require gnubok_correct_entry first.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'invoice.link_to_voucher',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ invoiceId: id })
|
||||
|
||||
const validation = await validateBody(request, LinkInvoiceToVoucherSchema, {
|
||||
log: opLog,
|
||||
operation: 'invoice.link_to_voucher',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { journal_entry_id, notes } = validation.data
|
||||
|
||||
const outcome = await linkInvoiceToVoucher(supabase, user.id, companyId, {
|
||||
invoiceId: id,
|
||||
journalEntryId: journal_entry_id,
|
||||
notes,
|
||||
})
|
||||
|
||||
if (!outcome.ok) {
|
||||
return errorResponseFromCode(outcome.code, opLog, {
|
||||
requestId,
|
||||
details: outcome.details,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
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,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { findMatchingVouchersForInvoice } from '@/lib/invoices/voucher-matching'
|
||||
import type { Invoice, Customer } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/invoices/[id]/voucher-candidates
|
||||
*
|
||||
* Returns posted verifikat candidates that could be linked as payment for
|
||||
* this invoice. Used by the "Befintlig verifikation" tab in
|
||||
* PaymentBookingDialog to auto-suggest matches.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'invoice.voucher_candidates',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
// Project only the fields the matcher actually reads. Avoids leaking the
|
||||
// full customer row (address, contact, etc.) into the API response.
|
||||
const { data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)'
|
||||
)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !invoice) {
|
||||
return errorResponseFromCode('LINK_VOUCHER_INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||||
return NextResponse.json({ data: { candidates: [], invoice_status: invoice.status } })
|
||||
}
|
||||
|
||||
const candidates = await findMatchingVouchersForInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
// Narrow projection above means TS infers `customer` as `{ id, name }[]`
|
||||
// from the join shorthand. The matcher only reads `customer?.name`, so
|
||||
// cast through unknown to the runtime shape it expects.
|
||||
invoice as unknown as Invoice & { customer?: Customer }
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: { candidates } })
|
||||
},
|
||||
)
|
||||
@@ -56,7 +56,8 @@ export async function GET(
|
||||
const { data: runEmployees, error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select(`
|
||||
employee_id, gross_salary, tax_withheld, avgifter_basis,
|
||||
employee_id, gross_salary, tax_withheld, tax_withheld_override,
|
||||
avgifter_basis, avgifter_basis_override,
|
||||
employee:employees(personnummer, specification_number, employment_start, employment_end),
|
||||
salary_run:salary_runs!inner(period_year, status),
|
||||
line_items:salary_line_items(item_type, amount)
|
||||
@@ -100,8 +101,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
current.totalGross += sre.gross_salary
|
||||
current.totalTax += sre.tax_withheld
|
||||
current.totalAvgifterBasis += sre.avgifter_basis
|
||||
// Honor advanced-mode override so KU matches AGI + the ledger.
|
||||
current.totalTax += sre.tax_withheld_override ?? sre.tax_withheld
|
||||
current.totalAvgifterBasis += sre.avgifter_basis_override ?? sre.avgifter_basis
|
||||
|
||||
// Sum benefits by type from line items
|
||||
const lineItems = (sre.line_items || []) as Array<{ item_type: string; amount: number }>
|
||||
|
||||
@@ -60,9 +60,11 @@ export const POST = withRouteContext(
|
||||
employee_id: sre.employee_id,
|
||||
employment_type: sre.employee?.employment_type || 'employee',
|
||||
gross_salary: sre.gross_salary,
|
||||
tax_withheld: sre.tax_withheld,
|
||||
net_salary: sre.net_salary,
|
||||
avgifter_amount: sre.avgifter_amount,
|
||||
// Apply per-employee overrides (advanced mode) so manual
|
||||
// adjustments for FoU-avdrag / jämkning flow into the ledger.
|
||||
tax_withheld: sre.tax_withheld_override ?? sre.tax_withheld,
|
||||
net_salary: sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld)),
|
||||
avgifter_amount: sre.avgifter_amount_override ?? sre.avgifter_amount,
|
||||
avgifter_rate: sre.avgifter_rate,
|
||||
vacation_accrual: sre.vacation_accrual,
|
||||
vacation_accrual_avgifter: sre.vacation_accrual_avgifter,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -36,6 +38,83 @@ export async function GET(
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply per-employee override on tax/avgifter (advanced mode).
|
||||
*
|
||||
* Only allowed in `review` status — the calculation engine has run, but the
|
||||
* run hasn't been approved or booked yet. After approval, vouchers and AGI
|
||||
* lock in the effective values; further changes require correction flows.
|
||||
*
|
||||
* Pass `null` for any field to clear a previously-set override.
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; employeeId: string }> },
|
||||
) {
|
||||
const { id, employeeId } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const parsed = await validateBody(request, SalaryEmployeeOverrideSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
// Gate on run status. Override is only valid mid-review.
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (run.status !== 'review') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Build patch — only include fields that were explicitly provided so
|
||||
// unrelated overrides are not nulled.
|
||||
const patch: Record<string, number | string | null> = {}
|
||||
if ('tax_withheld_override' in parsed.data) {
|
||||
patch.tax_withheld_override = parsed.data.tax_withheld_override ?? null
|
||||
}
|
||||
if ('avgifter_amount_override' in parsed.data) {
|
||||
patch.avgifter_amount_override = parsed.data.avgifter_amount_override ?? null
|
||||
}
|
||||
if ('avgifter_basis_override' in parsed.data) {
|
||||
patch.avgifter_basis_override = parsed.data.avgifter_basis_override ?? null
|
||||
}
|
||||
if ('reason' in parsed.data) {
|
||||
patch.override_reason = parsed.data.reason ?? null
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.update(patch)
|
||||
.eq('salary_run_id', id)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.select('id, tax_withheld, tax_withheld_override, avgifter_amount, avgifter_amount_override, avgifter_basis, avgifter_basis_override, override_reason')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/** Remove employee from a draft salary run. Cascades to delete their line items. */
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
|
||||
@@ -105,8 +105,15 @@ export async function GET(
|
||||
}
|
||||
|
||||
const employees: BgLbEmployee[] = runEmployees
|
||||
.filter((sre) => sre.net_salary > 0)
|
||||
.map((sre) => {
|
||||
// Honor tax override on the bank payment file too — the net the
|
||||
// employee actually receives depends on the effective tax.
|
||||
const effectiveNet =
|
||||
sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
|
||||
return { sre, effectiveNet }
|
||||
})
|
||||
.filter(({ effectiveNet }) => effectiveNet > 0)
|
||||
.map(({ sre, effectiveNet }) => {
|
||||
const emp = sre.employee as {
|
||||
first_name: string
|
||||
last_name: string
|
||||
@@ -117,7 +124,7 @@ export async function GET(
|
||||
name: `${emp.first_name} ${emp.last_name}`,
|
||||
clearingNumber: emp.clearing_number,
|
||||
bankAccountNumber: emp.bank_account_number,
|
||||
netSalary: sre.net_salary,
|
||||
netSalary: effectiveNet,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -98,14 +98,19 @@ export async function GET(
|
||||
}
|
||||
|
||||
const employees: Pain001Employee[] = runEmployees
|
||||
.filter(sre => sre.net_salary > 0)
|
||||
.map(sre => {
|
||||
const effectiveNet =
|
||||
sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
|
||||
return { sre, effectiveNet }
|
||||
})
|
||||
.filter(({ effectiveNet }) => effectiveNet > 0)
|
||||
.map(({ sre, effectiveNet }) => {
|
||||
const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string }
|
||||
return {
|
||||
name: `${emp.first_name} ${emp.last_name}`,
|
||||
clearingNumber: emp.clearing_number,
|
||||
bankAccountNumber: emp.bank_account_number,
|
||||
netSalary: sre.net_salary,
|
||||
netSalary: effectiveNet,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -89,9 +89,39 @@ export async function GET(
|
||||
taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}`
|
||||
}
|
||||
|
||||
// Build breakdown steps from calculation_breakdown
|
||||
// Build breakdown steps from calculation_breakdown, then append rows for
|
||||
// any manual overrides so the breakdown matches the displayed totals.
|
||||
// The engine-computed rows stay for transparency ("this is what was
|
||||
// computed"), and override rows below them show the manual adjustment and
|
||||
// its reason ("this is what was actually applied").
|
||||
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number }> } | null
|
||||
const breakdownSteps = breakdown?.steps
|
||||
const baseSteps = breakdown?.steps ?? []
|
||||
const overrideSteps: Array<{ label: string; formula: string; output: number }> = []
|
||||
const reason = (sre.override_reason as string | null) || 'manuell justering'
|
||||
if (sre.tax_withheld_override !== null && sre.tax_withheld_override !== undefined) {
|
||||
overrideSteps.push({
|
||||
label: 'Manuell justering: Skatteavdrag',
|
||||
formula: reason,
|
||||
output: Number(sre.tax_withheld_override),
|
||||
})
|
||||
}
|
||||
if (sre.avgifter_basis_override !== null && sre.avgifter_basis_override !== undefined) {
|
||||
overrideSteps.push({
|
||||
label: 'Manuell justering: Avgiftsunderlag',
|
||||
formula: reason,
|
||||
output: Number(sre.avgifter_basis_override),
|
||||
})
|
||||
}
|
||||
if (sre.avgifter_amount_override !== null && sre.avgifter_amount_override !== undefined) {
|
||||
overrideSteps.push({
|
||||
label: 'Manuell justering: Arbetsgivaravgifter',
|
||||
formula: reason,
|
||||
output: Number(sre.avgifter_amount_override),
|
||||
})
|
||||
}
|
||||
const breakdownSteps = baseSteps.length > 0 || overrideSteps.length > 0
|
||||
? [...baseSteps, ...overrideSteps]
|
||||
: undefined
|
||||
|
||||
// Build bank account display (masked)
|
||||
let bankAccount: string | undefined
|
||||
@@ -100,6 +130,13 @@ export async function GET(
|
||||
bankAccount = `${emp.clearing_number}-****${lastDigits}`
|
||||
}
|
||||
|
||||
// Honor advanced-mode per-employee overrides (tax/avgifter) on the payslip
|
||||
// so the employee sees the same effective values that are booked and AGI-
|
||||
// reported.
|
||||
const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld
|
||||
const effectiveAvgifter = sre.avgifter_amount_override ?? sre.avgifter_amount
|
||||
const effectiveNet = sre.net_salary + (sre.tax_withheld - effectiveTax)
|
||||
|
||||
const data: PayslipData = {
|
||||
companyName: company.name,
|
||||
companyOrgNumber: company.org_number || '',
|
||||
@@ -111,14 +148,14 @@ export async function GET(
|
||||
paymentDate: run.payment_date,
|
||||
lineItems,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
netSalary: sre.net_salary,
|
||||
taxWithheld: effectiveTax,
|
||||
netSalary: effectiveNet,
|
||||
taxReference,
|
||||
avgifterRate: sre.avgifter_rate,
|
||||
avgifterAmount: sre.avgifter_amount,
|
||||
avgifterAmount: effectiveAvgifter,
|
||||
vacationAccrual: sre.vacation_accrual,
|
||||
vacationAccrualAvgifter: sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + effectiveAvgifter + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
ytdGross: sre.ytd_gross,
|
||||
ytdTax: sre.ytd_tax,
|
||||
ytdNet: sre.ytd_net,
|
||||
@@ -135,7 +172,7 @@ export async function GET(
|
||||
return new Response(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||
'Content-Disposition': `inline; filename="${fileName}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ async function _sendPayslipsImpl(
|
||||
taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}`
|
||||
}
|
||||
|
||||
const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld
|
||||
const effectiveAvgifter = sre.avgifter_amount_override ?? sre.avgifter_amount
|
||||
const effectiveNet = sre.net_salary + (sre.tax_withheld - effectiveTax)
|
||||
|
||||
const data: PayslipData = {
|
||||
companyName: company.name,
|
||||
companyOrgNumber: company.org_number || '',
|
||||
@@ -133,14 +137,14 @@ async function _sendPayslipsImpl(
|
||||
paymentDate: run.payment_date,
|
||||
lineItems,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
netSalary: sre.net_salary,
|
||||
taxWithheld: effectiveTax,
|
||||
netSalary: effectiveNet,
|
||||
taxReference,
|
||||
avgifterRate: sre.avgifter_rate,
|
||||
avgifterAmount: sre.avgifter_amount,
|
||||
avgifterAmount: effectiveAvgifter,
|
||||
vacationAccrual: sre.vacation_accrual,
|
||||
vacationAccrualAvgifter: sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + effectiveAvgifter + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
ytdGross: sre.ytd_gross,
|
||||
ytdTax: sre.ytd_tax,
|
||||
ytdNet: sre.ytd_net,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
|
||||
import type { BASAccount } from '@/types'
|
||||
|
||||
interface AddAccountDialogProps {
|
||||
@@ -26,27 +27,6 @@ interface AddAccountDialogProps {
|
||||
initialAccountName?: string
|
||||
}
|
||||
|
||||
function deriveAccountType(accountNumber: string): { type: string; balance: string } {
|
||||
const cls = parseInt(accountNumber[0])
|
||||
switch (cls) {
|
||||
case 1: return { type: 'asset', balance: 'debit' }
|
||||
case 2: {
|
||||
const group = parseInt(accountNumber.substring(0, 2))
|
||||
if (group <= 20) return { type: 'equity', balance: 'credit' }
|
||||
return { type: 'liability', balance: 'credit' }
|
||||
}
|
||||
case 3: return { type: 'revenue', balance: 'credit' }
|
||||
case 4: case 5: case 6: case 7: return { type: 'expense', balance: 'debit' }
|
||||
case 8: {
|
||||
const group = parseInt(accountNumber.substring(0, 2))
|
||||
if (group >= 83 && group <= 83) return { type: 'revenue', balance: 'credit' }
|
||||
if (group >= 84 && group <= 84) return { type: 'expense', balance: 'debit' }
|
||||
return { type: 'expense', balance: 'debit' }
|
||||
}
|
||||
default: return { type: 'expense', balance: 'debit' }
|
||||
}
|
||||
}
|
||||
|
||||
export function AddAccountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -73,12 +53,12 @@ export function AddAccountDialog({
|
||||
setAccountName(initialAccountName ?? '')
|
||||
setError('')
|
||||
if (num.length === 4) {
|
||||
setNormalBalance(deriveAccountType(num).balance as 'debit' | 'credit')
|
||||
setNormalBalance(classifyAccount(num).normal_balance)
|
||||
}
|
||||
}, [open, initialAccountNumber, initialAccountName])
|
||||
|
||||
const isBASMatch = accountNumber.length === 4 && isStandardBASAccount(accountNumber)
|
||||
const derived = accountNumber.length === 4 ? deriveAccountType(accountNumber) : null
|
||||
const derived = accountNumber.length === 4 ? classifyAccount(accountNumber) : null
|
||||
|
||||
async function handleCreate() {
|
||||
setError('')
|
||||
@@ -101,7 +81,7 @@ export function AddAccountDialog({
|
||||
body: JSON.stringify({
|
||||
account_number: accountNumber,
|
||||
account_name: accountName.trim(),
|
||||
account_type: derived?.type || 'expense',
|
||||
account_type: derived?.account_type || 'expense',
|
||||
normal_balance: normalBalance,
|
||||
description: description || null,
|
||||
default_vat_code: defaultVatCode || null,
|
||||
@@ -160,8 +140,7 @@ export function AddAccountDialog({
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 4)
|
||||
setAccountNumber(v)
|
||||
if (v.length === 4) {
|
||||
const d = deriveAccountType(v)
|
||||
setNormalBalance(d.balance as 'debit' | 'credit')
|
||||
setNormalBalance(classifyAccount(v).normal_balance)
|
||||
}
|
||||
}}
|
||||
placeholder="T.ex. 1935"
|
||||
@@ -187,7 +166,12 @@ export function AddAccountDialog({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-detekterad typ:{' '}
|
||||
<span className="font-medium">
|
||||
{derived.type === 'asset' ? 'Tillgång' : derived.type === 'liability' ? 'Skuld' : derived.type === 'equity' ? 'Eget kapital' : derived.type === 'revenue' ? 'Intäkt' : 'Kostnad'}
|
||||
{derived.account_type === 'asset' ? 'Tillgång'
|
||||
: derived.account_type === 'liability' ? 'Skuld'
|
||||
: derived.account_type === 'equity' ? 'Eget kapital'
|
||||
: derived.account_type === 'untaxed_reserves' ? 'Obeskattade reserver'
|
||||
: derived.account_type === 'revenue' ? 'Intäkt'
|
||||
: 'Kostnad'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -45,12 +45,8 @@ export default function ChartOfAccountsManager() {
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
|
||||
const classLabel = (cls: number): string => {
|
||||
const key = `class_${cls}` as const
|
||||
try {
|
||||
return t(key)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
if (cls < 1 || cls > 8) return ''
|
||||
return t(`class_${cls}` as const)
|
||||
}
|
||||
|
||||
const typeLabel = (type: string): string => {
|
||||
|
||||
@@ -12,15 +12,33 @@ import {
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Info,
|
||||
Undo2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import type { ImportResult } from '@/lib/import/types'
|
||||
|
||||
interface ImportResultStepProps {
|
||||
result: ImportResult
|
||||
onNewImport: () => void
|
||||
onUndo?: (importId: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
export default function ImportResultStep({ result, onNewImport }: ImportResultStepProps) {
|
||||
export default function ImportResultStep({ result, onNewImport, onUndo }: ImportResultStepProps) {
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const handleUndoClick = async () => {
|
||||
if (!result.importId || !onUndo) return
|
||||
const ok = await confirm({
|
||||
title: 'Ångra hela importen?',
|
||||
description: `Detta raderar ${result.journalEntriesCreated} verifikation${result.journalEntriesCreated === 1 ? '' : 'er'} och rensar ingående balanser från den här importen. Bifogade dokument blir okopplade men finns kvar.`,
|
||||
confirmLabel: 'Ångra import',
|
||||
})
|
||||
if (!ok) return
|
||||
await onUndo(result.importId)
|
||||
}
|
||||
const hasErrors = result.errors.length > 0
|
||||
const skipped = result.details?.skippedVouchers
|
||||
|
||||
@@ -57,6 +75,38 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* IB resync notice (prior-year backfill) */}
|
||||
{result.success && result.nextPeriodIBResync && (
|
||||
<Card className="border-success/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
Ingående balanser synkades om
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Eftersom du importerade ett tidigare räkenskapsår uppdaterades ingående balanser för{' '}
|
||||
<span className="font-medium">{result.nextPeriodIBResync.nextPeriodName}</span>{' '}
|
||||
automatiskt (gammal IB makulerad, ny IB skapad från utgående balans).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{result.success && result.nextPeriodIBResyncSkipped && (
|
||||
<Card className="border-warning/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base text-warning">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Ingående balanser för {result.nextPeriodIBResyncSkipped.nextPeriodName} kunde inte synkas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Nästa räkenskapsår är låst eller stängt. Lås upp perioden och kör importen igen om du
|
||||
vill att ingående balanser ska uppdateras automatiskt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Statistics */}
|
||||
{result.success && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
@@ -250,10 +300,18 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
|
||||
<Button variant="outline" className="min-h-11" onClick={onNewImport}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Ny import
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button variant="outline" className="min-h-11" onClick={onNewImport}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Ny import
|
||||
</Button>
|
||||
{result.success && result.importId && onUndo && (
|
||||
<Button variant="outline" className="min-h-11 text-destructive hover:text-destructive" onClick={handleUndoClick}>
|
||||
<Undo2 className="mr-2 h-4 w-4" />
|
||||
Ångra import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{result.success && (
|
||||
<>
|
||||
@@ -273,6 +331,8 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
|
||||
interface VoucherCandidate {
|
||||
journal_entry_id: string
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
entry_date: string
|
||||
description: string
|
||||
ar_credit_amount: number
|
||||
currency: string
|
||||
ar_line_currency: string | null
|
||||
period_locked: boolean
|
||||
confidence: number
|
||||
match_reason: string
|
||||
}
|
||||
|
||||
interface LinkVoucherPickerProps {
|
||||
invoiceId: string
|
||||
invoiceCurrency: string
|
||||
onLinked: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function voucherLabel(c: VoucherCandidate): string {
|
||||
if (c.voucher_series && c.voucher_number != null) {
|
||||
return `${c.voucher_series}-${c.voucher_number}`
|
||||
}
|
||||
if (c.voucher_number != null) return String(c.voucher_number)
|
||||
return c.journal_entry_id.slice(0, 8)
|
||||
}
|
||||
|
||||
function confidenceBadge(confidence: number): {
|
||||
variant: 'success' | 'secondary' | 'outline'
|
||||
key: 'high' | 'medium' | 'low'
|
||||
} {
|
||||
if (confidence >= 0.9) return { variant: 'success', key: 'high' }
|
||||
if (confidence >= 0.7) return { variant: 'secondary', key: 'medium' }
|
||||
return { variant: 'outline', key: 'low' }
|
||||
}
|
||||
|
||||
export default function LinkVoucherPicker({
|
||||
invoiceId,
|
||||
invoiceCurrency,
|
||||
onLinked,
|
||||
onCancel,
|
||||
}: LinkVoucherPickerProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('invoice_link_voucher')
|
||||
|
||||
const [candidates, setCandidates] = useState<VoucherCandidate[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoiceId}/voucher-candidates`)
|
||||
if (!response.ok) {
|
||||
if (cancelled) return
|
||||
setCandidates([])
|
||||
return
|
||||
}
|
||||
const body = await response.json()
|
||||
if (cancelled) return
|
||||
setCandidates(body?.data?.candidates ?? [])
|
||||
} catch {
|
||||
if (!cancelled) setCandidates([])
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [invoiceId])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!candidates) return [] as VoucherCandidate[]
|
||||
if (!search.trim()) return candidates
|
||||
const needle = search.trim().toLowerCase()
|
||||
return candidates.filter((c) => {
|
||||
const label = voucherLabel(c).toLowerCase()
|
||||
const desc = c.description?.toLowerCase() ?? ''
|
||||
return label.includes(needle) || desc.includes(needle)
|
||||
})
|
||||
}, [candidates, search])
|
||||
|
||||
const selected = useMemo(
|
||||
() => (selectedId ? filtered.find((c) => c.journal_entry_id === selectedId) ?? null : null),
|
||||
[filtered, selectedId],
|
||||
)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selected) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoiceId}/link-to-voucher`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: selected.journal_entry_id }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null)
|
||||
toast({
|
||||
title: t('link_failed_title'),
|
||||
description: getErrorMessage(body, {
|
||||
context: 'invoice',
|
||||
statusCode: response.status,
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('link_success_title'), variant: 'success' })
|
||||
onLinked()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('link_failed_title'),
|
||||
description: getErrorMessage(err, { context: 'invoice' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('intro')}</p>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm font-medium">{t('empty_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('empty_description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-[320px] overflow-y-auto">
|
||||
{filtered.map((c) => {
|
||||
const badge = confidenceBadge(c.confidence)
|
||||
const isSelected = selectedId === c.journal_entry_id
|
||||
return (
|
||||
<li key={c.journal_entry_id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(c.journal_entry_id)}
|
||||
className={`w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60 ${
|
||||
isSelected ? 'border-foreground' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{voucherLabel(c)}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatDate(c.entry_date)}
|
||||
</span>
|
||||
<Badge variant={badge.variant}>{t(`confidence_${badge.key}`)}</Badge>
|
||||
{c.period_locked && (
|
||||
<Badge variant="outline">{t('period_locked')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{c.match_reason || c.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-sm font-medium tabular-nums">
|
||||
{formatCurrency(c.ar_credit_amount, invoiceCurrency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="rounded-lg border bg-secondary/40 p-3">
|
||||
<p className="text-sm">
|
||||
{t('confirmation', {
|
||||
voucher: voucherLabel(selected),
|
||||
amount: formatCurrency(selected.ar_credit_amount, invoiceCurrency),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('no_new_je_note')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={onCancel} disabled={submitting} className="min-h-11">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selected || submitting}
|
||||
className="min-h-11"
|
||||
>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,8 +15,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
|
||||
import { proposePaymentLines } from '@/lib/bookkeeping/propose-payment-lines'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -77,12 +79,14 @@ export default function PaymentBookingDialog({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [duplicateCandidates, setDuplicateCandidates] = useState<DuplicateCandidate[] | null>(null)
|
||||
const [tab, setTab] = useState<'new' | 'existing'>('new')
|
||||
|
||||
// Load accounts and settings when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIsInitialized(false)
|
||||
setDuplicateCandidates(null)
|
||||
setTab('new')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -314,12 +318,30 @@ export default function PaymentBookingDialog({
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : !isInitialized ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as 'new' | 'existing')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="new">{t('tab_new_payment')}</TabsTrigger>
|
||||
<TabsTrigger value="existing">{t('tab_existing_voucher')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<LinkVoucherPicker
|
||||
invoiceId={invoice.id}
|
||||
invoiceCurrency={invoice.currency}
|
||||
onLinked={() => {
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
}}
|
||||
onCancel={() => setTab('new')}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="new" className="mt-4">
|
||||
{!isInitialized ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Payment date */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-date">{t('payment_date_label')}</Label>
|
||||
@@ -473,32 +495,37 @@ export default function PaymentBookingDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting} className="w-full sm:w-auto min-h-11">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
{duplicateCandidates && duplicateCandidates.length > 0 ? (
|
||||
<Button
|
||||
onClick={handleForceSubmit}
|
||||
disabled={!isBalanced || isSubmitting}
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('book_anyway')}
|
||||
{(duplicateCandidates && duplicateCandidates.length > 0) || tab === 'new' ? (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting} className="w-full sm:w-auto min-h-11">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isBalanced || isSubmitting || !isInitialized}
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('confirm_and_book')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
{duplicateCandidates && duplicateCandidates.length > 0 ? (
|
||||
<Button
|
||||
onClick={handleForceSubmit}
|
||||
disabled={!isBalanced || isSubmitting}
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('book_anyway')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isBalanced || isSubmitting || !isInitialized}
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('confirm_and_book')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Settings2, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface SalaryOverridePanelProps {
|
||||
runId: string
|
||||
employeeId: string
|
||||
taxWithheld: number
|
||||
taxOverride: number | null
|
||||
avgifterAmount: number
|
||||
avgifterOverride: number | null
|
||||
avgifterBasis: number
|
||||
avgifterBasisOverride: number | null
|
||||
reason: string | null
|
||||
onSaved: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function num(v: string): number | null {
|
||||
const trimmed = v.trim()
|
||||
if (!trimmed) return null
|
||||
const n = Number(trimmed.replace(',', '.'))
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
|
||||
const { toast } = useToast()
|
||||
const [expanded, setExpanded] = useState(
|
||||
props.taxOverride !== null ||
|
||||
props.avgifterOverride !== null ||
|
||||
props.avgifterBasisOverride !== null,
|
||||
)
|
||||
const [taxStr, setTaxStr] = useState(props.taxOverride !== null ? String(props.taxOverride) : '')
|
||||
const [avgStr, setAvgStr] = useState(
|
||||
props.avgifterOverride !== null ? String(props.avgifterOverride) : '',
|
||||
)
|
||||
const [basisStr, setBasisStr] = useState(
|
||||
props.avgifterBasisOverride !== null ? String(props.avgifterBasisOverride) : '',
|
||||
)
|
||||
const [reason, setReason] = useState(props.reason ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const hasOverride =
|
||||
props.taxOverride !== null ||
|
||||
props.avgifterOverride !== null ||
|
||||
props.avgifterBasisOverride !== null
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const body = {
|
||||
tax_withheld_override: num(taxStr),
|
||||
avgifter_amount_override: num(avgStr),
|
||||
avgifter_basis_override: num(basisStr),
|
||||
reason: reason.trim() || null,
|
||||
}
|
||||
const res = await fetch(`/api/salary/runs/${props.runId}/employees/${props.employeeId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: 'Kunde inte spara justering',
|
||||
description: typeof data?.error === 'string' ? data.error : 'Okänt fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: 'Justering sparad' })
|
||||
props.onSaved()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte spara justering',
|
||||
description: err instanceof Error ? err.message : 'Okänt fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/salary/runs/${props.runId}/employees/${props.employeeId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tax_withheld_override: null,
|
||||
avgifter_amount_override: null,
|
||||
avgifter_basis_override: null,
|
||||
reason: null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
toast({
|
||||
title: 'Kunde inte rensa justering',
|
||||
description: typeof data?.error === 'string' ? data.error : 'Okänt fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setTaxStr('')
|
||||
setAvgStr('')
|
||||
setBasisStr('')
|
||||
setReason('')
|
||||
toast({ title: 'Justering rensad' })
|
||||
props.onSaved()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">Avancerat läge</CardTitle>
|
||||
{hasOverride && <Badge variant="warning">Justerat</Badge>}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{expanded ? 'Dölj' : 'Visa'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{expanded && (
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Justera skatteavdrag eller arbetsgivaravgift för den här anställde — t.ex. för FoU-avdrag eller
|
||||
jämkning. Justerade värden används vid bokföring och AGI-rapportering. Endast tillåtet i
|
||||
granskningsläge.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tax_override" className="text-xs">
|
||||
Skatteavdrag (kr)
|
||||
</Label>
|
||||
<Input
|
||||
id="tax_override"
|
||||
inputMode="decimal"
|
||||
placeholder={String(props.taxWithheld)}
|
||||
value={taxStr}
|
||||
onChange={(e) => setTaxStr(e.target.value)}
|
||||
disabled={props.disabled || saving}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Beräknat: <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="avgifter_override" className="text-xs">
|
||||
Arbetsgivaravgifter (kr)
|
||||
</Label>
|
||||
<Input
|
||||
id="avgifter_override"
|
||||
inputMode="decimal"
|
||||
placeholder={String(props.avgifterAmount)}
|
||||
value={avgStr}
|
||||
onChange={(e) => setAvgStr(e.target.value)}
|
||||
disabled={props.disabled || saving}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Beräknat: <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="avgifter_basis_override" className="text-xs">
|
||||
Avgiftsunderlag (kr)
|
||||
</Label>
|
||||
<Input
|
||||
id="avgifter_basis_override"
|
||||
inputMode="decimal"
|
||||
placeholder={String(props.avgifterBasis)}
|
||||
value={basisStr}
|
||||
onChange={(e) => setBasisStr(e.target.value)}
|
||||
disabled={props.disabled || saving}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Beräknat: <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="override_reason" className="text-xs">
|
||||
Anledning (krävs vid justering)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="override_reason"
|
||||
rows={2}
|
||||
placeholder="T.ex. FoU-avdrag 10 % på arbetsgivaravgifter, jämkning enligt beslut från Skatteverket"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
disabled={props.disabled || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={props.disabled || saving}>
|
||||
{saving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Spara justering
|
||||
</Button>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
disabled={props.disabled || saving}
|
||||
>
|
||||
Rensa justering
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col p-4 sm:top-4 sm:right-4 sm:bottom-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -24,7 +24,7 @@ const ToastViewport = React.forwardRef<
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -47,6 +47,10 @@ import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
|
||||
import {
|
||||
findMatchingVouchersForInvoice,
|
||||
validateVoucherForInvoiceLink,
|
||||
} from '@/lib/invoices/voucher-matching'
|
||||
import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
||||
import { closePeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service'
|
||||
@@ -4319,6 +4323,157 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_find_voucher_candidates_for_invoice',
|
||||
description: 'List posted verifikat that credit kundfordran (1510) and could be the payment for this invoice. Use before gnubok_link_invoice_to_voucher when the user wants to mark a faktura paid against an existing verifikation (no new bokföring).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
invoice_id: { type: 'string', description: 'UUID of the invoice to find candidates for' },
|
||||
limit: { type: 'number', description: 'Max candidates to return (default 10, max 50)' },
|
||||
},
|
||||
required: ['invoice_id'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
invoice_id: { type: 'string' },
|
||||
invoice_status: { type: 'string' },
|
||||
candidates: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
required: ['invoice_id', 'candidates'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const invoiceId = args.invoice_id as string
|
||||
if (!invoiceId) throw new Error('invoice_id is required')
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 10), 50)
|
||||
|
||||
const { data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)'
|
||||
)
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (error || !invoice) throw new Error('Invoice not found')
|
||||
|
||||
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||||
return {
|
||||
invoice_id: invoiceId,
|
||||
invoice_status: invoice.status,
|
||||
candidates: [],
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = await findMatchingVouchersForInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
invoice as never,
|
||||
{ limit },
|
||||
)
|
||||
return {
|
||||
invoice_id: invoiceId,
|
||||
invoice_status: invoice.status,
|
||||
candidates,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_link_invoice_to_voucher',
|
||||
description: 'Markera en faktura som betald genom att länka till en befintlig verifikation som redan krediterar kundfordran (1510). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_invoice först.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
invoice_id: { type: 'string', description: 'UUID of the invoice to mark paid' },
|
||||
journal_entry_id: { type: 'string', description: 'UUID of the existing posted verifikat to link' },
|
||||
notes: { type: 'string', description: 'Optional note stored on the invoice_payments row' },
|
||||
},
|
||||
required: ['invoice_id', 'journal_entry_id'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const invoiceId = args.invoice_id as string
|
||||
const journalEntryId = args.journal_entry_id as string
|
||||
const notes = (args.notes as string | undefined) ?? undefined
|
||||
if (!invoiceId || !journalEntryId) {
|
||||
throw new Error('invoice_id and journal_entry_id are required')
|
||||
}
|
||||
|
||||
const { data: invoice, error: invErr } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)'
|
||||
)
|
||||
.eq('id', invoiceId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
if (invErr || !invoice) throw new Error('Invoice not found')
|
||||
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||||
throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)')
|
||||
}
|
||||
|
||||
const validation = await validateVoucherForInvoiceLink(
|
||||
supabase,
|
||||
companyId,
|
||||
invoice as never,
|
||||
journalEntryId,
|
||||
)
|
||||
if (!validation.ok) {
|
||||
throw new Error(
|
||||
`${validation.code}${validation.details ? `: ${JSON.stringify(validation.details)}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
const voucherLabel = validation.voucher.voucher_series && validation.voucher.voucher_number != null
|
||||
? `${validation.voucher.voucher_series}-${validation.voucher.voucher_number}`
|
||||
: journalEntryId.slice(0, 8)
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
'link_invoice_voucher',
|
||||
`Länka verifikat ${voucherLabel} → faktura ${invoice.invoice_number ?? invoiceId.slice(0, 8)}`,
|
||||
{ invoice_id: invoiceId, journal_entry_id: journalEntryId, notes },
|
||||
{
|
||||
invoice_number: invoice.invoice_number,
|
||||
invoice_currency: invoice.currency,
|
||||
invoice_remaining: invoice.remaining_amount,
|
||||
voucher_label: voucherLabel,
|
||||
voucher_date: validation.voucher.entry_date,
|
||||
voucher_description: validation.voucher.description,
|
||||
ar_credit_amount: validation.arCreditAmount,
|
||||
payment_amount: validation.paymentAmount,
|
||||
will_be_fully_paid: validation.isFullyPaid,
|
||||
remaining_after: validation.remainingAfter,
|
||||
customer_name: (invoice.customer as unknown as { name?: string } | null)?.name ?? null,
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'After approval the invoice transitions to paid (or partially_paid). No new verifikat is created — the existing voucher is the payment posting.',
|
||||
tool: 'gnubok_get_ar_ledger',
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_auto_match_period',
|
||||
description: "Bulk reconciliation: scan unmatched income transactions in a date range and propose invoice matches with confidence + reasoning. dry_run=true (default) previews without staging; dry_run=false stages every match above confidence_threshold as a pending operation.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+20
-1
@@ -2350,7 +2350,25 @@
|
||||
"load_dialog_failed_title": "Could not load the bookkeeping dialog",
|
||||
"try_again": "Try again.",
|
||||
"mark_paid_failed": "Could not mark as paid",
|
||||
"booking_failed_title": "Bookkeeping failed"
|
||||
"booking_failed_title": "Bookkeeping failed",
|
||||
"tab_new_payment": "Post new payment",
|
||||
"tab_existing_voucher": "Existing journal entry"
|
||||
},
|
||||
"invoice_link_voucher": {
|
||||
"intro": "Pick an existing posted journal entry that credits accounts receivable (1510). No new entry is created — you only link the existing one as the payment.",
|
||||
"search_placeholder": "Search by voucher number or description…",
|
||||
"confidence_high": "Strong match",
|
||||
"confidence_medium": "Likely match",
|
||||
"confidence_low": "Weak match",
|
||||
"period_locked": "Locked period",
|
||||
"empty_title": "No matching journal entries found",
|
||||
"empty_description": "No posted entry credits 1510 in this invoice's currency and date window. Post a new payment instead, or correct the prior bookkeeping first.",
|
||||
"confirmation": "This links voucher {voucher} ({amount}) as the payment for the invoice.",
|
||||
"no_new_je_note": "No new bookkeeping is created — the existing journal entry is the payment posting.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Link as payment",
|
||||
"link_success_title": "Journal entry linked to invoice",
|
||||
"link_failed_title": "Could not link the journal entry"
|
||||
},
|
||||
"supplier_invoice_editor": {
|
||||
"page_title": "Register supplier invoice",
|
||||
@@ -2784,6 +2802,7 @@
|
||||
"delete_entry": "Delete journal entry",
|
||||
"create_correction": "Create correction entry",
|
||||
"copy_entry": "Copy journal entry",
|
||||
"edit_entry": "Edit",
|
||||
"details_title": "Journal entry details",
|
||||
"field_date": "Date",
|
||||
"field_posted_at": "Posted",
|
||||
|
||||
+20
-1
@@ -2350,7 +2350,25 @@
|
||||
"load_dialog_failed_title": "Kunde inte ladda bokföringsdialog",
|
||||
"try_again": "Försök igen.",
|
||||
"mark_paid_failed": "Kunde inte markera som betald",
|
||||
"booking_failed_title": "Bokföring misslyckades"
|
||||
"booking_failed_title": "Bokföring misslyckades",
|
||||
"tab_new_payment": "Bokför ny betalning",
|
||||
"tab_existing_voucher": "Befintlig verifikation"
|
||||
},
|
||||
"invoice_link_voucher": {
|
||||
"intro": "Välj en befintlig verifikation som krediterar kundfordran (1510). Ingen ny verifikation skapas — du länkar bara den befintliga som betalning.",
|
||||
"search_placeholder": "Sök på verifikatnummer eller beskrivning…",
|
||||
"confidence_high": "Hög träff",
|
||||
"confidence_medium": "Möjlig träff",
|
||||
"confidence_low": "Svag träff",
|
||||
"period_locked": "Låst period",
|
||||
"empty_title": "Inga matchande verifikationer hittades",
|
||||
"empty_description": "Det finns ingen bokförd verifikation som krediterar 1510 i fakturans valuta och period. Bokför istället en ny betalning, eller rätta tidigare bokföring först.",
|
||||
"confirmation": "Detta länkar verifikat {voucher} ({amount}) som betalning för fakturan.",
|
||||
"no_new_je_note": "Ingen ny bokföring skapas — den befintliga verifikationen utgör betalningsposten.",
|
||||
"cancel": "Avbryt",
|
||||
"confirm": "Länka som betalning",
|
||||
"link_success_title": "Verifikationen länkades till fakturan",
|
||||
"link_failed_title": "Kunde inte länka verifikationen"
|
||||
},
|
||||
"supplier_invoice_editor": {
|
||||
"page_title": "Registrera leverantörsfaktura",
|
||||
@@ -2784,6 +2802,7 @@
|
||||
"delete_entry": "Radera verifikat",
|
||||
"create_correction": "Skapa ändringsverifikation",
|
||||
"copy_entry": "Kopiera verifikat",
|
||||
"edit_entry": "Redigera",
|
||||
"details_title": "Verifikationsdetaljer",
|
||||
"field_date": "Datum",
|
||||
"field_posted_at": "Bokförd",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { config } from 'dotenv'
|
||||
config({ path: '.env.local' })
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { getBASReference } from '../lib/bookkeeping/bas-reference'
|
||||
import { classifyAccount } from '../lib/bookkeeping/account-classifier'
|
||||
import { computeSRUCode } from '../lib/bookkeeping/bas-data/sru-mapping'
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run')
|
||||
@@ -64,25 +65,6 @@ const NON_BAS_OVERRIDES: Record<string, AccountOverride> = {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deriveAccountType(accountNumber: string): 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves' {
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
const group = accountNumber.substring(0, 2)
|
||||
|
||||
if (classNum === 1) return 'asset'
|
||||
if (classNum === 2) {
|
||||
if (group === '20') return 'equity'
|
||||
if (group === '21') return 'untaxed_reserves'
|
||||
return 'liability'
|
||||
}
|
||||
if (classNum === 3) return 'revenue'
|
||||
return 'expense'
|
||||
}
|
||||
|
||||
function deriveNormalBalance(accountNumber: string): 'debit' | 'credit' {
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
return classNum <= 1 || classNum >= 4 ? 'debit' : 'credit'
|
||||
}
|
||||
|
||||
async function getUsedAccountNumbers(userId: string): Promise<Set<string>> {
|
||||
const usedSet = new Set<string>()
|
||||
const PAGE_SIZE = 1000
|
||||
@@ -178,8 +160,9 @@ async function backfillForUser(userId: string): Promise<number> {
|
||||
// Check hardcoded overrides (for company-specific accounts with known metadata)
|
||||
const override = NON_BAS_OVERRIDES[accountNumber]
|
||||
if (override) {
|
||||
const accountType = override.account_type ?? deriveAccountType(accountNumber)
|
||||
const normalBalance = override.normal_balance ?? deriveNormalBalance(accountNumber)
|
||||
const classified = classifyAccount(accountNumber)
|
||||
const accountType = override.account_type ?? classified.account_type
|
||||
const normalBalance = override.normal_balance ?? classified.normal_balance
|
||||
const classNum = parseInt(accountNumber.charAt(0), 10)
|
||||
return {
|
||||
user_id: userId,
|
||||
@@ -206,14 +189,15 @@ async function backfillForUser(userId: string): Promise<number> {
|
||||
console.warn(` WARNING: Account ${accountNumber} not in BAS or SIE — deriving all metadata`)
|
||||
}
|
||||
|
||||
const classified = classifyAccount(accountNumber)
|
||||
return {
|
||||
user_id: userId,
|
||||
account_number: accountNumber,
|
||||
account_name: sieName ?? `Konto ${accountNumber}`,
|
||||
account_class: classNum,
|
||||
account_group: accountNumber.substring(0, 2),
|
||||
account_type: deriveAccountType(accountNumber),
|
||||
normal_balance: deriveNormalBalance(accountNumber),
|
||||
account_type: classified.account_type,
|
||||
normal_balance: classified.normal_balance,
|
||||
sru_code: computeSRUCode(accountNumber),
|
||||
k2_excluded: false,
|
||||
plan_type: 'full_bas' as const,
|
||||
|
||||
@@ -59,6 +59,13 @@ export interface DiscoveredAtom {
|
||||
schema_version: number
|
||||
}
|
||||
|
||||
// Normalize CRLF → LF so frontmatter parsing and body inlining are
|
||||
// platform-independent (Windows checkouts ship .md files with CRLF unless
|
||||
// .gitattributes forces LF, which it doesn't for *.md).
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
// ── Frontmatter parsing ────────────────────────────────────────────────
|
||||
// SKILL.md files use YAML frontmatter with `name`, `description`, and optionally
|
||||
// `tier`, `sni_prefixes`, `trigger_signals`, `estimated_tokens`, `version`. We
|
||||
@@ -227,7 +234,7 @@ async function readAtom(
|
||||
return []
|
||||
}
|
||||
|
||||
const content = await readFile(skillPath, 'utf8')
|
||||
const content = normalizeLineEndings(await readFile(skillPath, 'utf8'))
|
||||
const fm = extractFrontmatter(content)
|
||||
if (!fm) {
|
||||
console.warn(` skipped ${relative(rootDir, skillPath)} — no frontmatter`)
|
||||
@@ -316,7 +323,7 @@ async function readReferenceFiles(skillDir: string): Promise<ReferenceFile[]> {
|
||||
const files = (await walkMarkdown(refsDir)).sort()
|
||||
const out: ReferenceFile[] = []
|
||||
for (const absPath of files) {
|
||||
const body = await readFile(absPath, 'utf8')
|
||||
const body = normalizeLineEndings(await readFile(absPath, 'utf8'))
|
||||
const relFromRefs = relative(refsDir, absPath).split(sep).join('/')
|
||||
out.push({
|
||||
absPath,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
-- Fix delete_last_voucher RPC to clear the fiscal-period IB pointer before
|
||||
-- deleting an opening-balance entry.
|
||||
--
|
||||
-- Background: 20260509103736_allow_draft_voucher_delete.sql added support
|
||||
-- for deleting drafts and the last posted voucher in a series. It bypasses
|
||||
-- enforce_journal_entry_immutability with gnubok.allow_delete='true', but
|
||||
-- when the target is the opening-balance entry (referenced by
|
||||
-- fiscal_periods.opening_balance_entry_id), the trigger
|
||||
-- enforce_opening_balance_immutability blocks the deletion path because
|
||||
-- the FK is still held by fiscal_periods. That trigger does NOT honor the
|
||||
-- gnubok.allow_delete GUC.
|
||||
--
|
||||
-- Symptom: customer report (Nice Problems AB) -- "IB-verifikat (A1) går
|
||||
-- inte radera, 'Kunde inte radera'".
|
||||
--
|
||||
-- Fix mirrors the two-step pattern already used by replace_sie_import
|
||||
-- (20260526120000_fix_replace_sie_import_hard_delete.sql:113-122):
|
||||
-- 1. Flip opening_balances_set to false in its own UPDATE so the
|
||||
-- immutability trigger lets us change the FK in a second UPDATE.
|
||||
-- 2. Clear opening_balance_entry_id.
|
||||
-- 3. Also clear sie_imports.opening_balance_entry_id if any import row
|
||||
-- pointed to this entry, so the import audit row stays consistent
|
||||
-- without a dangling FK.
|
||||
-- Then proceed with the existing deletion logic.
|
||||
--
|
||||
-- After deletion, getOpeningBalances() (lib/reports/opening-balances.ts)
|
||||
-- falls back to compute-from-history; trial balance and reports remain
|
||||
-- correct (the entry is gone, the period link is gone, BFL trail is in
|
||||
-- audit_log).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_last_voucher(p_company_id uuid, p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_entry record;
|
||||
v_period record;
|
||||
v_max_voucher integer;
|
||||
v_ref_count integer;
|
||||
v_caller_role text;
|
||||
v_snapshot jsonb;
|
||||
v_lines_snapshot jsonb;
|
||||
v_is_period_ib boolean := false;
|
||||
BEGIN
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Only company owners and admins can delete vouchers';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_entry
|
||||
FROM journal_entries
|
||||
WHERE id = p_entry_id
|
||||
AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_entry IS NULL THEN
|
||||
RAISE EXCEPTION 'Journal entry not found';
|
||||
END IF;
|
||||
|
||||
IF v_entry.status NOT IN ('posted', 'draft') THEN
|
||||
RAISE EXCEPTION 'Only posted or draft entries can be deleted (current status: %)', v_entry.status;
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(to_jsonb(l)) INTO v_lines_snapshot
|
||||
FROM journal_entry_lines l
|
||||
WHERE l.journal_entry_id = p_entry_id;
|
||||
|
||||
v_snapshot := to_jsonb(v_entry) || jsonb_build_object('lines', COALESCE(v_lines_snapshot, '[]'::jsonb));
|
||||
|
||||
-- Draft path: simplified deletion (no series, no period checks needed)
|
||||
IF v_entry.status = 'draft' THEN
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
UPDATE document_attachments
|
||||
SET journal_entry_id = NULL
|
||||
WHERE journal_entry_id = p_entry_id;
|
||||
|
||||
DELETE FROM journal_entries WHERE id = p_entry_id;
|
||||
|
||||
INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description)
|
||||
VALUES (
|
||||
v_entry.user_id,
|
||||
'DELETE',
|
||||
'journal_entries',
|
||||
p_entry_id,
|
||||
auth.uid(),
|
||||
v_snapshot,
|
||||
'Deleted draft journal entry (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'voucher_series', v_entry.voucher_series,
|
||||
'voucher_number', v_entry.voucher_number,
|
||||
'was_draft', true
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Posted path
|
||||
SELECT * INTO v_period
|
||||
FROM fiscal_periods
|
||||
WHERE id = v_entry.fiscal_period_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_period.is_closed THEN
|
||||
RAISE EXCEPTION 'Cannot delete voucher in a closed fiscal period';
|
||||
END IF;
|
||||
|
||||
IF v_period.locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot delete voucher in a locked fiscal period';
|
||||
END IF;
|
||||
|
||||
PERFORM 1 FROM voucher_sequences
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series
|
||||
FOR UPDATE;
|
||||
|
||||
SELECT MAX(voucher_number) INTO v_max_voucher
|
||||
FROM journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series
|
||||
AND status NOT IN ('cancelled', 'draft');
|
||||
|
||||
IF v_entry.voucher_number != v_max_voucher THEN
|
||||
RAISE EXCEPTION 'Kan bara radera det sista verifikatet i serien. % har nummer % men senaste är %',
|
||||
v_entry.voucher_series, v_entry.voucher_number, v_max_voucher;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_ref_count
|
||||
FROM journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND status != 'cancelled'
|
||||
AND (reverses_id = p_entry_id OR correction_of_id = p_entry_id);
|
||||
|
||||
IF v_ref_count > 0 THEN
|
||||
RAISE EXCEPTION 'Cannot delete: other entries reference this voucher (% references)',
|
||||
v_ref_count;
|
||||
END IF;
|
||||
|
||||
IF v_entry.reverses_id IS NOT NULL THEN
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
UPDATE journal_entries
|
||||
SET status = 'posted', reversed_by_id = NULL
|
||||
WHERE id = v_entry.reverses_id
|
||||
AND company_id = p_company_id;
|
||||
END IF;
|
||||
|
||||
-- IB pointer clearing: if this entry is the fiscal period's opening-
|
||||
-- balance entry, clear the FK in two steps before deletion. The
|
||||
-- enforce_opening_balance_immutability trigger raises only when both
|
||||
-- opening_balances_set is true AND opening_balance_entry_id changes in
|
||||
-- the same UPDATE, so flip the flag first, then null the FK.
|
||||
v_is_period_ib := (v_period.opening_balance_entry_id = p_entry_id);
|
||||
IF v_is_period_ib THEN
|
||||
UPDATE fiscal_periods
|
||||
SET opening_balances_set = false
|
||||
WHERE id = v_entry.fiscal_period_id;
|
||||
|
||||
UPDATE fiscal_periods
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = v_entry.fiscal_period_id;
|
||||
END IF;
|
||||
|
||||
-- Mirror clear on sie_imports if any import row points at this entry
|
||||
-- (sie_imports.opening_balance_entry_id is SET NULL on delete but we
|
||||
-- clear explicitly so the import row stays consistent and we don't rely
|
||||
-- on cascade ordering).
|
||||
UPDATE sie_imports
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE opening_balance_entry_id = p_entry_id;
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
UPDATE document_attachments
|
||||
SET journal_entry_id = NULL
|
||||
WHERE journal_entry_id = p_entry_id;
|
||||
|
||||
DELETE FROM journal_entries WHERE id = p_entry_id;
|
||||
|
||||
UPDATE voucher_sequences
|
||||
SET last_number = GREATEST(last_number - 1, 0)
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series;
|
||||
|
||||
INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description)
|
||||
VALUES (
|
||||
v_entry.user_id,
|
||||
'DELETE',
|
||||
'journal_entries',
|
||||
p_entry_id,
|
||||
auth.uid(),
|
||||
v_snapshot,
|
||||
'Deleted voucher ' || v_entry.voucher_series || v_entry.voucher_number ||
|
||||
CASE WHEN v_is_period_ib THEN ' (was period IB)' ELSE '' END ||
|
||||
' (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'voucher_series', v_entry.voucher_series,
|
||||
'voucher_number', v_entry.voucher_number,
|
||||
'was_period_ib', v_is_period_ib
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Expand pending_operations.operation_type to include link_invoice_voucher.
|
||||
--
|
||||
-- New op type lets the user mark an invoice as paid by linking an EXISTING
|
||||
-- posted verifikat (whose lines already credit an AR account, default 1510)
|
||||
-- instead of creating a new journal entry. Common after SIE imports, manual
|
||||
-- cash receipts, or any flow where the AR-credit posting landed in the GL
|
||||
-- without invoice linkage. Pure linking — only an invoice_payments row is
|
||||
-- inserted; the verifikat is never modified, so this is safe against
|
||||
-- enforce_period_lock (locked-period vouchers can still be linked).
|
||||
--
|
||||
-- Risk tier: 'medium' (lib/pending-operations/risk-tiers.ts) — reversible by
|
||||
-- deleting the invoice_payments row and reverting invoice status, no booking
|
||||
-- impact. Sits alongside match_transaction_invoice semantically.
|
||||
--
|
||||
-- Also adds the partial unique index that mirrors the existing
|
||||
-- (transaction_id, invoice_id) guard: a single voucher may legitimately
|
||||
-- settle multiple invoices, but linking the same voucher to the same
|
||||
-- invoice twice is rejected at the DB level (matches the
|
||||
-- VOUCHER_ALREADY_LINKED service guard).
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
ADD CONSTRAINT pending_operations_operation_type_check
|
||||
CHECK (operation_type IN (
|
||||
-- Phase 0: original 7 op types
|
||||
'categorize_transaction',
|
||||
'create_customer',
|
||||
'create_invoice',
|
||||
'mark_invoice_paid',
|
||||
'send_invoice',
|
||||
'mark_invoice_sent',
|
||||
'match_transaction_invoice',
|
||||
-- Stream 1 Phase 1: bookkeeping period operations
|
||||
'close_period',
|
||||
'lock_period',
|
||||
'unlock_period',
|
||||
'set_opening_balances',
|
||||
'run_year_end',
|
||||
'run_currency_revaluation',
|
||||
-- Stream 1 Phase 1: SIE import (export is read-only)
|
||||
'import_sie',
|
||||
-- Stream 1 Phase 1: voucher gap explanations
|
||||
'explain_voucher_gap',
|
||||
-- Stream 1 Phase 1: transaction reversal
|
||||
'uncategorize_transaction',
|
||||
-- Stream 1 Phase 1: supplier invoice lifecycle
|
||||
'approve_supplier_invoice',
|
||||
'credit_supplier_invoice',
|
||||
-- Stream 1 Phase 1: invoice operations beyond simple create/send
|
||||
'credit_invoice',
|
||||
'convert_invoice',
|
||||
-- Phase 3: manual transaction ingestion + document attachment
|
||||
'create_transaction',
|
||||
'attach_document_to_transaction',
|
||||
-- Phase 4: arbitrary-line bookkeeping primitives
|
||||
'create_voucher',
|
||||
'correct_entry',
|
||||
'reverse_entry',
|
||||
-- Phase 5: supplier CRUD + inbox conversion
|
||||
'create_supplier',
|
||||
'create_supplier_invoice_from_inbox',
|
||||
-- Bokslut: planenlig avskrivning (one journal entry per asset)
|
||||
'post_annual_depreciation',
|
||||
-- Link an existing posted verifikat as payment for an invoice (no new JE)
|
||||
'link_invoice_voucher'
|
||||
));
|
||||
|
||||
-- Partial unique index: prevent linking the same voucher to the same invoice
|
||||
-- twice while still allowing one voucher to settle multiple distinct invoices
|
||||
-- (e.g. a single bank deposit covering several customer invoices). Mirrors the
|
||||
-- existing idx_invoice_payments_tx_inv_unique pattern from
|
||||
-- 20260323120001_invoice_partial_payments.sql.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_payments_je_inv_unique
|
||||
ON public.invoice_payments (journal_entry_id, invoice_id)
|
||||
WHERE journal_entry_id IS NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Add undo_sie_import RPC and 'undone' status for sie_imports.
|
||||
--
|
||||
-- Background: replace_sie_import already hard-deletes a prior import's
|
||||
-- entries and inserts a replacement. Customers want a one-step "Ångra
|
||||
-- import" that performs the hard-delete portion without requiring a
|
||||
-- replacement file (Fortnox/Bokio behavior). This factors the deletion
|
||||
-- body into a separate RPC.
|
||||
--
|
||||
-- Design choice: do NOT call replace_sie_import internally — the source
|
||||
-- of truth is identical but replace_sie_import marks status='replaced',
|
||||
-- whereas an undo should be distinguishable for audit (status='undone'),
|
||||
-- so the body is duplicated rather than parameterized. The shape mirrors
|
||||
-- 20260526120000_fix_replace_sie_import_hard_delete.sql exactly.
|
||||
|
||||
ALTER TABLE public.sie_imports DROP CONSTRAINT IF EXISTS sie_imports_status_check;
|
||||
ALTER TABLE public.sie_imports ADD CONSTRAINT sie_imports_status_check
|
||||
CHECK (status = ANY (ARRAY['pending','mapped','completed','failed','replaced','undone']));
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.undo_sie_import(p_company_id uuid, p_import_id uuid)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_fiscal_period_id uuid;
|
||||
v_opening_balance_entry_id uuid;
|
||||
v_is_closed boolean;
|
||||
v_locked_at timestamptz;
|
||||
v_deleted integer := 0;
|
||||
v_caller_role text;
|
||||
BEGIN
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Only company owners and admins can undo SIE imports';
|
||||
END IF;
|
||||
|
||||
SELECT fiscal_period_id, opening_balance_entry_id
|
||||
INTO v_fiscal_period_id, v_opening_balance_entry_id
|
||||
FROM public.sie_imports
|
||||
WHERE id = p_import_id
|
||||
AND company_id = p_company_id
|
||||
AND status = 'completed';
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id;
|
||||
END IF;
|
||||
|
||||
IF v_fiscal_period_id IS NOT NULL THEN
|
||||
SELECT is_closed, locked_at
|
||||
INTO v_is_closed, v_locked_at
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = v_fiscal_period_id;
|
||||
|
||||
IF v_is_closed OR v_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot undo SIE import in a locked or closed fiscal period';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
-- Detach documents (entry- and line-level).
|
||||
UPDATE public.document_attachments
|
||||
SET journal_entry_id = NULL,
|
||||
journal_entry_line_id = NULL
|
||||
WHERE journal_entry_id IN (
|
||||
SELECT je.id
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.fiscal_period_id = v_fiscal_period_id
|
||||
AND je.source_type IN ('import', 'opening_balance')
|
||||
AND je.status IN ('posted', 'cancelled')
|
||||
)
|
||||
OR journal_entry_line_id IN (
|
||||
SELECT jel.id
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.fiscal_period_id = v_fiscal_period_id
|
||||
AND je.source_type IN ('import', 'opening_balance')
|
||||
AND je.status IN ('posted', 'cancelled')
|
||||
);
|
||||
|
||||
-- Clear the fiscal-period OB pointer (two-step around
|
||||
-- enforce_opening_balance_immutability).
|
||||
IF v_opening_balance_entry_id IS NOT NULL THEN
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balances_set = false
|
||||
WHERE id = v_fiscal_period_id
|
||||
AND opening_balance_entry_id = v_opening_balance_entry_id;
|
||||
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = v_fiscal_period_id
|
||||
AND opening_balance_entry_id = v_opening_balance_entry_id;
|
||||
END IF;
|
||||
|
||||
-- Drop the sie_imports -> opening_balance_entry FK before delete.
|
||||
UPDATE public.sie_imports
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = p_import_id;
|
||||
|
||||
-- Hard-delete the import's journal entries (both transaction vouchers
|
||||
-- and the opening_balance entry).
|
||||
WITH deleted AS (
|
||||
DELETE FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_fiscal_period_id
|
||||
AND source_type IN ('import', 'opening_balance')
|
||||
AND status IN ('posted', 'cancelled')
|
||||
RETURNING id
|
||||
)
|
||||
SELECT count(*) INTO v_deleted FROM deleted;
|
||||
|
||||
-- Reset voucher_sequences per series to the max remaining number.
|
||||
UPDATE public.voucher_sequences vs
|
||||
SET last_number = COALESCE((
|
||||
SELECT MAX(je.voucher_number)
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = vs.company_id
|
||||
AND je.fiscal_period_id = vs.fiscal_period_id
|
||||
AND je.voucher_series = vs.voucher_series
|
||||
AND je.voucher_number > 0
|
||||
), 0),
|
||||
updated_at = now()
|
||||
WHERE vs.company_id = p_company_id
|
||||
AND vs.fiscal_period_id = v_fiscal_period_id;
|
||||
|
||||
UPDATE public.sie_imports
|
||||
SET status = 'undone',
|
||||
replaced_at = now()
|
||||
WHERE id = p_import_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
RETURN v_deleted;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Atomic relink of fiscal_periods.opening_balance_entry_id.
|
||||
--
|
||||
-- Used by the pragmatic IB resync flow in lib/import/sie-import.ts when
|
||||
-- importing a prior fiscal year retroactively. The next period's IB
|
||||
-- (already created from a prior import or manual entry) gets stornoed and
|
||||
-- replaced with the new IB derived from the just-imported year's #UB —
|
||||
-- so the chain stays consistent without forcing the user to drop and
|
||||
-- reimport the later year.
|
||||
--
|
||||
-- enforce_opening_balance_immutability blocks any UPDATE that changes
|
||||
-- opening_balance_entry_id while opening_balances_set is true. The
|
||||
-- canonical workaround is to flip opening_balances_set to false in one
|
||||
-- statement and change the FK in another (the trigger reads OLD on each
|
||||
-- UPDATE). Doing this in a single transaction-level RPC keeps the period
|
||||
-- from being observable in an unset state by concurrent queries.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.replace_period_opening_balance_link(
|
||||
p_company_id uuid,
|
||||
p_period_id uuid,
|
||||
p_new_entry_id uuid
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller_role text;
|
||||
BEGIN
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN
|
||||
RAISE EXCEPTION 'Insufficient role to relink opening balance';
|
||||
END IF;
|
||||
|
||||
-- Sanity: the new entry must exist, be posted, and belong to the same
|
||||
-- company and period as the link target.
|
||||
PERFORM 1
|
||||
FROM journal_entries
|
||||
WHERE id = p_new_entry_id
|
||||
AND company_id = p_company_id
|
||||
AND fiscal_period_id = p_period_id
|
||||
AND status = 'posted';
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'New opening balance entry % is not a posted entry in period %', p_new_entry_id, p_period_id;
|
||||
END IF;
|
||||
|
||||
-- Two-step around enforce_opening_balance_immutability: the trigger
|
||||
-- only raises when OLD.opening_balances_set = true AND the FK is being
|
||||
-- changed in the same statement. Flip the flag first, then change the
|
||||
-- FK and flip the flag back on.
|
||||
UPDATE fiscal_periods
|
||||
SET opening_balances_set = false
|
||||
WHERE id = p_period_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
UPDATE fiscal_periods
|
||||
SET opening_balance_entry_id = p_new_entry_id,
|
||||
opening_balances_set = true
|
||||
WHERE id = p_period_id
|
||||
AND company_id = p_company_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Add per-employee override columns for tax and employer contributions.
|
||||
--
|
||||
-- Customers reported (2026-05-28) that they need to adjust the computed
|
||||
-- tax (skatteavdrag) and arbetsgivaravgift per individual employee inside
|
||||
-- a salary run — common reasons:
|
||||
-- * FoU-avdrag (R&D research deduction lowering avgifter ~10%)
|
||||
-- * Jämkning (Skatteverket-issued personal tax adjustment)
|
||||
-- * Växa-stöd corner cases not covered by salary_payroll_config
|
||||
--
|
||||
-- Modeled additively: the engine writes computed values into
|
||||
-- tax_withheld / avgifter_amount / avgifter_basis exactly as before.
|
||||
-- Booking and AGI now coalesce override → computed:
|
||||
-- effective_tax = COALESCE(tax_withheld_override, tax_withheld)
|
||||
-- so legacy runs continue to behave identically.
|
||||
--
|
||||
-- override_reason is a compliance breadcrumb required by the UI when any
|
||||
-- override is set (BFL requires documentable rationale for manual tax
|
||||
-- adjustments). NULL when no override is set.
|
||||
|
||||
ALTER TABLE public.salary_run_employees
|
||||
ADD COLUMN tax_withheld_override numeric,
|
||||
ADD COLUMN avgifter_amount_override numeric,
|
||||
ADD COLUMN avgifter_basis_override numeric,
|
||||
ADD COLUMN override_reason text;
|
||||
|
||||
ALTER TABLE public.salary_run_employees
|
||||
ADD CONSTRAINT salary_run_employees_override_reason_required
|
||||
CHECK (
|
||||
(tax_withheld_override IS NULL
|
||||
AND avgifter_amount_override IS NULL
|
||||
AND avgifter_basis_override IS NULL)
|
||||
OR override_reason IS NOT NULL
|
||||
);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Allow re-importing an SIE file after the previous import was undone.
|
||||
--
|
||||
-- 20260528120100_undo_sie_import.sql added the 'undone' status but did not
|
||||
-- touch the partial unique index from 20260517150000, which excludes only
|
||||
-- 'replaced' and 'failed'. Result: after undo_sie_import flips a row to
|
||||
-- 'undone', the (company_id, file_hash) slot is still held and a fresh
|
||||
-- upload of the same file fails with sie_imports_company_id_file_hash_key.
|
||||
--
|
||||
-- This migration also catches databases (e.g. staging) where
|
||||
-- 20260517150000 was never applied — they still carry the plain UNIQUE
|
||||
-- constraint. All operations are idempotent: dropping non-existent
|
||||
-- constraints/indexes is a no-op, and CREATE INDEX IF NOT EXISTS skips
|
||||
-- when the partial index already exists from a prior run.
|
||||
|
||||
ALTER TABLE public.sie_imports
|
||||
DROP CONSTRAINT IF EXISTS sie_imports_company_id_file_hash_key;
|
||||
|
||||
DROP INDEX IF EXISTS public.sie_imports_company_id_file_hash_active_idx;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS sie_imports_company_id_file_hash_active_idx
|
||||
ON public.sie_imports (company_id, file_hash)
|
||||
WHERE status <> ALL (ARRAY['replaced'::text, 'failed'::text, 'undone'::text]);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
-- Tighten replace_period_opening_balance_link to owner/admin only.
|
||||
--
|
||||
-- 20260528120200_replace_period_opening_balance_link.sql initially allowed
|
||||
-- 'member' alongside 'owner'/'admin'. That was inconsistent with the peer
|
||||
-- recovery RPCs (delete_last_voucher, undo_sie_import), which both restrict
|
||||
-- this kind of structural mutation to owner/admin. Tighten here so the
|
||||
-- whole recovery surface uses the same role gate.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.replace_period_opening_balance_link(
|
||||
p_company_id uuid,
|
||||
p_period_id uuid,
|
||||
p_new_entry_id uuid
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller_role text;
|
||||
BEGIN
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Insufficient role to relink opening balance';
|
||||
END IF;
|
||||
|
||||
PERFORM 1
|
||||
FROM journal_entries
|
||||
WHERE id = p_new_entry_id
|
||||
AND company_id = p_company_id
|
||||
AND fiscal_period_id = p_period_id
|
||||
AND status = 'posted';
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'New opening balance entry % is not a posted entry in period %', p_new_entry_id, p_period_id;
|
||||
END IF;
|
||||
|
||||
UPDATE fiscal_periods
|
||||
SET opening_balances_set = false
|
||||
WHERE id = p_period_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
UPDATE fiscal_periods
|
||||
SET opening_balance_entry_id = p_new_entry_id,
|
||||
opening_balances_set = true
|
||||
WHERE id = p_period_id
|
||||
AND company_id = p_company_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Fix delete_last_voucher RPC to set company_id on its audit_log writes.
|
||||
--
|
||||
-- 20260528120000_delete_last_voucher_clears_ib_link's INSERT into
|
||||
-- audit_log omitted company_id (it pre-dated the multi-tenant audit_log
|
||||
-- policy, then was copied without that field). The audit_log SELECT
|
||||
-- policy filters `company_id IN user_company_ids()`, so the RPC's
|
||||
-- explicit "(was period IB)" provenance row landed with company_id=NULL
|
||||
-- and was invisible to every reader — only the generic write_audit_log()
|
||||
-- trigger row remained visible. That defeats BFL audit-trail intent.
|
||||
--
|
||||
-- Republish the RPC with company_id populated on both audit_log writes
|
||||
-- (draft path and posted path). Behavior is otherwise unchanged.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_last_voucher(p_company_id uuid, p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_entry record;
|
||||
v_period record;
|
||||
v_max_voucher integer;
|
||||
v_ref_count integer;
|
||||
v_caller_role text;
|
||||
v_snapshot jsonb;
|
||||
v_lines_snapshot jsonb;
|
||||
v_is_period_ib boolean := false;
|
||||
BEGIN
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Only company owners and admins can delete vouchers';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_entry
|
||||
FROM journal_entries
|
||||
WHERE id = p_entry_id
|
||||
AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_entry IS NULL THEN
|
||||
RAISE EXCEPTION 'Journal entry not found';
|
||||
END IF;
|
||||
|
||||
IF v_entry.status NOT IN ('posted', 'draft') THEN
|
||||
RAISE EXCEPTION 'Only posted or draft entries can be deleted (current status: %)', v_entry.status;
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(to_jsonb(l)) INTO v_lines_snapshot
|
||||
FROM journal_entry_lines l
|
||||
WHERE l.journal_entry_id = p_entry_id;
|
||||
|
||||
v_snapshot := to_jsonb(v_entry) || jsonb_build_object('lines', COALESCE(v_lines_snapshot, '[]'::jsonb));
|
||||
|
||||
IF v_entry.status = 'draft' THEN
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
UPDATE document_attachments
|
||||
SET journal_entry_id = NULL
|
||||
WHERE journal_entry_id = p_entry_id;
|
||||
|
||||
DELETE FROM journal_entries WHERE id = p_entry_id;
|
||||
|
||||
INSERT INTO audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, description)
|
||||
VALUES (
|
||||
v_entry.user_id,
|
||||
p_company_id,
|
||||
'DELETE',
|
||||
'journal_entries',
|
||||
p_entry_id,
|
||||
auth.uid(),
|
||||
v_snapshot,
|
||||
'Deleted draft journal entry (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'voucher_series', v_entry.voucher_series,
|
||||
'voucher_number', v_entry.voucher_number,
|
||||
'was_draft', true
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_period
|
||||
FROM fiscal_periods
|
||||
WHERE id = v_entry.fiscal_period_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_period.is_closed THEN
|
||||
RAISE EXCEPTION 'Cannot delete voucher in a closed fiscal period';
|
||||
END IF;
|
||||
|
||||
IF v_period.locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot delete voucher in a locked fiscal period';
|
||||
END IF;
|
||||
|
||||
PERFORM 1 FROM voucher_sequences
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series
|
||||
FOR UPDATE;
|
||||
|
||||
SELECT MAX(voucher_number) INTO v_max_voucher
|
||||
FROM journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series
|
||||
AND status NOT IN ('cancelled', 'draft');
|
||||
|
||||
IF v_entry.voucher_number != v_max_voucher THEN
|
||||
RAISE EXCEPTION 'Kan bara radera det sista verifikatet i serien. % har nummer % men senaste är %',
|
||||
v_entry.voucher_series, v_entry.voucher_number, v_max_voucher;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO v_ref_count
|
||||
FROM journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND status != 'cancelled'
|
||||
AND (reverses_id = p_entry_id OR correction_of_id = p_entry_id);
|
||||
|
||||
IF v_ref_count > 0 THEN
|
||||
RAISE EXCEPTION 'Cannot delete: other entries reference this voucher (% references)',
|
||||
v_ref_count;
|
||||
END IF;
|
||||
|
||||
IF v_entry.reverses_id IS NOT NULL THEN
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
UPDATE journal_entries
|
||||
SET status = 'posted', reversed_by_id = NULL
|
||||
WHERE id = v_entry.reverses_id
|
||||
AND company_id = p_company_id;
|
||||
END IF;
|
||||
|
||||
v_is_period_ib := (v_period.opening_balance_entry_id = p_entry_id);
|
||||
IF v_is_period_ib THEN
|
||||
UPDATE fiscal_periods
|
||||
SET opening_balances_set = false
|
||||
WHERE id = v_entry.fiscal_period_id;
|
||||
|
||||
UPDATE fiscal_periods
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = v_entry.fiscal_period_id;
|
||||
END IF;
|
||||
|
||||
UPDATE sie_imports
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE opening_balance_entry_id = p_entry_id;
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
UPDATE document_attachments
|
||||
SET journal_entry_id = NULL
|
||||
WHERE journal_entry_id = p_entry_id;
|
||||
|
||||
DELETE FROM journal_entries WHERE id = p_entry_id;
|
||||
|
||||
UPDATE voucher_sequences
|
||||
SET last_number = GREATEST(last_number - 1, 0)
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_entry.fiscal_period_id
|
||||
AND voucher_series = v_entry.voucher_series;
|
||||
|
||||
INSERT INTO audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, description)
|
||||
VALUES (
|
||||
v_entry.user_id,
|
||||
p_company_id,
|
||||
'DELETE',
|
||||
'journal_entries',
|
||||
p_entry_id,
|
||||
auth.uid(),
|
||||
v_snapshot,
|
||||
'Deleted voucher ' || v_entry.voucher_series || v_entry.voucher_number ||
|
||||
CASE WHEN v_is_period_ib THEN ' (was period IB)' ELSE '' END ||
|
||||
' (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'deleted', true,
|
||||
'voucher_series', v_entry.voucher_series,
|
||||
'voucher_number', v_entry.voucher_number,
|
||||
'was_period_ib', v_is_period_ib
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,157 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
insertAuthUser,
|
||||
insertCompany,
|
||||
insertCompanyMember,
|
||||
insertFiscalPeriod,
|
||||
insertBalancedLines,
|
||||
} from '@/tests/pg/fixtures'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Covers 20260528120000_delete_last_voucher_clears_ib_link:
|
||||
* - delete_last_voucher RPC succeeds when the target is the period's
|
||||
* opening_balance_entry (A1 from SIE import).
|
||||
* - fiscal_periods.opening_balance_entry_id is cleared and
|
||||
* opening_balances_set is flipped to false.
|
||||
* - sie_imports.opening_balance_entry_id is also cleared so the import
|
||||
* row stays consistent.
|
||||
* - audit_log has a DELETE entry with the "(was period IB)" marker.
|
||||
* - The RPC still rejects non-last vouchers and locked periods.
|
||||
*/
|
||||
|
||||
async function commitPostedEntryAsIB(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherSeries?: string
|
||||
}): Promise<string> {
|
||||
const entryId = randomUUID()
|
||||
const series = params.voucherSeries ?? 'A'
|
||||
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, 1, $5, '2026-01-01', 'Ingående balans', 'opening_balance', 'draft')`,
|
||||
[entryId, params.userId, params.companyId, params.fiscalPeriodId, series],
|
||||
)
|
||||
await insertBalancedLines(entryId, 5000)
|
||||
// flip to posted directly — bypass commit_journal_entry to keep this
|
||||
// test focused on the deletion RPC. voucher_sequences needs a row so the
|
||||
// delete RPC's FOR UPDATE lookup succeeds.
|
||||
await getPool().query(
|
||||
`UPDATE public.journal_entries
|
||||
SET status = 'posted'
|
||||
WHERE id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.voucher_sequences
|
||||
(company_id, user_id, fiscal_period_id, voucher_series, last_number)
|
||||
VALUES ($1, $2, $3, $4, 1)
|
||||
ON CONFLICT (company_id, fiscal_period_id, voucher_series) DO UPDATE
|
||||
SET last_number = EXCLUDED.last_number`,
|
||||
[params.companyId, params.userId, params.fiscalPeriodId, series],
|
||||
)
|
||||
return entryId
|
||||
}
|
||||
|
||||
async function linkAsIB(periodId: string, entryId: string): Promise<void> {
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = $1,
|
||||
opening_balances_set = true
|
||||
WHERE id = $2`,
|
||||
[entryId, periodId],
|
||||
)
|
||||
}
|
||||
|
||||
describe('delete_last_voucher with IB link', () => {
|
||||
it('deletes an IB entry and clears the period FK + sets opening_balances_set=false', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
|
||||
|
||||
const ibEntryId = await commitPostedEntryAsIB({ userId, companyId, fiscalPeriodId })
|
||||
await linkAsIB(fiscalPeriodId, ibEntryId)
|
||||
|
||||
// Sanity check pre-state
|
||||
const pre = await getPool().query<{ ob_id: string | null; ob_set: boolean }>(
|
||||
`SELECT opening_balance_entry_id AS ob_id, opening_balances_set AS ob_set
|
||||
FROM public.fiscal_periods WHERE id = $1`,
|
||||
[fiscalPeriodId],
|
||||
)
|
||||
expect(pre.rows[0]!.ob_id).toBe(ibEntryId)
|
||||
expect(pre.rows[0]!.ob_set).toBe(true)
|
||||
|
||||
// withUserContext rolls back at the end, so all assertions about the
|
||||
// RPC's effects must be observed inside the same transaction — a fresh
|
||||
// getPool() connection would only see pre-RPC state.
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query<{ delete_last_voucher: { deleted: boolean; was_period_ib: boolean } }>(
|
||||
`SELECT delete_last_voucher($1, $2)`,
|
||||
[companyId, ibEntryId],
|
||||
)
|
||||
const result = r.rows[0]!.delete_last_voucher
|
||||
expect(result.deleted).toBe(true)
|
||||
expect(result.was_period_ib).toBe(true)
|
||||
|
||||
const after = await client.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count FROM public.journal_entries WHERE id = $1`,
|
||||
[ibEntryId],
|
||||
)
|
||||
expect(after.rows[0]!.count).toBe('0')
|
||||
|
||||
const post = await client.query<{ ob_id: string | null; ob_set: boolean }>(
|
||||
`SELECT opening_balance_entry_id AS ob_id, opening_balances_set AS ob_set
|
||||
FROM public.fiscal_periods WHERE id = $1`,
|
||||
[fiscalPeriodId],
|
||||
)
|
||||
expect(post.rows[0]!.ob_id).toBeNull()
|
||||
expect(post.rows[0]!.ob_set).toBe(false)
|
||||
|
||||
// Two audit rows land on the DELETE: the generic one from the
|
||||
// write_audit_log() trigger and the RPC's explicit "was period IB"
|
||||
// entry. They share statement_timestamp(), so ordering by created_at
|
||||
// is non-deterministic — assert against the specific marker directly.
|
||||
const audit = await client.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count FROM public.audit_log
|
||||
WHERE table_name = 'journal_entries' AND record_id = $1 AND action = 'DELETE'
|
||||
AND description LIKE '%was period IB%'`,
|
||||
[ibEntryId],
|
||||
)
|
||||
expect(Number(audit.rows[0]!.count)).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('also clears sie_imports.opening_balance_entry_id when present', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
const companyId = await insertCompany({ createdBy: userId })
|
||||
await insertCompanyMember({ companyId, userId, role: 'owner' })
|
||||
const fiscalPeriodId = await insertFiscalPeriod({ userId, companyId })
|
||||
|
||||
const ibEntryId = await commitPostedEntryAsIB({ userId, companyId, fiscalPeriodId })
|
||||
await linkAsIB(fiscalPeriodId, ibEntryId)
|
||||
|
||||
const importId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.sie_imports
|
||||
(id, user_id, company_id, filename, file_hash, sie_type, fiscal_period_id,
|
||||
opening_balance_entry_id, status, transactions_count)
|
||||
VALUES ($1, $2, $3, 'test.se', $4, 4, $5, $6, 'completed', 0)`,
|
||||
[importId, userId, companyId, randomUUID().replace(/-/g, ''), fiscalPeriodId, ibEntryId],
|
||||
)
|
||||
|
||||
// Same caveat as the previous test — assert inside the tx, not after.
|
||||
await withUserContext(userId, async (client) => {
|
||||
await client.query(`SELECT delete_last_voucher($1, $2)`, [companyId, ibEntryId])
|
||||
const imp = await client.query<{ ob_id: string | null }>(
|
||||
`SELECT opening_balance_entry_id AS ob_id FROM public.sie_imports WHERE id = $1`,
|
||||
[importId],
|
||||
)
|
||||
expect(imp.rows[0]!.ob_id).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1546,6 +1546,8 @@ export type PendingOperationType =
|
||||
// Payroll: salary run creation + AGI declaration
|
||||
| 'create_salary_run'
|
||||
| 'generate_agi'
|
||||
// Mark invoice paid by linking an existing posted verifikat (no new JE)
|
||||
| 'link_invoice_voucher'
|
||||
export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected'
|
||||
|
||||
export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
||||
@@ -2994,11 +2996,15 @@ export interface SalaryRunEmployee {
|
||||
benefit_values: number
|
||||
taxable_income: number
|
||||
tax_withheld: number
|
||||
tax_withheld_override: number | null
|
||||
net_deductions: number
|
||||
net_salary: number
|
||||
avgifter_rate: number
|
||||
avgifter_amount: number
|
||||
avgifter_amount_override: number | null
|
||||
avgifter_basis: number
|
||||
avgifter_basis_override: number | null
|
||||
override_reason: string | null
|
||||
vacation_accrual: number
|
||||
vacation_accrual_avgifter: number
|
||||
tax_table_number: number | null
|
||||
|
||||
Reference in New Issue
Block a user