feat: voucher linking, recovery ops, and salary overrides (#591)
* feat: voucher linking, recovery ops, and salary overrides Adds reversible/correction-style write paths that customers and agents have been asking for, plus per-run salary employee overrides. Invoice → voucher linking - POST /api/invoices/[id]/link-to-voucher and GET /api/invoices/[id]/voucher-candidates - lib/invoices/voucher-matching.ts with full + pg test coverage - LinkVoucherPicker UI in PaymentBookingDialog - pending_operations.operation_type expanded with link_invoice_voucher (medium risk) and a (journal_entry_id, invoice_id) unique guard - MCP: gnubok_find_voucher_candidates_for_invoice and gnubok_link_invoice_to_voucher tools SIE undo - POST /api/import/sie/[id]/undo + undo_sie_import RPC - sie_imports.status gains 'undone' - ImportResultStep surfaces the action; structured error SIE_UNDO_FAILED Edit-recreate journal entries - POST /api/bookkeeping/journal-entries/[id]/edit-recreate - Bookkeeping detail page wires it into the existing edit flow Delete-last-voucher clears IB link - Trigger + pg test ensure deleting the last voucher of a period nulls the opening_balance_journal_entry_id link so a re-import lands cleanly Salary employee overrides - salary_run_employees gains per-run override fields + migration - lib/salary/effective-values.ts centralises resolved values; all payslip, payment, AGI, KU, and booking routes read through it - SalaryOverridePanel on the employee detail page Account classifier - lib/bookkeeping/account-classifier.ts + tests; AddAccountDialog uses it - backfill-import-accounts script updated Misc - toast: minor styling tweak - AGI generate-declaration: respect effective values - structured-errors: new LINK_INVOICE_VOUCHER namespace Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add link_invoice_voucher operation type to pending_operations * feat: refactor salary run calculations and update error handling for SIE imports * fix: PR review feedback on voucher linking and SIE recovery pg-real (blocking): - tests/pg/delete-last-voucher-ib: drop posted_at = now() from the seed UPDATE — journal_entries has no posted_at column. - lib/invoices/__tests__/voucher-matching.pg: seed the posted voucher before closing the fiscal period so enforce_period_lock doesn't block the INSERT during setup. voucher-matching error codes and rollback: - Add LINK_VOUCHER_DB_ERROR (HTTP 500) and return it on real invoice UPDATE / payment INSERT failures. Previously these returned LINK_VOUCHER_VOUCHER_NOT_FOUND (404) which the pending-op dispatcher auto-rejects on transient DB errors. - Log rollback failures explicitly so an invoice left in a half-linked state (advanced status, no payment row) surfaces for manual reconciliation instead of disappearing silently. resyncNextPeriodOpeningBalance ordering: - Create the new IB first, relink the period FK, then storno the old IB. Previously the storno ran first; if createJournalEntry failed the next period was left with a reversed IB and nothing to replace it, and executeSIEImport swallows the error as a non-fatal warning. replace_period_opening_balance_link: - Tighten role check to owner/admin (was owner/admin/member). Matches delete_last_voucher and undo_sie_import. Data minimisation: - /api/invoices/[id]/voucher-candidates and the matching MCP tools now project only the invoice and customer fields the matcher reads, instead of returning the full customer row. Schema bounds: - SalaryEmployeeOverrideSchema caps each numeric override at 10 MSEK to catch typos before they reach the ledger or AGI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): supply user_id when seeding voucher_sequences voucher_sequences.user_id is NOT NULL (per the multi-tenant refactor in 20260330130000). The previous test seed only set company_id / fiscal_period_id / voucher_series, which made the seed fail with a constraint violation on the latest pg-real run. Pass the same userId used elsewhere in the seed helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): scope delete-last-voucher RPC assertions inside the tx withUserContext always ROLLBACKs, so any DELETE the RPC performs is discarded when the callback returns. The previous test then queried journal_entries via a fresh getPool() connection that only saw the pre-RPC committed seed state — hence "expected '1' to be '0'". Move every post-RPC assertion (entry count, period FK clear, opening_balances_set flip, audit log entry, sie_imports clear) inside the same withUserContext callback so they observe the uncommitted state before ROLLBACK fires. Also fix the sie_imports INSERT: the column is `filename`, not `file_name`, and `sie_type` is NOT NULL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): assert against the IB-marker audit row directly DELETE on journal_entries fires two audit_log writes: the generic write_audit_log() trigger row ("Deleted journal_entries record") and the delete_last_voucher RPC's explicit "(was period IB)" entry. Both land at the same statement_timestamp(), so ORDER BY created_at DESC LIMIT 1 returned the trigger row non-deterministically in CI. Switch to a presence check with a LIKE filter on the IB marker so the test verifies what it actually cares about — that the RPC's IB-aware audit row exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(db): set company_id on delete_last_voucher audit_log rows 20260528120000_delete_last_voucher_clears_ib_link.sql inserts directly into audit_log without setting company_id. audit_log's SELECT policy filters company_id IN user_company_ids(), so those rows landed with company_id=NULL and were invisible to every reader — only the generic write_audit_log() trigger row remained visible. That broke BFL audit- trail intent: the "(was period IB)" provenance row was never readable. Republish delete_last_voucher with p_company_id populated on both audit_log INSERTs (draft path and posted path). Behavior is otherwise unchanged; the pg-real test for the IB-clear flow now sees the RPC-written marker row as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Emil
parent
cb7eac90b1
commit
ccdfed5fea
@@ -84,6 +84,12 @@ export async function POST(request: Request) {
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json(
|
||||
{ error: `Kontonummer ${body.account_number} finns redan i din kontoplan.` },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { undoSIEImport } from '@/lib/import/sie-import'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* DELETE /api/import/sie/[id]/undo
|
||||
*
|
||||
* Undo a completed SIE import — hard-deletes all journal entries created
|
||||
* by the import (transaction vouchers + the opening_balance entry),
|
||||
* detaches any user-attached documents, resets voucher_sequences, and
|
||||
* marks the sie_imports row as 'undone'. Period must be open and not
|
||||
* locked. Owner/admin only (enforced by the RPC).
|
||||
*/
|
||||
export const DELETE = withRouteContext(
|
||||
'sie_import.undo',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ sieImportId: id })
|
||||
|
||||
const result = await undoSIEImport(supabase, companyId!, id)
|
||||
|
||||
if (!result.success) {
|
||||
return errorResponseFromCode('SIE_UNDO_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: result.error },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, deletedEntries: result.deletedEntries })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { LinkInvoiceToVoucherSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/link-to-voucher
|
||||
*
|
||||
* Marks an invoice as paid by linking an existing posted verifikat whose
|
||||
* lines already credit AR (1510). Creates no new journal entry — only an
|
||||
* invoice_payments row + invoice status advance.
|
||||
*
|
||||
* Rejects with LINK_VOUCHER_NO_AR_CREDIT for vouchers that book income
|
||||
* directly (e.g. 1930→3001) — those require gnubok_correct_entry first.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'invoice.link_to_voucher',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ invoiceId: id })
|
||||
|
||||
const validation = await validateBody(request, LinkInvoiceToVoucherSchema, {
|
||||
log: opLog,
|
||||
operation: 'invoice.link_to_voucher',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { journal_entry_id, notes } = validation.data
|
||||
|
||||
const outcome = await linkInvoiceToVoucher(supabase, user.id, companyId, {
|
||||
invoiceId: id,
|
||||
journalEntryId: journal_entry_id,
|
||||
notes,
|
||||
})
|
||||
|
||||
if (!outcome.ok) {
|
||||
return errorResponseFromCode(outcome.code, opLog, {
|
||||
requestId,
|
||||
details: outcome.details,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
invoice_status: outcome.result.invoiceStatus,
|
||||
paid_amount: outcome.result.paidAmount,
|
||||
remaining_amount: outcome.result.remainingAmount,
|
||||
payment_amount: outcome.result.paymentAmount,
|
||||
payment_id: outcome.result.paymentId,
|
||||
journal_entry_id: outcome.result.journalEntryId,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { findMatchingVouchersForInvoice } from '@/lib/invoices/voucher-matching'
|
||||
import type { Invoice, Customer } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/invoices/[id]/voucher-candidates
|
||||
*
|
||||
* Returns posted verifikat candidates that could be linked as payment for
|
||||
* this invoice. Used by the "Befintlig verifikation" tab in
|
||||
* PaymentBookingDialog to auto-suggest matches.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'invoice.voucher_candidates',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
// Project only the fields the matcher actually reads. Avoids leaking the
|
||||
// full customer row (address, contact, etc.) into the API response.
|
||||
const { data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(
|
||||
'id, invoice_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)'
|
||||
)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !invoice) {
|
||||
return errorResponseFromCode('LINK_VOUCHER_INVOICE_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||||
return NextResponse.json({ data: { candidates: [], invoice_status: invoice.status } })
|
||||
}
|
||||
|
||||
const candidates = await findMatchingVouchersForInvoice(
|
||||
supabase,
|
||||
companyId,
|
||||
// Narrow projection above means TS infers `customer` as `{ id, name }[]`
|
||||
// from the join shorthand. The matcher only reads `customer?.name`, so
|
||||
// cast through unknown to the runtime shape it expects.
|
||||
invoice as unknown as Invoice & { customer?: Customer }
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: { candidates } })
|
||||
},
|
||||
)
|
||||
@@ -56,7 +56,8 @@ export async function GET(
|
||||
const { data: runEmployees, error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select(`
|
||||
employee_id, gross_salary, tax_withheld, avgifter_basis,
|
||||
employee_id, gross_salary, tax_withheld, tax_withheld_override,
|
||||
avgifter_basis, avgifter_basis_override,
|
||||
employee:employees(personnummer, specification_number, employment_start, employment_end),
|
||||
salary_run:salary_runs!inner(period_year, status),
|
||||
line_items:salary_line_items(item_type, amount)
|
||||
@@ -100,8 +101,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
current.totalGross += sre.gross_salary
|
||||
current.totalTax += sre.tax_withheld
|
||||
current.totalAvgifterBasis += sre.avgifter_basis
|
||||
// Honor advanced-mode override so KU matches AGI + the ledger.
|
||||
current.totalTax += sre.tax_withheld_override ?? sre.tax_withheld
|
||||
current.totalAvgifterBasis += sre.avgifter_basis_override ?? sre.avgifter_basis
|
||||
|
||||
// Sum benefits by type from line items
|
||||
const lineItems = (sre.line_items || []) as Array<{ item_type: string; amount: number }>
|
||||
|
||||
@@ -60,9 +60,11 @@ export const POST = withRouteContext(
|
||||
employee_id: sre.employee_id,
|
||||
employment_type: sre.employee?.employment_type || 'employee',
|
||||
gross_salary: sre.gross_salary,
|
||||
tax_withheld: sre.tax_withheld,
|
||||
net_salary: sre.net_salary,
|
||||
avgifter_amount: sre.avgifter_amount,
|
||||
// Apply per-employee overrides (advanced mode) so manual
|
||||
// adjustments for FoU-avdrag / jämkning flow into the ledger.
|
||||
tax_withheld: sre.tax_withheld_override ?? sre.tax_withheld,
|
||||
net_salary: sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld)),
|
||||
avgifter_amount: sre.avgifter_amount_override ?? sre.avgifter_amount,
|
||||
avgifter_rate: sre.avgifter_rate,
|
||||
vacation_accrual: sre.vacation_accrual,
|
||||
vacation_accrual_avgifter: sre.vacation_accrual_avgifter,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -36,6 +38,83 @@ export async function GET(
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply per-employee override on tax/avgifter (advanced mode).
|
||||
*
|
||||
* Only allowed in `review` status — the calculation engine has run, but the
|
||||
* run hasn't been approved or booked yet. After approval, vouchers and AGI
|
||||
* lock in the effective values; further changes require correction flows.
|
||||
*
|
||||
* Pass `null` for any field to clear a previously-set override.
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; employeeId: string }> },
|
||||
) {
|
||||
const { id, employeeId } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const parsed = await validateBody(request, SalaryEmployeeOverrideSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
// Gate on run status. Override is only valid mid-review.
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (run.status !== 'review') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Build patch — only include fields that were explicitly provided so
|
||||
// unrelated overrides are not nulled.
|
||||
const patch: Record<string, number | string | null> = {}
|
||||
if ('tax_withheld_override' in parsed.data) {
|
||||
patch.tax_withheld_override = parsed.data.tax_withheld_override ?? null
|
||||
}
|
||||
if ('avgifter_amount_override' in parsed.data) {
|
||||
patch.avgifter_amount_override = parsed.data.avgifter_amount_override ?? null
|
||||
}
|
||||
if ('avgifter_basis_override' in parsed.data) {
|
||||
patch.avgifter_basis_override = parsed.data.avgifter_basis_override ?? null
|
||||
}
|
||||
if ('reason' in parsed.data) {
|
||||
patch.override_reason = parsed.data.reason ?? null
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.update(patch)
|
||||
.eq('salary_run_id', id)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.select('id, tax_withheld, tax_withheld_override, avgifter_amount, avgifter_amount_override, avgifter_basis, avgifter_basis_override, override_reason')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/** Remove employee from a draft salary run. Cascades to delete their line items. */
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
|
||||
@@ -105,8 +105,15 @@ export async function GET(
|
||||
}
|
||||
|
||||
const employees: BgLbEmployee[] = runEmployees
|
||||
.filter((sre) => sre.net_salary > 0)
|
||||
.map((sre) => {
|
||||
// Honor tax override on the bank payment file too — the net the
|
||||
// employee actually receives depends on the effective tax.
|
||||
const effectiveNet =
|
||||
sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
|
||||
return { sre, effectiveNet }
|
||||
})
|
||||
.filter(({ effectiveNet }) => effectiveNet > 0)
|
||||
.map(({ sre, effectiveNet }) => {
|
||||
const emp = sre.employee as {
|
||||
first_name: string
|
||||
last_name: string
|
||||
@@ -117,7 +124,7 @@ export async function GET(
|
||||
name: `${emp.first_name} ${emp.last_name}`,
|
||||
clearingNumber: emp.clearing_number,
|
||||
bankAccountNumber: emp.bank_account_number,
|
||||
netSalary: sre.net_salary,
|
||||
netSalary: effectiveNet,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -98,14 +98,19 @@ export async function GET(
|
||||
}
|
||||
|
||||
const employees: Pain001Employee[] = runEmployees
|
||||
.filter(sre => sre.net_salary > 0)
|
||||
.map(sre => {
|
||||
const effectiveNet =
|
||||
sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld))
|
||||
return { sre, effectiveNet }
|
||||
})
|
||||
.filter(({ effectiveNet }) => effectiveNet > 0)
|
||||
.map(({ sre, effectiveNet }) => {
|
||||
const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string }
|
||||
return {
|
||||
name: `${emp.first_name} ${emp.last_name}`,
|
||||
clearingNumber: emp.clearing_number,
|
||||
bankAccountNumber: emp.bank_account_number,
|
||||
netSalary: sre.net_salary,
|
||||
netSalary: effectiveNet,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -89,9 +89,39 @@ export async function GET(
|
||||
taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}`
|
||||
}
|
||||
|
||||
// Build breakdown steps from calculation_breakdown
|
||||
// Build breakdown steps from calculation_breakdown, then append rows for
|
||||
// any manual overrides so the breakdown matches the displayed totals.
|
||||
// The engine-computed rows stay for transparency ("this is what was
|
||||
// computed"), and override rows below them show the manual adjustment and
|
||||
// its reason ("this is what was actually applied").
|
||||
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number }> } | null
|
||||
const breakdownSteps = breakdown?.steps
|
||||
const baseSteps = breakdown?.steps ?? []
|
||||
const overrideSteps: Array<{ label: string; formula: string; output: number }> = []
|
||||
const reason = (sre.override_reason as string | null) || 'manuell justering'
|
||||
if (sre.tax_withheld_override !== null && sre.tax_withheld_override !== undefined) {
|
||||
overrideSteps.push({
|
||||
label: 'Manuell justering: Skatteavdrag',
|
||||
formula: reason,
|
||||
output: Number(sre.tax_withheld_override),
|
||||
})
|
||||
}
|
||||
if (sre.avgifter_basis_override !== null && sre.avgifter_basis_override !== undefined) {
|
||||
overrideSteps.push({
|
||||
label: 'Manuell justering: Avgiftsunderlag',
|
||||
formula: reason,
|
||||
output: Number(sre.avgifter_basis_override),
|
||||
})
|
||||
}
|
||||
if (sre.avgifter_amount_override !== null && sre.avgifter_amount_override !== undefined) {
|
||||
overrideSteps.push({
|
||||
label: 'Manuell justering: Arbetsgivaravgifter',
|
||||
formula: reason,
|
||||
output: Number(sre.avgifter_amount_override),
|
||||
})
|
||||
}
|
||||
const breakdownSteps = baseSteps.length > 0 || overrideSteps.length > 0
|
||||
? [...baseSteps, ...overrideSteps]
|
||||
: undefined
|
||||
|
||||
// Build bank account display (masked)
|
||||
let bankAccount: string | undefined
|
||||
@@ -100,6 +130,13 @@ export async function GET(
|
||||
bankAccount = `${emp.clearing_number}-****${lastDigits}`
|
||||
}
|
||||
|
||||
// Honor advanced-mode per-employee overrides (tax/avgifter) on the payslip
|
||||
// so the employee sees the same effective values that are booked and AGI-
|
||||
// reported.
|
||||
const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld
|
||||
const effectiveAvgifter = sre.avgifter_amount_override ?? sre.avgifter_amount
|
||||
const effectiveNet = sre.net_salary + (sre.tax_withheld - effectiveTax)
|
||||
|
||||
const data: PayslipData = {
|
||||
companyName: company.name,
|
||||
companyOrgNumber: company.org_number || '',
|
||||
@@ -111,14 +148,14 @@ export async function GET(
|
||||
paymentDate: run.payment_date,
|
||||
lineItems,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
netSalary: sre.net_salary,
|
||||
taxWithheld: effectiveTax,
|
||||
netSalary: effectiveNet,
|
||||
taxReference,
|
||||
avgifterRate: sre.avgifter_rate,
|
||||
avgifterAmount: sre.avgifter_amount,
|
||||
avgifterAmount: effectiveAvgifter,
|
||||
vacationAccrual: sre.vacation_accrual,
|
||||
vacationAccrualAvgifter: sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + effectiveAvgifter + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
ytdGross: sre.ytd_gross,
|
||||
ytdTax: sre.ytd_tax,
|
||||
ytdNet: sre.ytd_net,
|
||||
@@ -135,7 +172,7 @@ export async function GET(
|
||||
return new Response(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||
'Content-Disposition': `inline; filename="${fileName}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ async function _sendPayslipsImpl(
|
||||
taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}`
|
||||
}
|
||||
|
||||
const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld
|
||||
const effectiveAvgifter = sre.avgifter_amount_override ?? sre.avgifter_amount
|
||||
const effectiveNet = sre.net_salary + (sre.tax_withheld - effectiveTax)
|
||||
|
||||
const data: PayslipData = {
|
||||
companyName: company.name,
|
||||
companyOrgNumber: company.org_number || '',
|
||||
@@ -133,14 +137,14 @@ async function _sendPayslipsImpl(
|
||||
paymentDate: run.payment_date,
|
||||
lineItems,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
netSalary: sre.net_salary,
|
||||
taxWithheld: effectiveTax,
|
||||
netSalary: effectiveNet,
|
||||
taxReference,
|
||||
avgifterRate: sre.avgifter_rate,
|
||||
avgifterAmount: sre.avgifter_amount,
|
||||
avgifterAmount: effectiveAvgifter,
|
||||
vacationAccrual: sre.vacation_accrual,
|
||||
vacationAccrualAvgifter: sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + effectiveAvgifter + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
ytdGross: sre.ytd_gross,
|
||||
ytdTax: sre.ytd_tax,
|
||||
ytdNet: sre.ytd_net,
|
||||
|
||||
Reference in New Issue
Block a user