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:
@@ -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 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 på 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 på 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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
/** 4–5 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')
|
||||
})
|
||||
})
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user