Add/skv salary agi (#423)

* feat: add Bankgirot LB-fil support for salary payments and tax payments

- Implemented `generateBgLb` for salary batch payments, producing opening, payment, and closing records.
- Added tests for `generateBgLb` to ensure correct record generation and validation.
- Created `generateBankgiroPaymentBgLb` for single tax payments to Skatteverket, including validation and formatting.
- Added tests for `generateBankgiroPaymentBgLb` to verify record structure and data integrity.
- Introduced `generateSkattekontoOcr` for generating valid OCR references for Skattekonto payments, with tests for various input formats.
- Updated database schema to track payment file formats and timestamps for salary runs and AGI declarations.
- Created a new table for logging salary payslip deliveries to ensure compliance with audit requirements.

* feat: add write permission check and company ID validation for payment file generation

* feat: add write permission check for salary payment file generation
This commit is contained in:
Mattsson
2026-05-09 12:41:16 +02:00
committed by GitHub
parent c238542596
commit 7e81f661b2
24 changed files with 1870 additions and 20 deletions
@@ -141,6 +141,11 @@ export const POST = withRouteContext(
const periodEndDate = new Date(Date.UTC(periodYear, periodMonth, 0)) // last day of month
const periodEnd = periodEndDate.toISOString().slice(0, 10)
// Track employees who hit Försäkringskassan day-15 transition or läkarintyg
// dag 8 — surfaced as warnings in the response so the UI can flag them.
const lakarintygEmployees: string[] = []
const fkReportingEmployees: string[] = []
for (const sre of runEmployees) {
const emp = sre.employee
if (!emp) continue
@@ -161,6 +166,10 @@ export const POST = withRouteContext(
periodEnd,
})
const employeeName = `${emp.first_name} ${emp.last_name}`
if (absenceResult.flagLakarintyg) lakarintygEmployees.push(employeeName)
if (absenceResult.flagFkReporting) fkReportingEmployees.push(employeeName)
const { error: delAbsErr } = await supabase
.from('salary_line_items')
.delete()
@@ -341,6 +350,23 @@ export const POST = withRouteContext(
)
}
if (lakarintygEmployees.length > 0) {
// Per Sjuklönelagen 8§: from day 8 of a sjuklöneperiod the employer can
// require a läkarintyg. Day 1–7 use sjukförsäkran (employee declaration).
warnings.push(
`Läkarintyg krävs från och med dag 8: ${lakarintygEmployees.join(', ')}. ` +
`Kontrollera att läkarintyg finns innan lönekörningen godkänns.`
)
}
if (fkReportingEmployees.length > 0) {
// Day 15+ falls on Försäkringskassan; the employer reports via FK.
warnings.push(
`Försäkringskassan tar över sjuklön från dag 15: ${fkReportingEmployees.join(', ')}. ` +
`Säkerställ att anmälan till FK är gjord.`
)
}
return NextResponse.json({ data: updatedRun, warnings })
},
{ requireWrite: true },
@@ -0,0 +1,155 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { generateBgLb } from '@/lib/salary/payment/bg-lb-generator'
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
import type { BgLbCompanyData, BgLbEmployee } from '@/lib/salary/payment/bg-lb-generator'
ensureInitialized()
/**
* Generate Bankgirot LB-fil for a salary run.
*
* Used by Swedish banks (Swedbank, SEB, Handelsbanken, Nordea) for batch
* salary payments via the corporate portal. The file is uploaded; Bankgirot
* routes funds from the company's BG to each employee's bank account.
*
* Per BFL: The payment file is räkenskapsinformation linked to the salary
* journal entry. Subject to 7-year retention.
*/
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 writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { data: run } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (!['approved', 'paid', 'booked'].includes(run.status)) {
return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 })
}
const { data: company } = await supabase
.from('companies')
.select('name')
.eq('id', companyId)
.single()
if (!company) {
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
}
const { data: settings } = await supabase
.from('company_settings')
.select('bankgiro')
.eq('company_id', companyId)
.single()
if (!settings?.bankgiro) {
return NextResponse.json(
{ error: 'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.' },
{ status: 400 }
)
}
if (!validateBankgiroNumber(settings.bankgiro)) {
return NextResponse.json(
{ error: 'Bankgironumret i företagsinställningar är ogiltigt (felaktig kontrollsiffra).' },
{ status: 400 }
)
}
const { data: runEmployees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)')
.eq('salary_run_id', id)
if (!runEmployees || runEmployees.length === 0) {
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
}
const missingBank = runEmployees.filter((sre) => {
const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null
return !emp?.clearing_number || !emp?.bank_account_number
})
if (missingBank.length > 0) {
return NextResponse.json(
{ error: `${missingBank.length} anställd(a) saknar bankkontouppgifter` },
{ status: 400 }
)
}
const companyData: BgLbCompanyData = {
name: company.name,
senderBankgiro: settings.bankgiro,
}
const employees: BgLbEmployee[] = runEmployees
.filter((sre) => sre.net_salary > 0)
.map((sre) => {
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,
}
})
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
let result
try {
result = generateBgLb(companyData, employees, {
paymentDate: run.payment_date,
periodLabel,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'Kunde inte generera Bankgirot LB-fil'
return NextResponse.json({ error: msg }, { status: 400 })
}
await supabase
.from('salary_runs')
.update({
payment_file_format: 'bg_lb',
payment_file_generated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
// ISO 8859-1 encoding — re-encode the JS string to Latin-1 bytes.
const buffer = Buffer.from(result.content, 'latin1')
return new Response(buffer, {
headers: {
'Content-Type': 'text/plain; charset=iso-8859-1',
'Content-Disposition': `attachment; filename="${result.filename}"`,
},
})
}
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { generatePain001 } from '@/lib/salary/payment/pain001-generator'
import { getBranding } from '@/lib/branding/service'
import type { Pain001CompanyData, Pain001Employee } from '@/lib/salary/payment/pain001-generator'
@@ -25,6 +26,9 @@ export async function GET(
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)
// Load salary run
@@ -52,7 +56,7 @@ export async function GET(
const { data: settings } = await supabase
.from('company_settings')
.select('bank_iban, bank_bic')
.select('iban, bic')
.eq('company_id', companyId)
.single()
@@ -60,7 +64,7 @@ export async function GET(
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
}
if (!settings?.bank_iban || !settings?.bank_bic) {
if (!settings?.iban || !settings?.bic) {
return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 })
}
@@ -89,8 +93,8 @@ export async function GET(
const companyData: Pain001CompanyData = {
name: company.name,
orgNumber: company.org_number || '',
iban: settings.bank_iban,
bic: settings.bank_bic,
iban: settings.iban,
bic: settings.bic,
}
const employees: Pain001Employee[] = runEmployees
@@ -114,6 +118,15 @@ export async function GET(
periodLabel,
})
await supabase
.from('salary_runs')
.update({
payment_file_format: 'pain001',
payment_file_generated_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
return new Response(xml, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
@@ -82,6 +82,17 @@ export async function POST(
if (!emp?.email) {
skipped++
// Persist a 'skipped' record so the audit trail is complete (BFL 7 kap.).
// Use a placeholder address since the column is NOT NULL.
await supabase.from('salary_payslip_deliveries').insert({
company_id: companyId,
salary_run_id: id,
employee_id: sre.employee_id,
user_id: user.id,
email_address: '(saknas)',
status: 'skipped',
error_message: 'Anställd saknar e-postadress',
})
continue
}
@@ -128,7 +139,7 @@ export async function POST(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pdfBuffer = await renderToBuffer(PayslipPDF({ data }) as any)
await emailService.sendEmail({
const sendResult = await emailService.sendEmail({
to: emp.email,
subject: `Lönespecifikation ${monthName} ${run.period_year} — ${company.name}`,
html: `<p>Hej ${emp.first_name},</p>
@@ -142,10 +153,48 @@ export async function POST(
}],
})
if (!sendResult.success) {
const msg = sendResult.error || 'E-postlevereantör returnerade ett fel'
errors.push(`${emp.first_name} ${emp.last_name}: ${msg}`)
await supabase.from('salary_payslip_deliveries').insert({
company_id: companyId,
salary_run_id: id,
employee_id: sre.employee_id,
user_id: user.id,
email_address: emp.email,
status: 'failed',
provider: 'resend',
error_message: msg.slice(0, 500),
})
continue
}
await supabase.from('salary_payslip_deliveries').insert({
company_id: companyId,
salary_run_id: id,
employee_id: sre.employee_id,
user_id: user.id,
email_address: emp.email,
status: 'sent',
provider: 'resend',
provider_message_id: sendResult.messageId ?? null,
})
sent++
} catch (err) {
const msg = err instanceof Error ? err.message : 'Okänt fel'
errors.push(`${emp.first_name} ${emp.last_name}: ${msg}`)
await supabase.from('salary_payslip_deliveries').insert({
company_id: companyId,
salary_run_id: id,
employee_id: sre.employee_id,
user_id: user.id,
email_address: emp.email,
status: 'failed',
provider: 'resend',
error_message: msg.slice(0, 500),
})
}
}