diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 7fb55c0f..637f58fd 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -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(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: )} + {/* Payment file — available once the run is approved */} + {['approved', 'paid', 'booked'].includes(run.status) && ( + + )} + + {/* Tax payment (skatt + arbetsgivaravgifter) — once AGI has been generated */} + {run.status === 'booked' && run.agi_generated_at && ( + + )} + {/* AGI (Arbetsgivardeklaration) — available once the run is booked */} {run.status === 'booked' && (
diff --git a/app/api/salary/runs/[id]/calculate/route.ts b/app/api/salary/runs/[id]/calculate/route.ts index 1f90398d..ced8e6d5 100644 --- a/app/api/salary/runs/[id]/calculate/route.ts +++ b/app/api/salary/runs/[id]/calculate/route.ts @@ -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 }, diff --git a/app/api/salary/runs/[id]/payment/bg-lb/route.ts b/app/api/salary/runs/[id]/payment/bg-lb/route.ts new file mode 100644 index 00000000..73970b06 --- /dev/null +++ b/app/api/salary/runs/[id]/payment/bg-lb/route.ts @@ -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}"`, + }, + }) +} diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts index 8fa4fa73..b5d53b40 100644 --- a/app/api/salary/runs/[id]/payment/pain001/route.ts +++ b/app/api/salary/runs/[id]/payment/pain001/route.ts @@ -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', diff --git a/app/api/salary/runs/[id]/payslips/send/route.ts b/app/api/salary/runs/[id]/payslips/send/route.ts index 468e08e2..4d9d64c5 100644 --- a/app/api/salary/runs/[id]/payslips/send/route.ts +++ b/app/api/salary/runs/[id]/payslips/send/route.ts @@ -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: `

Hej ${emp.first_name},

@@ -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), + }) } } diff --git a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts new file mode 100644 index 00000000..4662f1cf --- /dev/null +++ b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts @@ -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 } }) +} diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts new file mode 100644 index 00000000..171797c2 --- /dev/null +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts @@ -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` +} diff --git a/app/api/skatteverket/tax-payments/[period]/route.ts b/app/api/skatteverket/tax-payments/[period]/route.ts new file mode 100644 index 00000000..2e911d92 --- /dev/null +++ b/app/api/skatteverket/tax-payments/[period]/route.ts @@ -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 }) +} diff --git a/components/salary/PaymentFilePanel.tsx b/components/salary/PaymentFilePanel.tsx new file mode 100644 index 00000000..1d5fdbde --- /dev/null +++ b/components/salary/PaymentFilePanel.tsx @@ -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 = { + bg_lb: 'Bankgirot LB-fil', + pain001: 'SEPA pain.001 (XML)', +} + +const FORMAT_DESCRIPTION: Record = { + 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(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 ( + + + Betalfil till bank + + + {paymentFileFormat && paymentFileGeneratedAt && ( +
+ +
+ Senast genererad:{' '} + + {FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat} + {' '} + ({new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')}) +
+
+ )} + + {!readOnly && ( + <> +
+ + +

{FORMAT_DESCRIPTION[format]}

+
+ +
+ +
+ + )} +
+
+ ) +} diff --git a/components/salary/TaxPaymentPanel.tsx b/components/salary/TaxPaymentPanel.tsx new file mode 100644 index 00000000..612aaa86 --- /dev/null +++ b/components/salary/TaxPaymentPanel.tsx @@ -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('') + + 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 ( + + + Inbetalning till Skattekontot + + +
+
+

Skatt

+

{formatCurrency(totalTax)}

+
+
+

Arbetsgivaravgifter

+

{formatCurrency(totalAvgifter)}

+
+
+

Totalt att betala

+

{formatCurrency(totalAmount)}

+
+
+ +
+

+ Mottagare: Skatteverket Bankgiro 5050-1055 +

+

+ Förfallodag: {paymentDeadline} +

+

+ Betalfil använder ditt Skattekonto-OCR (org-nummer + Luhn-kontrollsiffra). Skatteverket + applicerar betalningen på det belopp du deklarerat i AGI för perioden. +

+
+ + {paymentFileGeneratedAt && ( +
+ +
+ Betalfil senast genererad{' '} + + {new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')} + +
+
+ )} + + {taxPaidAt && ( +
+ +
+ Markerad som betald{' '} + {new Date(taxPaidAt).toLocaleString('sv-SE')} +
+
+ )} + + {!readOnly && ( +
+ + + {!taxPaidAt && ( + + )} +
+ )} +
+
+ ) +} diff --git a/components/settings/SkatteverketConnectPanel.tsx b/components/settings/SkatteverketConnectPanel.tsx index fbee1a49..11a08438 100644 --- a/components/settings/SkatteverketConnectPanel.tsx +++ b/components/settings/SkatteverketConnectPanel.tsx @@ -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 = { @@ -92,14 +96,23 @@ export function SkatteverketConnectPanel() { return ( - Skatteverket +
+ Skatteverket + +
+ {status?.disabled && ( +
+ +

Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.

+
+ )}

Anslut till Skatteverket med BankID för att skicka momsdeklaration, arbetsgivardeklaration och hämta saldot på skattekontot.

- @@ -129,6 +142,7 @@ export function SkatteverketConnectPanel() { )} +
@@ -178,9 +192,16 @@ export function SkatteverketConnectPanel() { )} + {status.disabled && ( +
+ +

Skatteverket-integrationen är tillfälligt avstängd. Inlämningar är inaktiverade.

+
+ )} +
{(status.expired || !status.canRefresh || !scopes.includes('skattekonto') || !scopes.includes('agd')) && ( - @@ -198,3 +219,30 @@ export function SkatteverketConnectPanel() { ) } + +function EnvironmentBadge({ environment, disabled }: { environment?: Environment; disabled?: boolean }) { + if (disabled) { + return ( + + + Avstängd + + ) + } + if (environment === 'test') { + return ( + + + Testmiljö + + ) + } + if (environment === 'prod') { + return ( + + Produktion + + ) + } + return null +} diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index c8ef1e72..85563846 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -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, }) }, }, diff --git a/extensions/general/skatteverket/lib/api-client.ts b/extensions/general/skatteverket/lib/api-client.ts index 286a5e95..cb5c73c0 100644 --- a/extensions/general/skatteverket/lib/api-client.ts +++ b/extensions/general/skatteverket/lib/api-client.ts @@ -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 { + if (isDisabled()) { + throw new SkatteverketAuthError( + 'Skatteverket-integrationen är tillfälligt avstängd. Kontakta support.', + 'ACCESS_DENIED' + ) + } const accessToken = await getValidToken(supabase, userId) await enforceRateLimit() diff --git a/extensions/general/skatteverket/manifest.json b/extensions/general/skatteverket/manifest.json index 496a5444..28e23a1c 100644 --- a/extensions/general/skatteverket/manifest.json +++ b/extensions/general/skatteverket/manifest.json @@ -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": { diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index b86d8020..e58c6453 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 diff --git a/lib/salary/payment/__tests__/bg-lb-generator.test.ts b/lib/salary/payment/__tests__/bg-lb-generator.test.ts new file mode 100644 index 00000000..079b537d --- /dev/null +++ b/lib/salary/payment/__tests__/bg-lb-generator.test.ts @@ -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') + }) +}) diff --git a/lib/salary/payment/__tests__/bg-lb-tax-payment.test.ts b/lib/salary/payment/__tests__/bg-lb-tax-payment.test.ts new file mode 100644 index 00000000..b605b45d --- /dev/null +++ b/lib/salary/payment/__tests__/bg-lb-tax-payment.test.ts @@ -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 + }) +}) diff --git a/lib/salary/payment/bg-lb-generator.ts b/lib/salary/payment/bg-lb-generator.ts new file mode 100644 index 00000000..c4771afe --- /dev/null +++ b/lib/salary/payment/bg-lb-generator.ts @@ -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)` + ) +} diff --git a/lib/skatteverket/__tests__/skattekonto-ocr.test.ts b/lib/skatteverket/__tests__/skattekonto-ocr.test.ts new file mode 100644 index 00000000..f8542657 --- /dev/null +++ b/lib/skatteverket/__tests__/skattekonto-ocr.test.ts @@ -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') + }) +}) diff --git a/lib/skatteverket/skattekonto-ocr.ts b/lib/skatteverket/skattekonto-ocr.ts new file mode 100644 index 00000000..927b79a7 --- /dev/null +++ b/lib/skatteverket/skattekonto-ocr.ts @@ -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() +} diff --git a/supabase/migrations/20260508120000_salary_payment_files.sql b/supabase/migrations/20260508120000_salary_payment_files.sql new file mode 100644 index 00000000..4c24135f --- /dev/null +++ b/supabase/migrations/20260508120000_salary_payment_files.sql @@ -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'; diff --git a/supabase/migrations/20260508120100_agi_tax_payment_tracking.sql b/supabase/migrations/20260508120100_agi_tax_payment_tracking.sql new file mode 100644 index 00000000..7e4998fe --- /dev/null +++ b/supabase/migrations/20260508120100_agi_tax_payment_tracking.sql @@ -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'; diff --git a/supabase/migrations/20260508120200_salary_payslip_deliveries.sql b/supabase/migrations/20260508120200_salary_payslip_deliveries.sql new file mode 100644 index 00000000..4c24645e --- /dev/null +++ b/supabase/migrations/20260508120200_salary_payslip_deliveries.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index d844e352..2384f0cb 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 | null approved_by: string | null approved_at: string | null