-
{label}
+
+
+ {label}
+ {overridden && Justerat }
+
{formatCurrency(value)}
)
diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx
index 1e73a740..70e31616 100644
--- a/app/(dashboard)/salary/runs/[id]/page.tsx
+++ b/app/(dashboard)/salary/runs/[id]/page.tsx
@@ -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:
- {/* 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. */}
- {[
- { 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 }) => (
{label}
@@ -273,24 +330,42 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
Anställda ({employees.length})
- {run.status === 'draft' && canWrite && notAdded.length > 0 && (
- {
- handleAddEmployee(value)
- setAddEmployeeKey(k => k + 1)
- }}
- >
-
-
-
-
- {notAdded.map(emp => (
- {emp.first_name} {emp.last_name}
- ))}
-
-
- )}
+
+ {employees.length > 0 && (
+
+ {actionLoading === 'bulk_payslip' ? (
+
+ ) : (
+
+ )}
+ Ladda ner alla
+
+ )}
+ {run.status === 'draft' && canWrite && notAdded.length > 0 && (
+ {
+ handleAddEmployee(value)
+ setAddEmployeeKey(k => k + 1)
+ }}
+ >
+
+
+
+
+ {notAdded.map(emp => (
+ {emp.first_name} {emp.last_name}
+ ))}
+
+
+ )}
+
{employees.length === 0 ? (
@@ -307,6 +382,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
Netto
Avgifter
Semester
+ Lönespec
@@ -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 (
{formatCurrency(sre.gross_salary)}
- {formatCurrency(sre.tax_withheld)}
- {formatCurrency(sre.net_salary)}
- {formatCurrency(sre.avgifter_amount)}
+ {formatCurrency(taxValue)}
+ {formatCurrency(sre.net_salary + (sre.tax_withheld - taxValue))}
+ {formatCurrency(avgifterValue)}
{formatCurrency(sre.vacation_accrual)}
+
+ e.stopPropagation()}
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
+ title="Visa lönespecifikation"
+ >
+
+ Visa PDF
+
+
)
})}
diff --git a/app/api/bookkeeping/accounts/route.ts b/app/api/bookkeeping/accounts/route.ts
index 0f3ca704..469bf5b3 100644
--- a/app/api/bookkeeping/accounts/route.ts
+++ b/app/api/bookkeeping/accounts/route.ts
@@ -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 })
}
diff --git a/app/api/import/sie/[id]/undo/route.ts b/app/api/import/sie/[id]/undo/route.ts
new file mode 100644
index 00000000..8e1b868d
--- /dev/null
+++ b/app/api/import/sie/[id]/undo/route.ts
@@ -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 },
+)
diff --git a/app/api/invoices/[id]/link-to-voucher/route.ts b/app/api/invoices/[id]/link-to-voucher/route.ts
new file mode 100644
index 00000000..70a2105e
--- /dev/null
+++ b/app/api/invoices/[id]/link-to-voucher/route.ts
@@ -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 },
+)
diff --git a/app/api/invoices/[id]/voucher-candidates/route.ts b/app/api/invoices/[id]/voucher-candidates/route.ts
new file mode 100644
index 00000000..5632aea6
--- /dev/null
+++ b/app/api/invoices/[id]/voucher-candidates/route.ts
@@ -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 } })
+ },
+)
diff --git a/app/api/salary/ku/[year]/route.ts b/app/api/salary/ku/[year]/route.ts
index a6463b8d..c48a07dd 100644
--- a/app/api/salary/ku/[year]/route.ts
+++ b/app/api/salary/ku/[year]/route.ts
@@ -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 }>
diff --git a/app/api/salary/runs/[id]/book/route.ts b/app/api/salary/runs/[id]/book/route.ts
index 9f13ac9a..fd44256c 100644
--- a/app/api/salary/runs/[id]/book/route.ts
+++ b/app/api/salary/runs/[id]/book/route.ts
@@ -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,
diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts
index 20bc8f07..6d7a1cfc 100644
--- a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts
+++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts
@@ -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 = {}
+ 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,
diff --git a/app/api/salary/runs/[id]/payment/bg-lb/route.ts b/app/api/salary/runs/[id]/payment/bg-lb/route.ts
index 73970b06..7239128c 100644
--- a/app/api/salary/runs/[id]/payment/bg-lb/route.ts
+++ b/app/api/salary/runs/[id]/payment/bg-lb/route.ts
@@ -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,
}
})
diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts
index b5d53b40..e2462ef6 100644
--- a/app/api/salary/runs/[id]/payment/pain001/route.ts
+++ b/app/api/salary/runs/[id]/payment/pain001/route.ts
@@ -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,
}
})
diff --git a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts
index 5a474733..9caec56c 100644
--- a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts
+++ b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts
@@ -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}"`,
},
})
}
diff --git a/app/api/salary/runs/[id]/payslips/send/route.ts b/app/api/salary/runs/[id]/payslips/send/route.ts
index 28383d3b..361043e4 100644
--- a/app/api/salary/runs/[id]/payslips/send/route.ts
+++ b/app/api/salary/runs/[id]/payslips/send/route.ts
@@ -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,
diff --git a/components/bookkeeping/AddAccountDialog.tsx b/components/bookkeeping/AddAccountDialog.tsx
index b055f70d..d40c89ff 100644
--- a/components/bookkeeping/AddAccountDialog.tsx
+++ b/components/bookkeeping/AddAccountDialog.tsx
@@ -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({
Auto-detekterad typ:{' '}
- {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'}
)}
diff --git a/components/bookkeeping/ChartOfAccountsManager.tsx b/components/bookkeeping/ChartOfAccountsManager.tsx
index 3ff2cb02..deb7cdfb 100644
--- a/components/bookkeeping/ChartOfAccountsManager.tsx
+++ b/components/bookkeeping/ChartOfAccountsManager.tsx
@@ -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 => {
diff --git a/components/import/ImportResultStep.tsx b/components/import/ImportResultStep.tsx
index 6b3589d5..2c33c4b7 100644
--- a/components/import/ImportResultStep.tsx
+++ b/components/import/ImportResultStep.tsx
@@ -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
}
-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
+ {/* IB resync notice (prior-year backfill) */}
+ {result.success && result.nextPeriodIBResync && (
+
+
+
+
+ Ingående balanser synkades om
+
+
+ Eftersom du importerade ett tidigare räkenskapsår uppdaterades ingående balanser för{' '}
+ {result.nextPeriodIBResync.nextPeriodName} {' '}
+ automatiskt (gammal IB makulerad, ny IB skapad från utgående balans).
+
+
+
+ )}
+
+ {result.success && result.nextPeriodIBResyncSkipped && (
+
+
+
+
+ Ingående balanser för {result.nextPeriodIBResyncSkipped.nextPeriodName} kunde inte synkas
+
+
+ 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.
+
+
+
+ )}
+
{/* Statistics */}
{result.success && (
@@ -250,10 +300,18 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
{/* Actions */}
-
-
- Ny import
-
+
+
+
+ Ny import
+
+ {result.success && result.importId && onUndo && (
+
+
+ Ångra import
+
+ )}
+
{result.success && (
<>
@@ -273,6 +331,8 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
)}
+
+
)
}
diff --git a/components/invoices/LinkVoucherPicker.tsx b/components/invoices/LinkVoucherPicker.tsx
new file mode 100644
index 00000000..cbe8f28a
--- /dev/null
+++ b/components/invoices/LinkVoucherPicker.tsx
@@ -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(null)
+ const [loading, setLoading] = useState(true)
+ const [submitting, setSubmitting] = useState(false)
+ const [selectedId, setSelectedId] = useState(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 (
+
+
{t('intro')}
+
+
+
+ setSearch(e.target.value)}
+ placeholder={t('search_placeholder')}
+ className="pl-9"
+ />
+
+
+ {loading ? (
+
+
+
+
+
+ ) : filtered.length === 0 ? (
+
+
{t('empty_title')}
+
{t('empty_description')}
+
+ ) : (
+
+ {filtered.map((c) => {
+ const badge = confidenceBadge(c.confidence)
+ const isSelected = selectedId === c.journal_entry_id
+ return (
+
+ 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'
+ }`}
+ >
+
+
+
+
+ {voucherLabel(c)}
+
+
+ {formatDate(c.entry_date)}
+
+ {t(`confidence_${badge.key}`)}
+ {c.period_locked && (
+ {t('period_locked')}
+ )}
+
+
+ {c.match_reason || c.description}
+
+
+
+
+ {formatCurrency(c.ar_credit_amount, invoiceCurrency)}
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+ {selected && (
+
+
+ {t('confirmation', {
+ voucher: voucherLabel(selected),
+ amount: formatCurrency(selected.ar_credit_amount, invoiceCurrency),
+ })}
+
+
{t('no_new_je_note')}
+
+ )}
+
+
+
+ {t('cancel')}
+
+
+ {submitting && }
+ {t('confirm')}
+
+
+
+ )
+}
diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx
index 003aed25..834ff3c1 100644
--- a/components/invoices/PaymentBookingDialog.tsx
+++ b/components/invoices/PaymentBookingDialog.tsx
@@ -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(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({
})}
- ) : !isInitialized ? (
-