From ccdfed5fea31a4a3560a867e50963ace7f57688a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 28 May 2026 21:09:43 +0200 Subject: [PATCH] feat: voucher linking, recovery ops, and salary overrides (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Emil --- app/(dashboard)/bookkeeping/[id]/page.tsx | 4 +- app/(dashboard)/import/page.tsx | 58 +- .../runs/[id]/employees/[employeeId]/page.tsx | 74 +- app/(dashboard)/salary/runs/[id]/page.tsx | 151 +++- app/api/bookkeeping/accounts/route.ts | 6 + app/api/import/sie/[id]/undo/route.ts | 34 + .../invoices/[id]/link-to-voucher/route.ts | 60 ++ .../invoices/[id]/voucher-candidates/route.ts | 50 ++ app/api/salary/ku/[year]/route.ts | 8 +- app/api/salary/runs/[id]/book/route.ts | 8 +- .../runs/[id]/employees/[employeeId]/route.ts | 79 +++ .../salary/runs/[id]/payment/bg-lb/route.ts | 11 +- .../salary/runs/[id]/payment/pain001/route.ts | 9 +- .../[id]/payslips/[employeeId]/pdf/route.ts | 51 +- .../salary/runs/[id]/payslips/send/route.ts | 12 +- components/bookkeeping/AddAccountDialog.tsx | 38 +- .../bookkeeping/ChartOfAccountsManager.tsx | 8 +- components/import/ImportResultStep.tsx | 70 +- components/invoices/LinkVoucherPicker.tsx | 240 +++++++ components/invoices/PaymentBookingDialog.tsx | 83 ++- components/salary/SalaryOverridePanel.tsx | 240 +++++++ components/ui/toast.tsx | 4 +- extensions/general/mcp-server/server.ts | 155 +++++ lib/api/schemas.ts | 47 ++ .../__tests__/account-classifier.test.ts | 38 + lib/bookkeeping/account-classifier.ts | 64 ++ lib/errors/structured-errors.ts | 68 ++ lib/import/__tests__/account-mapper.test.ts | 14 +- lib/import/sie-import.ts | 281 +++++++- lib/import/types.ts | 14 + .../__tests__/voucher-matching.pg.test.ts | 244 +++++++ .../__tests__/voucher-matching.test.ts | 359 ++++++++++ lib/invoices/invoice-matching.ts | 6 +- lib/invoices/voucher-matching.ts | 651 ++++++++++++++++++ lib/pending-operations/commit.ts | 49 ++ lib/pending-operations/risk-tiers.ts | 5 + lib/salary/agi/generate-declaration.ts | 20 +- lib/salary/effective-values.ts | 28 + messages/en.json | 21 +- messages/sv.json | 21 +- scripts/backfill-import-accounts.ts | 30 +- scripts/lib/atom-discovery.ts | 11 +- ...000_delete_last_voucher_clears_ib_link.sql | 216 ++++++ ...ng_operations_add_link_invoice_voucher.sql | 79 +++ .../20260528120100_undo_sie_import.sql | 143 ++++ ...00_replace_period_opening_balance_link.sql | 69 ++ ...28120300_salary_run_employee_overrides.sql | 35 + ...0400_sie_imports_undone_partial_unique.sql | 24 + ...riod_opening_balance_link_tighten_role.sql | 55 ++ ...0_delete_last_voucher_audit_company_id.sql | 190 +++++ tests/pg/delete-last-voucher-ib.pg.test.ts | 157 +++++ types/index.ts | 6 + 52 files changed, 4192 insertions(+), 206 deletions(-) create mode 100644 app/api/import/sie/[id]/undo/route.ts create mode 100644 app/api/invoices/[id]/link-to-voucher/route.ts create mode 100644 app/api/invoices/[id]/voucher-candidates/route.ts create mode 100644 components/invoices/LinkVoucherPicker.tsx create mode 100644 components/salary/SalaryOverridePanel.tsx create mode 100644 lib/bookkeeping/__tests__/account-classifier.test.ts create mode 100644 lib/bookkeeping/account-classifier.ts create mode 100644 lib/invoices/__tests__/voucher-matching.pg.test.ts create mode 100644 lib/invoices/__tests__/voucher-matching.test.ts create mode 100644 lib/invoices/voucher-matching.ts create mode 100644 lib/salary/effective-values.ts create mode 100644 supabase/migrations/20260528120000_delete_last_voucher_clears_ib_link.sql create mode 100644 supabase/migrations/20260528120001_pending_operations_add_link_invoice_voucher.sql create mode 100644 supabase/migrations/20260528120100_undo_sie_import.sql create mode 100644 supabase/migrations/20260528120200_replace_period_opening_balance_link.sql create mode 100644 supabase/migrations/20260528120300_salary_run_employee_overrides.sql create mode 100644 supabase/migrations/20260528120400_sie_imports_undone_partial_unique.sql create mode 100644 supabase/migrations/20260528120500_replace_period_opening_balance_link_tighten_role.sql create mode 100644 supabase/migrations/20260528120600_delete_last_voucher_audit_company_id.sql create mode 100644 tests/pg/delete-last-voucher-ib.pg.test.ts diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index dff942a4..5a38d746 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -245,8 +245,8 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i disabled={!canWrite} title={!canWrite ? t('read_only_tooltip') : undefined} > - {!canWrite && } - {t('create_correction')} + {!canWrite ? : } + {t('edit_entry')} )} {entry.status === 'posted' && ( diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 6f5bebc8..81f8cde4 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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() { )} - {step === 'result' && importResult && } + {step === 'result' && importResult && } ) } diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index 2ca047c1..3aa252bd 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -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}

- + {run.status === 'draft' && ( + + )} {/* Summary */}
- - - + + +
+ {/* Advanced mode — per-employee override of tax / arbetsgivaravgift */} + {run.status === 'review' && ( + + )} + {/* Unified calendar — worked time (for hourly) + absence on the same grid */} @@ -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 ( -
-
{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 && ( - - )} +
+ {employees.length > 0 && ( + + )} + {run.status === 'draft' && canWrite && notAdded.length > 0 && ( + + )} +
{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 */}
- +
+ + {result.success && result.importId && onUndo && ( + + )} +
{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 ( +
  • + +
  • + ) + })} +
+ )} + + {selected && ( +
+

+ {t('confirmation', { + voucher: voucherLabel(selected), + amount: formatCurrency(selected.ar_credit_amount, invoiceCurrency), + })} +

+

{t('no_new_je_note')}

+
+ )} + +
+ + +
+
+ ) +} 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 ? ( -
- -
) : ( -
+ setTab(v as 'new' | 'existing')}> + + {t('tab_new_payment')} + {t('tab_existing_voucher')} + + + { + onOpenChange(false) + onSuccess() + }} + onCancel={() => setTab('new')} + /> + + + {!isInitialized ? ( +
+ +
+ ) : ( +
{/* Payment date */}
@@ -473,32 +495,37 @@ export default function PaymentBookingDialog({
+ )} + + )} - - - {duplicateCandidates && duplicateCandidates.length > 0 ? ( - - ) : ( - - )} - + {duplicateCandidates && duplicateCandidates.length > 0 ? ( + + ) : ( + + )} + + ) : null} ) diff --git a/components/salary/SalaryOverridePanel.tsx b/components/salary/SalaryOverridePanel.tsx new file mode 100644 index 00000000..4985b5ed --- /dev/null +++ b/components/salary/SalaryOverridePanel.tsx @@ -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 ( + + +
+ Avancerat läge + {hasOverride && Justerat} +
+ +
+ {expanded && ( + +

+ 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. +

+ +
+
+ + setTaxStr(e.target.value)} + disabled={props.disabled || saving} + className="tabular-nums" + /> +

+ Beräknat: {formatCurrency(props.taxWithheld)} +

+
+ +
+ + setAvgStr(e.target.value)} + disabled={props.disabled || saving} + className="tabular-nums" + /> +

+ Beräknat: {formatCurrency(props.avgifterAmount)} +

+
+ +
+ + setBasisStr(e.target.value)} + disabled={props.disabled || saving} + className="tabular-nums" + /> +

+ Beräknat: {formatCurrency(props.avgifterBasis)} +

+
+
+ +
+ +