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:
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user