Files
accounted/app/api/salary/runs/[id]/preview/route.ts
T
MattssonandClaude Opus 4.8 64991eb3c9 Add/transaction deletion (#695)
* feat(salary): add remove-employee button to draft salary runs

The DELETE /api/salary/runs/{id}/employees/{employeeId} endpoint already
existed (draft-only, cascades to the employee's line items) but had no UI
trigger, so a mistakenly added employee could only be cleared by deleting
the whole draft. Add a trash-icon action column to the "Anställda" table,
gated on draft status + write permission to match the endpoint's guard,
with a confirm prompt and success/error toast.

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

* fix(settings): prevent horizontal overflow on mobile

The company settings invite form was a non-wrapping fixed-width flex row that overflowed narrow viewports, forcing the full-screen settings modal to scroll on the x-axis. Stack the form vertically on mobile (sm:flex-row at and above the sm breakpoint) and add the missing min-w-0 guard to the modal content pane.

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

* feat(bookkeeping): move journal entry filters into a filter dialog

The ledger toolbar showed every filter inline (fiscal year, sort, series,
date range, missing-documents toggle), which felt cluttered. Keep only the
search field visible and move the rest into a "Filtrera" dialog with an
active-filter count badge.

- JournalEntryList now owns the fiscal-year scope, restored from the same
  localStorage key FiscalYearSelector writes, so the page no longer renders
  the selector separately.
- Filters apply live and the dialog stays open; "Rensa alla filter" clears them.
- Export STORAGE_KEY_PREFIX / ALL_YEARS_VALUE from FiscalYearSelector so the
  list reuses the persisted selection without duplicating the key.

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

* feat(transactions): implement imported transaction guard for deletion

- Added a guard to prevent deletion of transactions that are imported via bank sync or file uploads.
- Introduced `isImportedTransaction` utility to determine if a transaction is user-created or imported.
- Updated DELETE endpoint to return a 409 status for attempts to delete imported transactions.
- Enhanced transaction history and inbox components to reflect the new deletion rules.
- Added tests for transaction origin determination and deletion behavior.
- Updated UI components to include a confirmation dialog for clearing journal entry forms.
- Localized new strings for clearing form functionality in English and Swedish.

* feat(transactions): enhance transaction deletion guard and improve fiscal year visibility

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:25:27 +02:00

201 lines
7.7 KiB
TypeScript

import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { SALARY_ACCOUNTS, getLineItemAccount } from '@/lib/salary/account-mapping'
import type { CreateJournalEntryLineInput } from '@/types'
ensureInitialized()
/**
* Preview the journal entries that would be created when booking this salary run.
* Shows exact BAS accounts and amounts — this is a key differentiator.
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (runError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
// Load employees with line items
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(employment_type), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
if (!employees || employees.length === 0) {
return NextResponse.json({ error: 'Inga beräknade resultat — kör beräkning först' }, { status: 400 })
}
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const desc = `Lön ${periodLabel}`
// Build salary entry preview
const salaryLines: CreateJournalEntryLineInput[] = []
const expenseByAccount = new Map<string, number>()
for (const sre of employees) {
for (const li of sre.line_items || []) {
if (li.is_net_deduction || li.is_gross_deduction) continue
const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee')
expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount)
}
}
for (const [account, amount] of expenseByAccount) {
if (amount === 0) continue
salaryLines.push({
account_number: account,
debit_amount: amount > 0 ? Math.round(amount * 100) / 100 : 0,
credit_amount: amount < 0 ? Math.round(Math.abs(amount) * 100) / 100 : 0,
line_description: `${desc}`,
})
}
const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0)
if (totalTax > 0) {
salaryLines.push({
account_number: SALARY_ACCOUNTS.TAX_WITHHELD,
debit_amount: 0,
credit_amount: Math.round(totalTax * 100) / 100,
line_description: `${desc} — Personalskatt`,
})
}
const totalNet = employees.reduce((sum, e) => sum + e.net_salary, 0)
if (totalNet > 0) {
salaryLines.push({
account_number: SALARY_ACCOUNTS.BANK,
debit_amount: 0,
credit_amount: Math.round(totalNet * 100) / 100,
line_description: `${desc} — Nettolön`,
})
}
// Build avgifter entry preview — skipped for a nollkörning (0 avgifter),
// mirroring the vacation/pension guards below. The bookkeeping engine never
// posts an all-zero 7510/2731 voucher (see book/route.ts nollkörning path),
// so previewing one would falsely imply a verifikat that is never created.
const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0)
const roundedAvgifter = Math.round(totalAvgifter * 100) / 100
const avgifterLines: CreateJournalEntryLineInput[] = roundedAvgifter !== 0
? [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: roundedAvgifter,
credit_amount: 0,
line_description: `${desc} — Arbetsgivaravgifter`,
},
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: roundedAvgifter,
line_description: `${desc} — Arbetsgivaravgifter`,
},
]
: []
// Build vacation entry preview
const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
const totalVacationAvgifter = employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
const vacationLines: CreateJournalEntryLineInput[] = []
if (totalVacation > 0) {
vacationLines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
debit_amount: Math.round(totalVacation * 100) / 100,
credit_amount: 0,
line_description: `${desc} — Semesteravsättning`,
},
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
debit_amount: 0,
credit_amount: Math.round(totalVacation * 100) / 100,
line_description: `${desc} — Semesteravsättning`,
}
)
}
if (totalVacationAvgifter > 0) {
vacationLines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
debit_amount: Math.round(totalVacationAvgifter * 100) / 100,
credit_amount: 0,
line_description: `${desc} — Sociala avgifter semester`,
},
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: Math.round(totalVacationAvgifter * 100) / 100,
line_description: `${desc} — Sociala avgifter semester`,
}
)
}
// Build pension entry preview (löneväxling — per deductions-lonevaxling.md)
// This would be populated from salary_line_items with type 'gross_deduction_pension'
// For now, pension preview is shown when pension line items exist
const pensionLineItems = employees.flatMap(e =>
((e.line_items || []) as Array<Record<string, unknown>>)
.filter(li => li.item_type === 'gross_deduction_pension')
)
const pensionLines: CreateJournalEntryLineInput[] = []
if (pensionLineItems.length > 0) {
const totalPensionDeduction = Math.abs(pensionLineItems.reduce((s, li) => s + ((li.amount as number) || 0), 0))
const pensionContribution = Math.round(totalPensionDeduction * 1.058 * 100) / 100
const slp = Math.round(pensionContribution * 0.2426 * 100) / 100
if (pensionContribution > 0) {
pensionLines.push(
{ account_number: '7410', debit_amount: pensionContribution, credit_amount: 0, line_description: `${desc} — Pensionsförsäkringspremier` },
{ account_number: '2740', debit_amount: 0, credit_amount: pensionContribution, line_description: `${desc} — Pensionsförsäkringspremier` },
)
if (slp > 0) {
pensionLines.push(
{ account_number: '7533', debit_amount: slp, credit_amount: 0, line_description: `${desc} — Särskild löneskatt 24,26%` },
{ account_number: '2514', debit_amount: 0, credit_amount: slp, line_description: `${desc} — Särskild löneskatt 24,26%` },
)
}
}
}
return NextResponse.json({
data: {
// Each entry is null when it has no lines — a nollkörning posts nothing,
// so the salary and avgifter entries fall away just like vacation/pension
// already do, and the UI can simply skip the null ones.
salaryEntry: salaryLines.length > 0 ? {
description: desc,
lines: salaryLines,
} : null,
avgifterEntry: avgifterLines.length > 0 ? {
description: `${desc} — Arbetsgivaravgifter`,
lines: avgifterLines,
} : null,
vacationEntry: vacationLines.length > 0 ? {
description: `${desc} — Semesteravsättning`,
lines: vacationLines,
} : null,
pensionEntry: pensionLines.length > 0 ? {
description: `${desc} — Pensionsavsättning`,
lines: pensionLines,
} : null,
},
})
}