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

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

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

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

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

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

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

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

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

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

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

* feat: add link_invoice_voucher operation type to pending_operations

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

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

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

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

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

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

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

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

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

* fix(tests): supply user_id when seeding voucher_sequences

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
Jakob Wennberg
2026-05-28 21:09:43 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 Emil
parent cb7eac90b1
commit ccdfed5fea
52 changed files with 4192 additions and 206 deletions
+6
View File
@@ -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 })
}
+34
View File
@@ -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 } })
},
)
+5 -3
View File
@@ -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 }>
+5 -3
View File
@@ -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,