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
+57 -1
View File
@@ -16,6 +16,8 @@ import { formatCurrency } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInput } from '@/types'
import { AGIPanel } from '@/components/salary/AGIPanel'
import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel'
import { TaxPaymentPanel } from '@/components/salary/TaxPaymentPanel'
type SalaryRunWithArbetsgivare = SalaryRun & { arbetsgivare?: string | null }
@@ -60,12 +62,25 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [addEmployeeKey, setAddEmployeeKey] = useState(0)
const [preferredPaymentFormat, setPreferredPaymentFormat] = useState<'bg_lb' | 'pain001'>('bg_lb')
const [taxPayment, setTaxPayment] = useState<{
tax_payment_file_generated_at: string | null
tax_paid_at: string | null
} | null>(null)
async function loadRun() {
const res = await fetch(`/api/salary/runs/${id}`)
if (res.ok) {
const { data } = await res.json()
setRun(data)
if (data?.period_year && data?.period_month) {
const period = `${data.period_year}-${String(data.period_month).padStart(2, '0')}`
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
if (txRes.ok) {
const tx = await txRes.json()
setTaxPayment(tx.data)
}
}
}
}
@@ -77,6 +92,13 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
const { data } = await empRes.json()
setAvailableEmployees(data || [])
}
const settingsRes = await fetch('/api/settings')
if (settingsRes.ok) {
const { data } = await settingsRes.json()
if (data?.preferred_payment_format === 'pain001' || data?.preferred_payment_format === 'bg_lb') {
setPreferredPaymentFormat(data.preferred_payment_format)
}
}
setLoading(false)
}
load()
@@ -124,8 +146,16 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
setActionLoading('calculate')
const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' })
if (res.ok) {
const payload = await res.json()
await loadRun()
toast({ title: 'Beräkning klar' })
const warnings = (payload.warnings as string[] | undefined) ?? []
if (warnings.length === 0) {
toast({ title: 'Beräkning klar' })
} else {
for (const warning of warnings) {
toast({ title: 'Att kontrollera', description: warning })
}
}
} else {
const result = await res.json()
toast({
@@ -379,6 +409,32 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
</Card>
)}
{/* Payment file — available once the run is approved */}
{['approved', 'paid', 'booked'].includes(run.status) && (
<PaymentFilePanel
salaryRunId={id}
periodLabel={periodLabel}
paymentFileFormat={run.payment_file_format}
paymentFileGeneratedAt={run.payment_file_generated_at}
defaultFormat={preferredPaymentFormat}
readOnly={!canWrite}
onDownloaded={loadRun}
/>
)}
{/* Tax payment (skatt + arbetsgivaravgifter) — once AGI has been generated */}
{run.status === 'booked' && run.agi_generated_at && (
<TaxPaymentPanel
period={periodLabel}
totalTax={run.total_tax}
totalAvgifter={run.total_avgifter}
paymentFileGeneratedAt={taxPayment?.tax_payment_file_generated_at ?? null}
taxPaidAt={taxPayment?.tax_paid_at ?? null}
readOnly={!canWrite}
onChange={loadRun}
/>
)}
{/* AGI (Arbetsgivardeklaration) — available once the run is booked */}
{run.status === 'booked' && (
<div className="space-y-3">
@@ -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 17 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),
})
}
}
@@ -0,0 +1,65 @@
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'
ensureInitialized()
/**
* Mark the AGI period's tax payment (skatt + avgifter) as paid.
*
* This is a manual confirmation by the user — bank reconciliation against
* Skattekontot transactions can also flip this flag automatically (handled
* elsewhere via the Skattekonto sync).
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ period: string }> }
) {
const { period } = await params
const periodMatch = /^(\d{4})-(\d{2})$/.exec(period)
if (!periodMatch) {
return NextResponse.json(
{ error: 'Ogiltig period. Använd YYYY-MM (t.ex. 2026-04).' },
{ status: 400 }
)
}
const periodYear = parseInt(periodMatch[1], 10)
const periodMonth = parseInt(periodMatch[2], 10)
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: agi } = await supabase
.from('agi_declarations')
.select('id')
.eq('company_id', companyId)
.eq('period_year', periodYear)
.eq('period_month', periodMonth)
.single()
if (!agi) {
return NextResponse.json(
{ error: `Ingen AGI för perioden ${period}.` },
{ status: 404 }
)
}
const { error } = await supabase
.from('agi_declarations')
.update({ tax_paid_at: new Date().toISOString() })
.eq('id', agi.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { ok: true } })
}
@@ -0,0 +1,160 @@
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 { generateBankgiroPaymentBgLb } from '@/lib/salary/payment/bg-lb-generator'
import { generateSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '@/lib/skatteverket/skattekonto-ocr'
import { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
ensureInitialized()
/**
* Generate Bankgirot LB-fil for paying skatt + arbetsgivaravgifter for a
* given AGI period to Skatteverket's Bankgiro 5050-1055 with the company's
* Skattekontot OCR.
*
* Period format: "YYYY-MM" (e.g. "2026-04").
*
* Per BFL: Generated 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<{ period: string }> }
) {
const { period } = await params
const periodMatch = /^(\d{4})-(\d{2})$/.exec(period)
if (!periodMatch) {
return NextResponse.json(
{ error: 'Ogiltig period. Använd YYYY-MM (t.ex. 2026-04).' },
{ status: 400 }
)
}
const periodYear = parseInt(periodMatch[1], 10)
const periodMonth = parseInt(periodMatch[2], 10)
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: agi } = await supabase
.from('agi_declarations')
.select('id, total_tax, total_avgifter')
.eq('company_id', companyId)
.eq('period_year', periodYear)
.eq('period_month', periodMonth)
.single()
if (!agi) {
return NextResponse.json(
{ error: `Ingen AGI för perioden ${period}. Generera AGI först.` },
{ status: 404 }
)
}
const totalAmount = Math.round((agi.total_tax + agi.total_avgifter) * 100) / 100
if (totalAmount <= 0) {
return NextResponse.json(
{ error: `Inget belopp att betala för perioden ${period}.` },
{ status: 400 }
)
}
const { data: company } = await supabase
.from('companies')
.select('name, org_number')
.eq('id', companyId)
.single()
if (!company || !company.org_number) {
return NextResponse.json(
{ error: 'Organisationsnummer saknas för företaget.' },
{ status: 400 }
)
}
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.' },
{ status: 400 }
)
}
if (!validateBankgiroNumber(settings.bankgiro)) {
return NextResponse.json(
{ error: 'Bankgironumret är ogiltigt (felaktig kontrollsiffra).' },
{ status: 400 }
)
}
let ocr: string
try {
ocr = generateSkattekontoOcr(company.org_number)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Kunde inte generera OCR-nummer'
return NextResponse.json({ error: msg }, { status: 400 })
}
// Payment date = AGI deadline, which is the 12th of the following month
// (17th in Jan/Aug for ≤40 MSEK turnover, but we play safe with 12th here).
const paymentDate = computeTaxPaymentDate(periodYear, periodMonth)
let result
try {
result = generateBankgiroPaymentBgLb(
{ name: company.name, senderBankgiro: settings.bankgiro },
{
receiverBankgiro: SKATTEKONTO_BANKGIRO,
ocr,
amount: totalAmount,
receiverName: 'Skatteverket',
},
{ paymentDate, periodLabel: period }
)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Kunde inte generera betalfil'
return NextResponse.json({ error: msg }, { status: 400 })
}
await supabase
.from('agi_declarations')
.update({
tax_payment_file_generated_at: new Date().toISOString(),
tax_payment_file_format: 'bg_lb',
})
.eq('id', agi.id)
.eq('company_id', companyId)
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}"`,
},
})
}
/**
* Tax payment deadline = the 12th of the month *following* the AGI period.
* (Skatteverket also accepts the 17th in Jan/Aug for turnover ≤40 MSEK, but
* the conservative date is the 12th — money must be on the Skattekonto by
* then to avoid kostnadsränta.)
*/
function computeTaxPaymentDate(periodYear: number, periodMonth: number): string {
const deadlineMonth = periodMonth === 12 ? 1 : periodMonth + 1
const deadlineYear = periodMonth === 12 ? periodYear + 1 : periodYear
return `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-12`
}
@@ -0,0 +1,49 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
ensureInitialized()
/**
* Get tax payment status for an AGI period.
*
* Returns the AGI declaration's payment-tracking fields (file generated at,
* paid at, totals) so the UI can render the TaxPaymentPanel without
* round-tripping to load the full declaration.
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ period: string }> }
) {
const { period } = await params
const periodMatch = /^(\d{4})-(\d{2})$/.exec(period)
if (!periodMatch) {
return NextResponse.json(
{ error: 'Ogiltig period. Använd YYYY-MM.' },
{ status: 400 }
)
}
const periodYear = parseInt(periodMatch[1], 10)
const periodMonth = parseInt(periodMatch[2], 10)
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: agi } = await supabase
.from('agi_declarations')
.select('total_tax, total_avgifter, tax_payment_file_generated_at, tax_payment_file_format, tax_paid_at')
.eq('company_id', companyId)
.eq('period_year', periodYear)
.eq('period_month', periodMonth)
.single()
if (!agi) {
return NextResponse.json({ data: null })
}
return NextResponse.json({ data: agi })
}
+131
View File
@@ -0,0 +1,131 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Download, Loader2, CheckCircle2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
type PaymentFormat = 'bg_lb' | 'pain001'
interface PaymentFilePanelProps {
salaryRunId: string
periodLabel: string
paymentFileFormat: string | null
paymentFileGeneratedAt: string | null
defaultFormat: PaymentFormat
readOnly?: boolean
onDownloaded?: () => void
}
const FORMAT_LABEL: Record<PaymentFormat, string> = {
bg_lb: 'Bankgirot LB-fil',
pain001: 'SEPA pain.001 (XML)',
}
const FORMAT_DESCRIPTION: Record<PaymentFormat, string> = {
bg_lb: 'Standard för Swedbank, SEB, Handelsbanken, Nordea m.fl. Kräver bankgironummer hos Bankgirot.',
pain001: 'ISO 20022. För banker som inte är anslutna till Bankgirot, eller internationell SEPA.',
}
export function PaymentFilePanel({
salaryRunId,
periodLabel,
paymentFileFormat,
paymentFileGeneratedAt,
defaultFormat,
readOnly,
onDownloaded,
}: PaymentFilePanelProps) {
const { toast } = useToast()
const [format, setFormat] = useState<PaymentFormat>(defaultFormat)
const [downloading, setDownloading] = useState(false)
const endpoint =
format === 'bg_lb'
? `/api/salary/runs/${salaryRunId}/payment/bg-lb`
: `/api/salary/runs/${salaryRunId}/payment/pain001`
async function handleDownload() {
setDownloading(true)
try {
const res = await fetch(endpoint)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte generera betalfil' }))
toast({
title: 'Betalfil kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const ext = format === 'bg_lb' ? 'txt' : 'xml'
a.download = `lon_${periodLabel}.${ext}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({ title: 'Betalfil nedladdad' })
onDownloaded?.()
} finally {
setDownloading(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Betalfil till bank</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{paymentFileFormat && paymentFileGeneratedAt && (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-emerald-600 dark:text-emerald-400" />
<div>
Senast genererad:{' '}
<span className="text-foreground">
{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat}
</span>{' '}
({new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')})
</div>
</div>
)}
{!readOnly && (
<>
<div className="space-y-1">
<label className="text-sm font-medium">Format</label>
<Select value={format} onValueChange={(v) => setFormat(v as PaymentFormat)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bg_lb">{FORMAT_LABEL.bg_lb}</SelectItem>
<SelectItem value="pain001">{FORMAT_LABEL.pain001}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{FORMAT_DESCRIPTION[format]}</p>
</div>
<div className="flex justify-end">
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner betalfil
</Button>
</div>
</>
)}
</CardContent>
</Card>
)
}
+197
View File
@@ -0,0 +1,197 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Download, Loader2, CheckCircle2, ExternalLink } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
interface TaxPaymentPanelProps {
/** YYYY-MM */
period: string
totalTax: number
totalAvgifter: number
paymentFileGeneratedAt: string | null
taxPaidAt: string | null
readOnly?: boolean
onChange?: () => void
}
/**
* Generates a Bankgirot LB-fil for paying skatt + arbetsgivaravgifter for an
* AGI period to Skatteverket Bankgiro 5050-1055 with the company's
* Skattekontot OCR.
*/
export function TaxPaymentPanel({
period,
totalTax,
totalAvgifter,
paymentFileGeneratedAt,
taxPaidAt,
readOnly,
onChange,
}: TaxPaymentPanelProps) {
const { toast } = useToast()
const [downloading, setDownloading] = useState(false)
const [marking, setMarking] = useState(false)
const [paymentDeadline, setPaymentDeadline] = useState<string>('')
useEffect(() => {
const m = /^(\d{4})-(\d{2})$/.exec(period)
if (!m) return
const year = parseInt(m[1], 10)
const month = parseInt(m[2], 10)
const dlMonth = month === 12 ? 1 : month + 1
const dlYear = month === 12 ? year + 1 : year
setPaymentDeadline(`${dlYear}-${String(dlMonth).padStart(2, '0')}-12`)
}, [period])
const totalAmount = Math.round((totalTax + totalAvgifter) * 100) / 100
const handleDownload = useCallback(async () => {
setDownloading(true)
try {
const res = await fetch(`/api/skatteverket/tax-payments/${period}/payment-file`)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte generera betalfil' }))
toast({
title: 'Betalfil kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `bg_lb_skatt_${period}.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({ title: 'Betalfil för skatt nedladdad' })
onChange?.()
} finally {
setDownloading(false)
}
}, [period, toast, onChange])
const handleMarkPaid = useCallback(async () => {
setMarking(true)
try {
const res = await fetch(`/api/skatteverket/tax-payments/${period}/mark-paid`, {
method: 'POST',
})
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte markera som betald' }))
toast({
title: 'Fel',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({ title: 'Markerad som betald' })
onChange?.()
} finally {
setMarking(false)
}
}, [period, toast, onChange])
if (totalAmount <= 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Inbetalning till Skattekontot</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">Skatt</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalTax)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Arbetsgivaravgifter</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalAvgifter)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Totalt att betala</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalAmount)}</p>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
Mottagare: <span className="text-foreground">Skatteverket Bankgiro 5050-1055</span>
</p>
<p>
Förfallodag: <span className="text-foreground tabular-nums">{paymentDeadline}</span>
</p>
<p className="text-xs">
Betalfil använder ditt Skattekonto-OCR (org-nummer + Luhn-kontrollsiffra). Skatteverket
applicerar betalningen det belopp du deklarerat i AGI för perioden.
</p>
</div>
{paymentFileGeneratedAt && (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-emerald-600 dark:text-emerald-400" />
<div>
Betalfil senast genererad{' '}
<span className="text-foreground">
{new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')}
</span>
</div>
</div>
)}
{taxPaidAt && (
<div className="flex items-start gap-2 text-sm text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4 mt-0.5" />
<div>
Markerad som betald{' '}
<span className="font-medium">{new Date(taxPaidAt).toLocaleString('sv-SE')}</span>
</div>
</div>
)}
{!readOnly && (
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<a
href="https://www.skatteverket.se/foretag/skatterochavdrag/skattekonto.4.18e1b10334ebe8bc80004481.html"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
Skattekontot
</a>
</Button>
<Button onClick={handleDownload} disabled={downloading || marking}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner betalfil
</Button>
{!taxPaidAt && (
<Button
variant="outline"
onClick={handleMarkPaid}
disabled={downloading || marking}
>
{marking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
Markera som betald
</Button>
)}
</div>
)}
</CardContent>
</Card>
)
}
@@ -5,16 +5,20 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { CheckCircle2, ExternalLink, ShieldOff } from 'lucide-react'
import { CheckCircle2, ExternalLink, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
type Environment = 'test' | 'prod'
type Status =
| { connected: false }
| { connected: false; environment?: Environment; disabled?: boolean }
| {
connected: true
expired: boolean
canRefresh: boolean
scope: string
expiresAt: string
environment?: Environment
disabled?: boolean
}
const SCOPE_LABELS: Record<string, string> = {
@@ -92,14 +96,23 @@ export function SkatteverketConnectPanel() {
return (
<Card>
<CardHeader>
<CardTitle>Skatteverket</CardTitle>
<div className="flex items-center justify-between">
<CardTitle>Skatteverket</CardTitle>
<EnvironmentBadge environment={status?.environment} disabled={status?.disabled} />
</div>
</CardHeader>
<CardContent className="space-y-4">
{status?.disabled && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.</p>
</div>
)}
<p className="text-sm text-muted-foreground">
Anslut till Skatteverket med BankID för att skicka momsdeklaration,
arbetsgivardeklaration och hämta saldot skattekontot.
</p>
<Button onClick={startConnect}>
<Button onClick={startConnect} disabled={status?.disabled}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut med BankID
</Button>
@@ -129,6 +142,7 @@ export function SkatteverketConnectPanel() {
</Badge>
)}
</CardTitle>
<EnvironmentBadge environment={status.environment} disabled={status.disabled} />
</div>
</CardHeader>
<CardContent className="space-y-4">
@@ -178,9 +192,16 @@ export function SkatteverketConnectPanel() {
)}
</div>
{status.disabled && (
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
<p>Skatteverket-integrationen är tillfälligt avstängd. Inlämningar är inaktiverade.</p>
</div>
)}
<div className="flex gap-2 pt-2">
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && (
<Button onClick={startConnect}>
<Button onClick={startConnect} disabled={status.disabled}>
<ExternalLink className="mr-2 h-4 w-4" />
Anslut igen
</Button>
@@ -198,3 +219,30 @@ export function SkatteverketConnectPanel() {
</Card>
)
}
function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) {
if (disabled) {
return (
<Badge variant="destructive">
<ShieldAlert className="mr-1 h-3 w-3" />
Avstängd
</Badge>
)
}
if (environment === 'test') {
return (
<Badge variant="outline" className="border-amber-400 text-amber-700 dark:border-amber-600 dark:text-amber-400">
<FlaskConical className="mr-1 h-3 w-3" />
Testmiljö
</Badge>
)
}
if (environment === 'prod') {
return (
<Badge variant="outline" className="border-emerald-400 text-emerald-700 dark:border-emerald-600 dark:text-emerald-400">
Produktion
</Badge>
)
}
return null
}
+46 -8
View File
@@ -4,7 +4,7 @@ import { NextResponse } from 'next/server'
import { TimeoutError } from '@/lib/http/fetch-with-timeout'
import { buildAuthorizeUrl, exchangeCodeForTokens } from './lib/oauth'
import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
import { skvRequest, SkatteverketAuthError } from './lib/api-client'
import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import {
@@ -26,20 +26,53 @@ import type { VatPeriodType } from '@/types'
/**
* Skatteverket integration extension.
*
* Enables filing momsdeklaration (VAT declaration) directly to Skatteverket
* via their Momsdeklaration API 1.0. Users authenticate with BankID through
* the `per` (e-legitimation) OAuth2 flow.
* Enables filing momsdeklaration (VAT declaration) and arbetsgivardeklaration
* (AGI), plus Skattekonto saldo sync. Users authenticate with BankID via the
* `per` (e-legitimation) OAuth2 flow.
*
* Required environment variables:
* - SKATTEVERKET_OAUTH2_CLIENT_ID
* - SKATTEVERKET_OAUTH2_CLIENT_SECRET
* - SKATTEVERKET_APIGW_CLIENT_ID
* - SKATTEVERKET_APIGW_CLIENT_SECRET
* - SKATTEVERKET_TOKEN_ENCRYPTION_KEY
* - SKATTEVERKET_TOKEN_ENCRYPTION_KEY (openssl rand -base64 32; never reuse
* the test-env key in prod)
*
* Optional:
* - SKATTEVERKET_OAUTH_BASE_URL (defaults to test environment)
* - SKATTEVERKET_API_BASE_URL (defaults to test environment)
* - SKATTEVERKET_OAUTH_BASE_URL defaults to test
* - SKATTEVERKET_API_BASE_URL — momsdeklaration; defaults to test
* - SKATTEVERKET_AGD_INLAMNING_API_BASE_URL — AGI inlämning; defaults to test
* - SKATTEVERKET_AGD_PERIOD_API_BASE_URL — AGI period mgmt; defaults to test
* - SKATTEVERKET_SKATTEKONTO_API_BASE_URL — Skattekonto; defaults to test
* - SKATTEVERKET_DISABLED=true — emergency kill switch
*
* ─── Production cutover checklist ─────────────────────────────────────────
* Before flipping the env URLs to prod, the following has to land first
* (most are external blockers):
*
* 1. Register a prod OAuth2 client in Skatteverket's developer portal
* (separate from the test client). Requires a signed integrationsavtal.
* 2. Order APIGW prod credentials (separate ärende).
* 3. Register the prod redirect URI:
* `${NEXT_PUBLIC_APP_URL}/api/extensions/ext/skatteverket/callback`.
* 4. Request scopes: agd:skicka, agd:lasa, skattekonto:lasa, moms:skicka.
* 5. Pass Skatteverket's godkännandetest (they validate a few real AGI
* submissions in their test tenant before granting prod access).
* 6. Generate a fresh SKATTEVERKET_TOKEN_ENCRYPTION_KEY (rotate from test).
* 7. Set the prod base URLs:
* SKATTEVERKET_API_BASE_URL=https://api.skatteverket.se/momsdeklaration/v1
* SKATTEVERKET_AGD_INLAMNING_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
* SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
* SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
* SKATTEVERKET_OAUTH_BASE_URL=https://oauth2.skatteverket.se/oauth2
* 8. Verify Sentry alerts on /api/extensions/ext/skatteverket/* 5xx.
* 9. Verify 7-year retention of `agi_declarations.xml_content` +
* `kvittensnummer` (BFL 7 kap.).
* 10. Run a single AGI end-to-end against test on a real client before
* switching that client over.
*
* The /status endpoint reports which environment is active so the UI can
* surface a Testmiljö / Produktion badge.
*/
export const skatteverketExtension: Extension = {
id: 'skatteverket',
@@ -227,8 +260,11 @@ export const skatteverketExtension: Extension = {
}
const tokens = await getTokens(ctx.supabase, ctx.userId)
const environment = getSkatteverketEnvironment()
const disabled = (process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true'
if (!tokens) {
return NextResponse.json({ connected: false })
return NextResponse.json({ connected: false, environment, disabled })
}
const expired = tokens.expires_at < Date.now()
@@ -240,6 +276,8 @@ export const skatteverketExtension: Extension = {
canRefresh,
scope: tokens.scope,
expiresAt: new Date(tokens.expires_at).toISOString(),
environment,
disabled,
})
},
},
@@ -38,6 +38,31 @@ function getApiGwClientSecret(): string {
return secret
}
/**
* Kill switch: when SKATTEVERKET_DISABLED=true, all SKV API calls fail with a
* single, clear Swedish error. Useful during incidents (provider outage, key
* rotation, suspended access) to surface a graceful failure mode instead of
* letting requests hang or leak partial state.
*/
function isDisabled(): boolean {
const v = (process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase()
return v === 'true' || v === '1' || v === 'yes'
}
/**
* Detect whether we're pointed at SKV's test or prod environment.
* Used by the UI to surface an obvious badge so the user knows whether their
* filings will hit Skatteverket's production system.
*/
export function getSkatteverketEnvironment(): 'test' | 'prod' {
const baseUrl =
process.env.SKATTEVERKET_API_BASE_URL ||
process.env.SKATTEVERKET_AGD_INLAMNING_API_BASE_URL ||
process.env.SKATTEVERKET_SKATTEKONTO_API_BASE_URL ||
DEFAULT_API_BASE_URL
return baseUrl.includes('api.test.skatteverket.se') ? 'test' : 'prod'
}
/**
* Ensure rate limit compliance (4 req/sec).
* Delays if the last request was too recent.
@@ -146,6 +171,12 @@ export async function skvRequest(
body?: unknown,
options?: { baseUrl?: string; contentType?: string }
): Promise<Response> {
if (isDisabled()) {
throw new SkatteverketAuthError(
'Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.',
'ACCESS_DENIED'
)
}
const accessToken = await getValidToken(supabase, userId)
await enforceRateLimit()
@@ -16,7 +16,8 @@
"SKATTEVERKET_API_BASE_URL",
"SKATTEVERKET_AGD_INLAMNING_API_BASE_URL",
"SKATTEVERKET_AGD_PERIOD_API_BASE_URL",
"SKATTEVERKET_SKATTEKONTO_API_BASE_URL"
"SKATTEVERKET_SKATTEKONTO_API_BASE_URL",
"SKATTEVERKET_DISABLED"
],
"npmDependencies": [],
"definition": {
+2
View File
@@ -396,6 +396,8 @@ export const UpdateSettingsSchema = z.object({
invoice_credit_terms_text: z.string().nullable().optional(),
// AI agent flow
ai_flow_enabled: z.boolean().optional(),
// Salary payment file
preferred_payment_format: z.enum(['bg_lb', 'pain001']).optional(),
}).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
@@ -0,0 +1,152 @@
import { describe, it, expect } from 'vitest'
import { generateBgLb } from '../bg-lb-generator'
import type { BgLbCompanyData, BgLbEmployee, BgLbOptions } from '../bg-lb-generator'
const company: BgLbCompanyData = {
name: 'Acme AB',
senderBankgiro: '123-4567',
}
const baseOptions: BgLbOptions = {
paymentDate: '2026-04-25',
periodLabel: '2026-04',
}
describe('generateBgLb', () => {
it('produces a file with opening, payment, and closing records', () => {
const employees: BgLbEmployee[] = [
{
name: 'Anna Andersson',
clearingNumber: '6000',
bankAccountNumber: '1234567',
netSalary: 25000,
},
{
name: 'Bo Bergström',
clearingNumber: '6000',
bankAccountNumber: '7654321',
netSalary: 30500.5,
},
]
const result = generateBgLb(company, employees, baseOptions)
const lines = result.content.split('\r\n').filter((l) => l.length > 0)
expect(lines).toHaveLength(4) // 1 opening + 2 payments + 1 closing
expect(lines[0]).toMatch(/^11/)
expect(lines[1]).toMatch(/^54/)
expect(lines[2]).toMatch(/^54/)
expect(lines[3]).toMatch(/^29/)
expect(result.recordCount).toBe(2)
expect(result.totalAmount).toBe(55500.5)
})
it('writes records exactly 80 characters wide', () => {
const employees: BgLbEmployee[] = [
{ name: 'Cecilia Carlsson', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 28000 },
]
const result = generateBgLb(company, employees, baseOptions)
const lines = result.content.split('\r\n').filter((l) => l.length > 0)
for (const line of lines) {
expect(line.length).toBe(80)
}
})
it('encodes amounts in öre (no decimal)', () => {
const employees: BgLbEmployee[] = [
{ name: 'Test', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 12345.67 },
]
const result = generateBgLb(company, employees, baseOptions)
const paymentLine = result.content.split('\r\n')[1]
// Amount field for TK 54 is positions 42-53 (12 chars, zero-padded)
const amountField = paymentLine.slice(41, 53)
expect(amountField).toBe('000001234567') // 12345.67 SEK = 1234567 öre
})
it('handles 5-digit Swedbank clearings by shifting the 5th digit into the account', () => {
const employees: BgLbEmployee[] = [
{ name: 'Swedbank', clearingNumber: '83271', bankAccountNumber: '123456789', netSalary: 1000 },
]
const result = generateBgLb(company, employees, baseOptions)
const paymentLine = result.content.split('\r\n')[1]
// Pos 3-6 = clearing (4 digits)
expect(paymentLine.slice(2, 6)).toBe('8327')
// Pos 7-16 = account (10 digits): 5th clearing digit "1" + account "123456789"
expect(paymentLine.slice(6, 16)).toBe('1123456789')
})
it('rejects invalid bankgiro number', () => {
expect(() =>
generateBgLb({ ...company, senderBankgiro: 'invalid' }, [], baseOptions)
).toThrow(/Ogiltigt bankgironummer/)
})
it('rejects 5-digit clearing not starting with 8', () => {
const employees: BgLbEmployee[] = [
{ name: 'X', clearingNumber: '90001', bankAccountNumber: '1234567', netSalary: 100 },
]
expect(() => generateBgLb(company, employees, baseOptions)).toThrow(/clearing/)
})
it('skips employees with zero or negative net salary', () => {
const employees: BgLbEmployee[] = [
{ name: 'A', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 25000 },
{ name: 'B', clearingNumber: '6000', bankAccountNumber: '7654321', netSalary: 0 },
{ name: 'C', clearingNumber: '6000', bankAccountNumber: '1111111', netSalary: -500 },
]
const result = generateBgLb(company, employees, baseOptions)
expect(result.recordCount).toBe(1)
expect(result.totalAmount).toBe(25000)
})
it('opening record contains LEVERANTÖRSBETALNINGAR and LEVE markers', () => {
const result = generateBgLb(company, [
{ name: 'X', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
], baseOptions)
const opening = result.content.split('\r\n')[0]
expect(opening.slice(18, 40)).toBe('LEVERANTÖRSBETALNINGAR')
expect(opening.slice(40, 44)).toBe('LEVE')
})
it('closing record sums total amount in öre across all payments', () => {
const employees: BgLbEmployee[] = [
{ name: 'A', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
{ name: 'B', clearingNumber: '6000', bankAccountNumber: '7654321', netSalary: 250.5 },
]
const result = generateBgLb(company, employees, baseOptions)
const lines = result.content.split('\r\n').filter((l) => l)
const closing = lines[lines.length - 1]
expect(closing.startsWith('29')).toBe(true)
// Pos 21-32 = total amount in öre (12 digits)
expect(closing.slice(20, 32)).toBe('000000035050') // 350.50 SEK = 35050 öre
})
it('encodes payment date as YYMMDD on opening and payment records', () => {
const result = generateBgLb(company, [
{ name: 'X', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
], { ...baseOptions, paymentDate: '2026-12-31' })
const lines = result.content.split('\r\n')
// Opening: pos 45-50 = payment date YYMMDD
expect(lines[0].slice(44, 50)).toBe('261231')
// Payment: pos 54-59 = payment date YYMMDD
expect(lines[1].slice(53, 59)).toBe('261231')
})
it('truncates over-long employee names safely', () => {
const longName = 'A'.repeat(50)
const result = generateBgLb(company, [
{ name: longName, clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
], baseOptions)
const paymentLine = result.content.split('\r\n')[1]
// Pos 17-41 = receiver name (25 chars)
expect(paymentLine.slice(16, 41)).toBe('A'.repeat(25))
})
it('preserves Swedish characters å ä ö in receiver name', () => {
const result = generateBgLb(company, [
{ name: 'Åke Östberg', clearingNumber: '6000', bankAccountNumber: '1234567', netSalary: 100 },
], baseOptions)
const paymentLine = result.content.split('\r\n')[1]
expect(paymentLine.slice(16, 41).trimEnd()).toBe('Åke Östberg')
})
})
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest'
import { generateBankgiroPaymentBgLb } from '../bg-lb-generator'
const company = {
name: 'Acme AB',
senderBankgiro: '123-4567',
}
describe('generateBankgiroPaymentBgLb', () => {
it('produces opening + TK14 + closing records', () => {
const result = generateBankgiroPaymentBgLb(
company,
{
receiverBankgiro: '5050-1055',
ocr: '55601234566',
amount: 12345.67,
receiverName: 'Skatteverket',
},
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
)
const lines = result.content.split('\r\n').filter((l) => l)
expect(lines).toHaveLength(3)
expect(lines[0].slice(0, 2)).toBe('11')
expect(lines[1].slice(0, 2)).toBe('14')
expect(lines[2].slice(0, 2)).toBe('29')
expect(result.recordCount).toBe(1)
expect(result.totalAmount).toBe(12345.67)
})
it('all records are exactly 80 characters', () => {
const result = generateBankgiroPaymentBgLb(
company,
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 1000 },
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
)
for (const line of result.content.split('\r\n').filter((l) => l)) {
expect(line.length).toBe(80)
}
})
it('encodes receiver bankgiro right-justified zero-padded', () => {
const result = generateBankgiroPaymentBgLb(
company,
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 1000 },
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
)
const paymentLine = result.content.split('\r\n')[1]
// Pos 3-12 = receiver BG (10 digits)
expect(paymentLine.slice(2, 12)).toBe('0050501055')
})
it('encodes OCR right-justified zero-padded in pos 13-37', () => {
const result = generateBankgiroPaymentBgLb(
company,
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 1000 },
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
)
const paymentLine = result.content.split('\r\n')[1]
// OCR "55601234566" (11 digits) padded to 25 chars right-justified = 14 zeros + OCR
expect(paymentLine.slice(12, 37)).toBe('00000000000000055601234566'.slice(-25))
expect(paymentLine.slice(12, 37).length).toBe(25)
expect(paymentLine.slice(12, 37).endsWith('55601234566')).toBe(true)
})
it('encodes amount in öre (no decimal)', () => {
const result = generateBankgiroPaymentBgLb(
company,
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 12345.67 },
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
)
const paymentLine = result.content.split('\r\n')[1]
// Pos 38-49 = amount in öre (12 digits)
expect(paymentLine.slice(37, 49)).toBe('000001234567')
})
it('rejects invalid receiver bankgiro', () => {
expect(() =>
generateBankgiroPaymentBgLb(
company,
{ receiverBankgiro: 'invalid', ocr: '55601234566', amount: 100 },
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
)
).toThrow(/mottagar-bankgiro/)
})
it('rejects invalid OCR (non-numeric)', () => {
expect(() =>
generateBankgiroPaymentBgLb(
company,
{ receiverBankgiro: '5050-1055', ocr: 'abcdef', amount: 100 },
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
)
).toThrow(/OCR/)
})
it('closing record sums total amount in öre', () => {
const result = generateBankgiroPaymentBgLb(
company,
{ receiverBankgiro: '5050-1055', ocr: '55601234566', amount: 100.5 },
{ paymentDate: '2026-05-12', periodLabel: '2026-04' }
)
const closing = result.content.split('\r\n').filter((l) => l)[2]
expect(closing.slice(20, 32)).toBe('000000010050') // 100.50 SEK = 10050 öre
})
})
+335
View File
@@ -0,0 +1,335 @@
/**
* Bankgirot LB-fil (Leverantörsbetalningar) generator for salary batch payments.
*
* Used by Swedish banks (Swedbank, SEB, Handelsbanken, Nordea) for B2B and
* salary payments via the corporate portal. Each company has a sender BG
* registered with Bankgirot; the file is uploaded and Bankgirot routes the
* funds to the receiver bank accounts.
*
* Format reference: Bankgirot — "Leverantörsbetalningar Användarmanual",
* Posttyp specification (TK 11, 14, 54, 29).
* https://www.bankgirot.se/tjanster/leverantorsbetalningar
*
* Encoding: ISO 8859-1 (Latin-1).
* Line endings: CRLF.
* Record length: exactly 80 characters per line.
*
* Per BFL: The generated file is räkenskapsinformation (underlag) linked to
* the salary journal entry. Subject to 7-year retention.
*/
export interface BgLbCompanyData {
name: string
/** Sender bankgiro number, with or without dash. e.g. "123-4567" or "1234567" */
senderBankgiro: string
}
export interface BgLbEmployee {
name: string
/** 45 digit clearing number. */
clearingNumber: string
/** Up to 10-digit bank account number. */
bankAccountNumber: string
/** Net salary in SEK (öre handled internally). */
netSalary: number
}
export interface BgLbOptions {
/** YYYY-MM-DD execution date. Bankgirot encodes as YYMMDD. */
paymentDate: string
/** Period label shown on payslip-side info, e.g. "2026-04". */
periodLabel: string
}
export interface BgLbResult {
/** ISO 8859-1 ready text content with CRLF line endings. */
content: string
/** Suggested filename. */
filename: string
/** Total amount in SEK. */
totalAmount: number
/** Number of payment records (TK 54). */
recordCount: number
}
/**
* Generate a Bankgirot LB-fil for a salary batch.
*
* Layout:
* 1× Öppningspost (TK 11)
* N× Betalning till bankkonto (TK 54), one per employee
* 1× Slutpost (TK 29) with totals
*/
export function generateBgLb(
company: BgLbCompanyData,
employees: BgLbEmployee[],
options: BgLbOptions
): BgLbResult {
const senderBg = stripBgFormat(company.senderBankgiro)
if (!/^\d{7,8}$/.test(senderBg)) {
throw new Error(`Ogiltigt bankgironummer: ${company.senderBankgiro}`)
}
const paymentDateYyMmDd = toYyMmDd(options.paymentDate)
const todayYyMmDd = toYyMmDd(new Date().toISOString().slice(0, 10))
const positivePayments = employees.filter((e) => e.netSalary > 0)
const totalAmountOre = positivePayments.reduce(
(sum, e) => sum + Math.round(e.netSalary * 100),
0
)
const records: string[] = []
// ─── Posttyp 11 — Öppningspost ───
// Pos 1-2: "11"
// Pos 3-12: Sender bankgiro (10 digits, right-justified, zero-padded)
// Pos 13-18: Created date YYMMDD
// Pos 19-40: "LEVERANTÖRSBETALNINGAR" (22 chars)
// Pos 41-44: "LEVE"
// Pos 45-50: Payment date YYMMDD
// Pos 51-52: Currency "SE" (Bankgirot uses "SE" for SEK in file headers)
// Pos 53-80: Spaces (filler)
records.push(
pad('11', 2) +
padNumber(senderBg, 10) +
todayYyMmDd +
padText('LEVERANTÖRSBETALNINGAR', 22) +
'LEVE' +
paymentDateYyMmDd +
'SE' +
pad('', 28)
)
// ─── Posttyp 54 — Betalning till bankkonto (one per employee) ───
// Pos 1-2: "54"
// Pos 3-6: Clearing number (4 digits, right-justified, zero-padded)
// 5-digit Swedbank clearings: digit 5 goes in pos 7 (we shift
// into the account field below per Bankgirot spec).
// Pos 7-16: Bank account number (10 digits, right-justified, zero-padded)
// Pos 17-41: Receiver name (25 chars, left-justified, space-padded)
// Pos 42-53: Amount in öre (12 digits, right-justified, zero-padded)
// Pos 54-59: Payment date YYMMDD
// Pos 60-80: Free reference / period label (21 chars)
for (const emp of positivePayments) {
const { clearing4, accountWithSwedbankPrefix } = encodeReceiverAccount(
emp.clearingNumber,
emp.bankAccountNumber
)
const amountOre = Math.round(emp.netSalary * 100)
const reference = `Lon ${options.periodLabel}`
records.push(
pad('54', 2) +
padNumber(clearing4, 4) +
padNumber(accountWithSwedbankPrefix, 10) +
padText(emp.name, 25) +
padNumber(String(amountOre), 12) +
paymentDateYyMmDd +
padText(reference, 21)
)
}
// ─── Posttyp 29 — Slutpost ───
// Pos 1-2: "29"
// Pos 3-12: Sender bankgiro
// Pos 13-20: Total record count incl. opening + closing (8 digits)
// Pos 21-32: Total amount in öre (12 digits)
// Pos 33-80: Spaces
const totalRecords = records.length + 1 // include the closing record itself
records.push(
pad('29', 2) +
padNumber(senderBg, 10) +
padNumber(String(totalRecords), 8) +
padNumber(String(totalAmountOre), 12) +
pad('', 48)
)
// Validate every record is exactly 80 characters.
for (let i = 0; i < records.length; i++) {
if (records[i].length !== 80) {
throw new Error(
`Bankgirot LB-fil: post ${i + 1} har fel längd ${records[i].length} (förväntat 80)`
)
}
}
const content = records.join('\r\n') + '\r\n'
return {
content,
filename: `bg_lb_lon_${options.periodLabel}.txt`,
totalAmount: totalAmountOre / 100,
recordCount: positivePayments.length,
}
}
/**
* Generate a Bankgirot LB-fil with a single TK 14 payment to a Bankgiro
* receiver. Used for paying skatt + arbetsgivaravgifter to Skatteverket
* (BG 5050-1055) with the company's Skattekontot OCR.
*
* Layout:
* 1× Öppningspost (TK 11)
* 1× Betalning till BG (TK 14) — receiver BG, OCR, amount
* 1× Slutpost (TK 29)
*/
export function generateBankgiroPaymentBgLb(
company: BgLbCompanyData,
payment: {
/** Receiver bankgiro (e.g. "5050-1055" for Skattekontot). */
receiverBankgiro: string
/** OCR reference (numeric, ≤ 25 digits, including Luhn check digit). */
ocr: string
/** Amount in SEK. */
amount: number
/** Optional receiver name shown in additional info (max 25 chars). */
receiverName?: string
},
options: BgLbOptions
): BgLbResult {
const senderBg = stripBgFormat(company.senderBankgiro)
const receiverBg = stripBgFormat(payment.receiverBankgiro)
if (!/^\d{7,8}$/.test(senderBg)) {
throw new Error(`Ogiltigt avsändar-bankgiro: ${company.senderBankgiro}`)
}
if (!/^\d{7,8}$/.test(receiverBg)) {
throw new Error(`Ogiltigt mottagar-bankgiro: ${payment.receiverBankgiro}`)
}
const ocrDigits = payment.ocr.replace(/\D/g, '')
if (ocrDigits.length === 0 || ocrDigits.length > 25) {
throw new Error(`Ogiltigt OCR-nummer: ${payment.ocr}`)
}
const paymentDateYyMmDd = toYyMmDd(options.paymentDate)
const todayYyMmDd = toYyMmDd(new Date().toISOString().slice(0, 10))
const amountOre = Math.round(payment.amount * 100)
const records: string[] = []
// ─── Posttyp 11 — Öppningspost ───
records.push(
pad('11', 2) +
padNumber(senderBg, 10) +
todayYyMmDd +
padText('LEVERANTÖRSBETALNINGAR', 22) +
'LEVE' +
paymentDateYyMmDd +
'SE' +
pad('', 28)
)
// ─── Posttyp 14 — Betalning till BG ───
// Pos 1-2: "14"
// Pos 3-12: Receiver bankgiro (10 digits)
// Pos 13-37: OCR / reference (25 chars, right-justified zero-padded for OCR)
// Pos 38-49: Amount in öre (12 digits)
// Pos 50-55: Payment date YYMMDD
// Pos 56-80: Receiver name / free info (25 chars)
records.push(
pad('14', 2) +
padNumber(receiverBg, 10) +
padNumber(ocrDigits, 25) +
padNumber(String(amountOre), 12) +
paymentDateYyMmDd +
padText(payment.receiverName ?? options.periodLabel, 25)
)
// ─── Posttyp 29 — Slutpost ───
const totalRecords = records.length + 1
records.push(
pad('29', 2) +
padNumber(senderBg, 10) +
padNumber(String(totalRecords), 8) +
padNumber(String(amountOre), 12) +
pad('', 48)
)
for (let i = 0; i < records.length; i++) {
if (records[i].length !== 80) {
throw new Error(
`Bankgirot LB-fil: post ${i + 1} har fel längd ${records[i].length} (förväntat 80)`
)
}
}
return {
content: records.join('\r\n') + '\r\n',
filename: `bg_lb_skatt_${options.periodLabel}.txt`,
totalAmount: payment.amount,
recordCount: 1,
}
}
// ============================================================
// Helpers
// ============================================================
/** Strip dashes/spaces from a Bankgiro number. */
function stripBgFormat(bg: string): string {
return bg.replace(/[-\s]/g, '')
}
/** Convert YYYY-MM-DD to YYMMDD. */
function toYyMmDd(isoDate: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate)
if (!m) throw new Error(`Ogiltigt datum: ${isoDate}`)
return m[1].slice(2) + m[2] + m[3]
}
/** Right-justify with zero-padding (for numeric fields). */
function padNumber(value: string, length: number): string {
const digits = value.replace(/\D/g, '')
if (digits.length > length) {
throw new Error(`Numeriskt fält för långt (${digits.length} > ${length}): ${value}`)
}
return digits.padStart(length, '0')
}
/** Left-justify with space-padding, then truncate to length (for text fields).
* Bankgirot uses ISO 8859-1; keep å/ä/ö but strip anything outside that range. */
function padText(value: string, length: number): string {
const sanitized = value
.replace(/[\r\n\t]/g, ' ')
// Strip characters outside ISO 8859-1 printable range to avoid encoding errors.
.replace(/[^\x20-\x7E\xA0-\xFF]/g, '?')
.slice(0, length)
return sanitized.padEnd(length, ' ')
}
/** Plain padding to length (for fixed literals like "11"). */
function pad(value: string, length: number): string {
if (value.length > length) return value.slice(0, length)
return value.padEnd(length, ' ')
}
/**
* Bankgirot encodes 4-digit clearings directly. Swedbank uses 5-digit clearings
* (8xxx-x); the 5th digit is moved to the leading position of the account field.
*
* For 5-digit clearings starting with "8": digits 1-4 go to the clearing field,
* the 5th digit becomes the first digit of the 10-position account field.
*/
function encodeReceiverAccount(
clearingInput: string,
accountInput: string
): { clearing4: string; accountWithSwedbankPrefix: string } {
const clearing = clearingInput.replace(/\D/g, '')
const account = accountInput.replace(/\D/g, '')
if (clearing.length === 4) {
return { clearing4: clearing, accountWithSwedbankPrefix: account }
}
if (clearing.length === 5 && clearing.startsWith('8')) {
// Swedbank: keep 4 leading digits in clearing field, prepend 5th digit to account.
return {
clearing4: clearing.slice(0, 4),
accountWithSwedbankPrefix: clearing.slice(4) + account,
}
}
throw new Error(
`Ogiltigt clearingnummer: ${clearingInput} (förväntat 4 siffror, eller 5 siffror som börjar med 8 för Swedbank)`
)
}
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { generateSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '../skattekonto-ocr'
import { luhnValidate } from '@/lib/bankgiro/luhn'
describe('generateSkattekontoOcr', () => {
it('produces 11-digit OCR with valid Luhn check digit for AB org-number', () => {
const ocr = generateSkattekontoOcr('556012-3456')
expect(ocr).toHaveLength(11)
expect(ocr.startsWith('5560123456')).toBe(true)
expect(luhnValidate(ocr)).toBe(true)
})
it('accepts org-number without dash', () => {
expect(generateSkattekontoOcr('5560123456')).toBe(generateSkattekontoOcr('556012-3456'))
})
it('accepts 12-digit personnummer by stripping century prefix', () => {
const ocr12 = generateSkattekontoOcr('198802251234')
const ocr10 = generateSkattekontoOcr('880225-1234')
expect(ocr12).toBe(ocr10)
})
it('rejects malformed numbers', () => {
expect(() => generateSkattekontoOcr('123')).toThrow(/Ogiltigt/)
expect(() => generateSkattekontoOcr('')).toThrow(/Ogiltigt/)
expect(() => generateSkattekontoOcr('abcdefghij')).toThrow(/Ogiltigt/)
})
it('exports correct Bankgiro for Skattekontot', () => {
expect(SKATTEKONTO_BANKGIRO).toBe('5050-1055')
})
})
+53
View File
@@ -0,0 +1,53 @@
/**
* OCR-nummer for Skatteverket Skattekonto payments.
*
* Companies pay tax (skatt + arbetsgivaravgifter + F-skatt + slutlig skatt etc.)
* to Bankgiro 5050-1055 with an OCR reference. The reference identifies which
* Skattekonto receives the credit; Skatteverket applies it to the most recent
* declared liability.
*
* Format (per Skatteverket "OCR-nummer för inbetalning till skattekontot"):
* - 10-digit organisationsnummer (AB) or 10-digit personnummer (EF)
* stripped of dashes/spaces
* - Followed by a single Luhn check digit
* - Total: 11 digits
*
* Examples:
* 556012-3456 → "5560123456" + check digit "6" = "55601234566"
* 880225-1234 → "8802251234" + check digit → 11 digits
*
* Reference: https://www.skatteverket.se/foretag/skatterochavdrag/skattekonto/betalainochavskattekonto/sabetalardupaskattekontot.4.18e1b10334ebe8bc80004499.html
*/
import { luhnCheckDigit } from '@/lib/bankgiro/luhn'
/** Bankgiro number for all payments to Skattekontot. */
export const SKATTEKONTO_BANKGIRO = '5050-1055'
/**
* Generate the standard Skattekontot OCR reference for a company.
*
* Accepts org_number/personnummer in any common Swedish format
* ("556012-3456", "5560123456", "19880225-1234", "198802251234").
*
* For 12-digit personnummer (with century prefix), the leading century digits
* are stripped — Skatteverket's Skattekonto-OCR uses the 10-digit form.
*/
export function generateSkattekontoOcr(orgOrPersonnummer: string): string {
const digits = orgOrPersonnummer.replace(/\D/g, '')
let base: string
if (digits.length === 10) {
base = digits
} else if (digits.length === 12) {
// Strip century prefix (1900s = "19", 2000s = "20")
base = digits.slice(2)
} else {
throw new Error(
`Ogiltigt org/personnummer för Skattekonto-OCR: "${orgOrPersonnummer}" (förväntat 10 eller 12 siffror)`
)
}
const checkDigit = luhnCheckDigit(base)
return base + checkDigit.toString()
}
@@ -0,0 +1,52 @@
-- Salary payment files: Bankgirot LB-fil support alongside existing pain.001 (SEPA).
--
-- Most Swedish SMBs upload Bankgirot LB-files to Swedbank/SEB/Handelsbanken/Nordea
-- via their corporate portal — pain.001 is also supported but less common.
--
-- This migration:
-- 1. Adds `preferred_payment_format` to `company_settings` so the UI can
-- pre-select the right format per company.
-- 2. Tracks which format was generated for each salary run, plus the
-- generation timestamp (used by deadline reminders).
-- ------------------------------------------------------------------
-- company_settings.preferred_payment_format
-- ------------------------------------------------------------------
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS preferred_payment_format text NOT NULL DEFAULT 'bg_lb';
-- Re-apply default + NOT NULL in case ADD COLUMN IF NOT EXISTS skipped them
-- (column existed out-of-band).
ALTER TABLE public.company_settings
ALTER COLUMN preferred_payment_format SET DEFAULT 'bg_lb';
UPDATE public.company_settings
SET preferred_payment_format = 'bg_lb'
WHERE preferred_payment_format IS NULL;
ALTER TABLE public.company_settings
ALTER COLUMN preferred_payment_format SET NOT NULL;
ALTER TABLE public.company_settings
DROP CONSTRAINT IF EXISTS company_settings_preferred_payment_format_check;
ALTER TABLE public.company_settings
ADD CONSTRAINT company_settings_preferred_payment_format_check
CHECK (preferred_payment_format IN ('bg_lb', 'pain001'));
-- ------------------------------------------------------------------
-- salary_runs payment file tracking
-- ------------------------------------------------------------------
ALTER TABLE public.salary_runs
ADD COLUMN IF NOT EXISTS payment_file_format text,
ADD COLUMN IF NOT EXISTS payment_file_generated_at timestamptz;
ALTER TABLE public.salary_runs
DROP CONSTRAINT IF EXISTS salary_runs_payment_file_format_check;
ALTER TABLE public.salary_runs
ADD CONSTRAINT salary_runs_payment_file_format_check
CHECK (payment_file_format IS NULL OR payment_file_format IN ('bg_lb', 'pain001'));
-- Reload PostgREST schema cache.
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,26 @@
-- AGI tax payment tracking.
--
-- An AGI declaration represents both the filing obligation and the resulting
-- tax liability for the period (skatt + avgifter). We track when the payment
-- file (Bankgirot LB to BG 5050-1055) was generated and when the payment
-- actually cleared the bank.
--
-- This avoids a separate `tax_payment_runs` table — the AGI declaration is
-- already keyed on (company_id, period_year, period_month) and carries the
-- exact amounts owed.
ALTER TABLE public.agi_declarations
ADD COLUMN IF NOT EXISTS tax_payment_file_generated_at timestamptz,
ADD COLUMN IF NOT EXISTS tax_payment_file_format text,
ADD COLUMN IF NOT EXISTS tax_paid_at timestamptz,
ADD COLUMN IF NOT EXISTS tax_payment_journal_entry_id uuid REFERENCES public.journal_entries(id);
ALTER TABLE public.agi_declarations
DROP CONSTRAINT IF EXISTS agi_declarations_tax_payment_format_check;
ALTER TABLE public.agi_declarations
ADD CONSTRAINT agi_declarations_tax_payment_format_check
CHECK (tax_payment_file_format IS NULL OR tax_payment_file_format IN ('bg_lb'));
-- Reload PostgREST schema cache so the columns become visible to the API.
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,72 @@
-- Salary payslip delivery log.
--
-- Per BFL 7 kap.: delivery confirmation of lönespecifikationer must be
-- retained as part of the audit trail. The send route currently calls Resend
-- and returns the count, but doesn't persist a per-employee record — so we
-- can't answer "did Anna receive her March payslip?" months later.
--
-- This table stores one row per (salary_run, employee, attempt) with the
-- Resend message_id (if known) so a follow-up webhook can update status to
-- delivered/bounced/complained.
CREATE TABLE IF NOT EXISTS public.salary_payslip_deliveries (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
salary_run_id uuid NOT NULL REFERENCES public.salary_runs(id) ON DELETE CASCADE,
employee_id uuid NOT NULL REFERENCES public.employees(id) ON DELETE RESTRICT,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
email_address text NOT NULL,
status text NOT NULL DEFAULT 'sent'
CHECK (status IN ('sent', 'delivered', 'bounced', 'complained', 'failed', 'skipped')),
-- Resend (or other provider) tracking
provider text NOT NULL DEFAULT 'resend',
provider_message_id text,
provider_event jsonb,
error_message text,
sent_at timestamptz NOT NULL DEFAULT now(),
delivered_at timestamptz,
bounced_at timestamptz,
complained_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.salary_payslip_deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY "salary_payslip_deliveries_select"
ON public.salary_payslip_deliveries
FOR SELECT
USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "salary_payslip_deliveries_insert"
ON public.salary_payslip_deliveries
FOR INSERT
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "salary_payslip_deliveries_update"
ON public.salary_payslip_deliveries
FOR UPDATE
USING (company_id IN (SELECT public.user_company_ids()));
-- No DELETE policy — BFL 7 kap. requires retention of delivery records.
CREATE INDEX idx_payslip_deliveries_run
ON public.salary_payslip_deliveries (salary_run_id);
CREATE INDEX idx_payslip_deliveries_company
ON public.salary_payslip_deliveries (company_id);
CREATE INDEX idx_payslip_deliveries_provider_msg
ON public.salary_payslip_deliveries (provider_message_id)
WHERE provider_message_id IS NOT NULL;
CREATE TRIGGER salary_payslip_deliveries_updated_at
BEFORE UPDATE ON public.salary_payslip_deliveries
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
+2
View File
@@ -2555,6 +2555,8 @@ export interface SalaryRun {
vacation_entry_id: string | null
agi_generated_at: string | null
agi_submitted_at: string | null
payment_file_format: 'bg_lb' | 'pain001' | null
payment_file_generated_at: string | null
calculation_params: Record<string, unknown> | null
approved_by: string | null
approved_at: string | null