* feat(salary): validate employee clearing/kontonummer at entry Bank details on the "Anställda" form had no structural validation, so a typo in clearing/kontonummer was saved silently and only surfaced at Bankgirot LB generation (or never, on the SEPA path). Adds a shared validator (lib/salary/payment/bank-account.ts) wired into the create dialog, edit page, CreateEmployeeSchema, and the PATCH route: 4-digit clearing or 5-digit Swedbank (8xxxx), 5-11 digit account, both-or-neither. Mirrors encodeReceiverAccount so entry-time validation matches what the payout layer can encode. Update validates only when a bank field actually changes, so legacy free-text data stays editable. Includes a conservative clearing to bank-name hint (null for unknown ranges). Per-bank mod10/mod11 checksum deferred to a soft-warning follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(chart-of-accounts): styled delete warnings and bulk select-all Replace the native window.confirm() on single-account delete with the styled DestructiveConfirmDialog, and add to the prune dialog a master 'select all unused accounts' checkbox plus an explicit confirmation step before bulk deletion. New sv/en strings for the confirm titles and actions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): encrypt personnummer on v1 employee create; tolerate legacy plaintext on read The v1 REST create route stored personnummer unencrypted, which then threw ERR_CRYPTO_INVALID_AUTH_TAG on every decrypt-on-read path and 500'd the employees roster. Encrypt on write in v1 create, decrypt on read in the v1 list/detail/patch responses, and make decryptPersonnummer pass a raw 12-digit value through with a warn so a legacy plaintext row can't take the roster down. Encrypt seeded personnummer. Add a gated, idempotent backfill for existing rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): save a manual entry as a reusable template Add a "Spara som mall" action to the manual journal-entry form next to the existing "Anvand mall" picker, so users can capture a booking pattern the moment they work it out. Opens the shared TemplateForm (create mode) pre-seeded from the current lines via deriveTemplateLinesFromBooking, and saves through the existing POST /api/settings/booking-templates. Rendered in both the mobile and desktop layouts and in create + edit modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pending): label all staged operation types The Granskning list rendered the raw snake_case operation_type (e.g. create_supplier_invoice_from_inbox) for any type missing from the label map, which hogs the meta row and wraps awkwardly on mobile. Add short sv/en labels for all operation types in OPERATION_RISK_TIERS, plus a humanized fallback for future ones, and simplify the label map to a plain operation_type -> i18n-key record (the icon/variant fields were dead). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reports): let users file moms without a Skatteverket connection The momsdeklaration was never gated on the Skatteverket connection (it renders from the bookkeeping), but the not-connected "Anslut med BankID" card read as a wall. Make manual filing a first-class path: - Add a "Lämna in din momsdeklaration" card under the report with a PDF download (SKV 4700 layout, hela kronor) and a skatteverket.se link. - Add a momsdeklaration PDF route + template; buildManualFilingRows() rounds each ruta to whole kronor and recomputes ruta 49 per the SKV 4700 formula so it ties out. The PDF is a read/record copy, not a submission file (moms has no upload channel). - Offer PDF alongside Excel in the report's export menu. - Reframe the not-connected SkatteverketPanel to "Skicka direkt till Skatteverket (valfritt)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): compact new-employee dialog and warn on bad account check digit Redesign NewEmployeeDialog into a compact layout: borderless sections split by hairline dividers (no per-section cards), a fixed header + scrolling body + solid footer (fixes content showing through the old sticky bar), and denser grids. EmployeeTaxCard gains a `flat` variant so the dialog can host it without card chrome; the edit page keeps the boxed version. Add non-blocking Swedish account check-digit validation (lib/bankgiro/account-number.ts): mod10 (reuses luhn) + mod11, with a clearing->method table from the Bankgirot "Bankernas kontonummeruppbyggnad" spec, cross-checked against jop-io/kontonummer.js and verified against a real account (Forex 9420/4172385). Surfaced as a soft warning in both employee forms; unrecognised clearings return 'unknown' so we never warn on a valid but unmapped account. Never blocks saving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(invoices): configurable send time + editing for recurring invoices Re-register the accidentally-removed recurring cron (now hourly) and add a per-schedule send hour (Europe/Stockholm, DST-aware). The cron never sends for a past date, and the enabling migration pauses every existing schedule on deploy so nothing auto-sends behind a user's back; users reactivate consciously (with a confirm) or click "Skapa faktura nu" to send this month on demand. Automatic sending now requires a customer email. Adds a full edit flow (row click opens the prefilled form, PATCH), fixing the row-click 404. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): configure självfaktura via the invoice API Add an optional is_self_billed flag (plus external_invoice_number, self_billing_agreement_ref, received_date) to the public invoice-create endpoint so callers can register a received self-billing invoice (mottagen självfaktura, ML 17 kap 15§) via the API. It was previously only reachable from the internal dashboard route, so it was missing from the API docs. Extract the booking into a shared service (lib/invoices/self-billed-sale.ts) and refactor the internal /api/invoices/self-billed route to a thin wrapper over it, so the dashboard and the API cannot drift. Books as a sale (Debit 1510 / Credit 30xx+26xx) with the counterparty's number; no own number is consumed. Fields are plain optionals (no schema refine) so UpdateInvoiceSchema.omit() keeps working; required-when-self-billed is enforced in the route. Documented in the endpoint registry. No migration (columns already exist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): allow a partial voucher-series-per-source-type map In Zod 4 an enum-keyed z.record is exhaustive (every source_type required), so saving a default_voucher_series_per_source_type map that omits a source type (e.g. the newly added result_appropriation) failed with "expected string, received undefined". Use partialRecord so the map can be sparse; the engine falls back to series 'A' for any unmapped key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(salary): resolve employer name via getCompanyDisplayName Payslip PDFs, the payslip email, AGI, KU10, and the BG/LB + SEPA payment files now resolve the employer name through getCompanyDisplayName (company_settings.company_name, falling back to companies.name), matching how invoices already display it. Read-side coalesce, so no migration or backfill: companies.name is write-once at onboarding and not authoritative for these surfaces. The sidebar company switcher uses the same coalesce for the non-active companies in the list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(kontoplan): index-only account usage counts + lighter reference load Add a covering index on journal_entry_lines (journal_entry_id, account_number) so get_account_usage_counts becomes an index-only scan (prod worst case ~440ms). Slim /api/bookkeeping/accounts/reference to return only the company's activation rows and merge against the client-bundled BAS_REFERENCE instead of re-sending the full ~1,300-account catalog every load, and defer the BAS catalog + usage counts off the first-paint critical path in ChartOfAccountsManager. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(salary): add bank-account checksum warning string sv/en strings for the employee bank-account (clearing/kontonummer) soft checksum warning shown by the create/edit forms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update decision log Append the 2026-07-06/07 decision entries (salary employer-name coalesce, sidebar switcher, employees API personnummer fix, kontoplan load optimization, momsdeklaration manual filing, recurring invoices resend + reactivation + editing, "spara som mall", voucher-series partial map, and självfaktura via the invoice API). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address compliance-review findings on recurring invoices + moms filing - recurring cron: close the double-send window with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly runs can't both spawn from the same stale batch row - recurring edit dialog: force auto_send=false whenever the effective customer has no email, so a disabled-but-checked box can't PATCH auto_send=true after the async customer load - momsdeklaration manual-filing: truncate rutor to whole kronor (öretal faller bort per SFL 22 kap 1 §) instead of round-to-nearest, matching the SRU path Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
697 lines
27 KiB
TypeScript
697 lines
27 KiB
TypeScript
/**
|
|
* Shared AGI XML generation + persistence orchestration.
|
|
*
|
|
* Both the internal dashboard route (`GET /api/salary/runs/{id}/agi/xml`)
|
|
* and the v1 public route (`POST /api/v1/companies/{companyId}/salary-runs/{id}/generate-agi`)
|
|
* call this helper. It loads the salary run + employees + per-day absence
|
|
* records, builds the Skatteverket AGI XML, upserts the agi_declarations
|
|
* row (correction-aware), updates `salary_runs.agi_generated_at`, emits
|
|
* `agi.generated`, and auto-completes the `arbetsgivardeklaration` deadline
|
|
* for the period.
|
|
*
|
|
* Returns a discriminated result so callers can wrap it in their own
|
|
* response envelope (internal uses raw `Response`; v1 uses the JSON `ok`
|
|
* envelope with `xml` embedded as a string field).
|
|
*
|
|
* Per agi-filing.md:
|
|
* - FK570 (specifikationsnummer) MUST stay consistent per employee
|
|
* - Corrections resubmit with same FK570: a different number = a new record
|
|
* - XML is räkenskapsinformation; stored for 7-year retention per BFL 7 kap
|
|
* - Filing deadline: the 12th of the following month (17th in Jan/Aug for
|
|
* companies ≤ 40 MSEK turnover)
|
|
*/
|
|
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { z } from 'zod'
|
|
import {
|
|
generateAGIXml,
|
|
buildIndividuppgifterSnapshot,
|
|
AGIIncompleteDataError,
|
|
AGIPayloadTooLargeError,
|
|
} from './xml-generator'
|
|
import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator'
|
|
import { eventBus } from '@/lib/events'
|
|
import type { Logger } from '@/lib/logger'
|
|
|
|
// Strict runtime validation of the joined salary_run_employees row. Without
|
|
// this, columns added by recent migrations (removed_from_agi,
|
|
// benefits_adjusted, vaxa_stod_eligible, employment_start,
|
|
// housing_benefit_type) reaching the mapper as null/undefined would silently
|
|
// fall back to Boolean(undefined) = false and mis-emit regulatory flags.
|
|
// Zod produces an explicit error instead.
|
|
const EmployeeJoinSchema = z
|
|
.object({
|
|
personnummer: z.string().min(1, 'employee.personnummer saknas'),
|
|
specification_number: z.number().int().min(1, 'employee.specification_number måste vara ≥ 1'),
|
|
f_skatt_status: z.string(),
|
|
monthly_salary: z.number().nullable().optional(),
|
|
vaxa_stod_eligible: z.boolean().nullable().optional(),
|
|
employment_start: z.string().nullable().optional(),
|
|
housing_benefit_type: z.enum(['smahus', 'ej_smahus']).nullable().optional(),
|
|
})
|
|
.passthrough()
|
|
|
|
const LineItemSchema = z
|
|
.object({
|
|
item_type: z.string(),
|
|
amount: z.number().nullable().optional(),
|
|
quantity: z.number().nullable().optional(),
|
|
})
|
|
.passthrough()
|
|
|
|
const SalaryRunEmployeeRowSchema = z
|
|
.object({
|
|
employee_id: z.string().uuid(),
|
|
// Per-run snapshot of the monthly salary (authoritative for this run; the
|
|
// engine reads it, not the employee master). Used for the FK499
|
|
// sjuklönekostnad daily-rate below.
|
|
monthly_salary: z.number().nullable().optional(),
|
|
gross_salary: z.number(),
|
|
tax_withheld: z.number(),
|
|
tax_withheld_override: z.number().nullable().optional(),
|
|
avgifter_basis: z.number(),
|
|
avgifter_basis_override: z.number().nullable().optional(),
|
|
avgifter_amount: z.number(),
|
|
avgifter_amount_override: z.number().nullable().optional(),
|
|
avgifter_rate: z.number(),
|
|
avgifter_category: z.string().nullable().optional(),
|
|
removed_from_agi: z.boolean().nullable().optional(),
|
|
benefits_adjusted: z.boolean().nullable().optional(),
|
|
sick_days: z.number().nullable().optional(),
|
|
vab_days: z.number().nullable().optional(),
|
|
parental_days: z.number().nullable().optional(),
|
|
employee: EmployeeJoinSchema.nullable(),
|
|
line_items: z.array(LineItemSchema).nullable().optional(),
|
|
})
|
|
.passthrough()
|
|
|
|
type SalaryRunEmployeeRow = z.infer<typeof SalaryRunEmployeeRowSchema>
|
|
|
|
const ELIGIBLE_STATUSES = ['review', 'approved', 'paid', 'booked', 'corrected'] as const
|
|
|
|
export interface GenerateAgiDeclarationArgs {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
userId: string
|
|
/** Falls back into AGI contactEmail when company_settings + profile both have none. */
|
|
userEmail: string | null
|
|
salaryRunId: string
|
|
log: Logger
|
|
requestId: string
|
|
}
|
|
|
|
export type GenerateAgiDeclarationResult =
|
|
| {
|
|
ok: true
|
|
xml: string
|
|
agiDeclarationId: string
|
|
periodYear: number
|
|
periodMonth: number
|
|
employeeCount: number
|
|
isCorrection: boolean
|
|
totals: AGITotals
|
|
orgNumber: string
|
|
}
|
|
| {
|
|
ok: false
|
|
code: string
|
|
details?: unknown
|
|
status?: number
|
|
}
|
|
|
|
function sumLineItemAmounts(
|
|
lineItems: Array<Record<string, unknown>>,
|
|
types: string[],
|
|
): number {
|
|
return lineItems
|
|
.filter((li) => types.includes(li.item_type as string))
|
|
.reduce((sum, li) => sum + ((li.amount as number) || 0), 0)
|
|
}
|
|
|
|
export async function generateAgiDeclaration(
|
|
args: GenerateAgiDeclarationArgs,
|
|
): Promise<GenerateAgiDeclarationResult> {
|
|
const { supabase, companyId, userId, userEmail, salaryRunId, log, requestId } = args
|
|
const opLog = log.child({ salaryRunId })
|
|
|
|
// 1. Run + status precheck.
|
|
const { data: run, error: runError } = await supabase
|
|
.from('salary_runs')
|
|
.select('*')
|
|
.eq('id', salaryRunId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (runError || !run) {
|
|
return { ok: false, code: 'SALARY_RUN_NOT_FOUND' }
|
|
}
|
|
if (!ELIGIBLE_STATUSES.includes((run.status as typeof ELIGIBLE_STATUSES[number]))) {
|
|
return {
|
|
ok: false,
|
|
code: 'AGI_GENERATE_NOT_BOOKABLE',
|
|
details: { current_status: run.status, eligible_statuses: ELIGIBLE_STATUSES },
|
|
}
|
|
}
|
|
|
|
// 2. Company + settings + profile (for contact info).
|
|
const { data: company } = await supabase
|
|
.from('companies')
|
|
.select('name, org_number')
|
|
.eq('id', companyId)
|
|
.single()
|
|
|
|
if (!company) {
|
|
return { ok: false, code: 'COMPANY_NOT_FOUND' }
|
|
}
|
|
|
|
const { data: settings } = await supabase
|
|
.from('company_settings')
|
|
.select('company_name, org_number, phone, email')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('full_name, email')
|
|
.eq('id', userId)
|
|
.single()
|
|
|
|
// 3. Roster + line items + per-day absence.
|
|
const { data: runEmployees } = await supabase
|
|
.from('salary_run_employees')
|
|
.select(
|
|
'*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary, vaxa_stod_eligible, employment_start, housing_benefit_type), line_items:salary_line_items(*)',
|
|
)
|
|
.eq('salary_run_id', salaryRunId)
|
|
|
|
// An empty roster is valid: a registered employer must file a
|
|
// nolldeklaration (HU-only, no individuppgifter) for months without payroll.
|
|
// Only a genuine query failure (null) is treated as an error here.
|
|
if (!runEmployees) {
|
|
return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' }
|
|
}
|
|
|
|
// 4. Build AGI input shapes.
|
|
// Employer name on the arbetsgivardeklaration follows the current company
|
|
// name (company_settings.company_name), not the frozen onboarding companies.name.
|
|
const companyName = settings?.company_name || company.name
|
|
const companyData: AGICompanyData = {
|
|
orgNumber: (settings?.org_number || company.org_number || '').trim(),
|
|
companyName,
|
|
periodYear: run.period_year,
|
|
periodMonth: run.period_month,
|
|
contactName: (profile?.full_name || companyName || '').trim(),
|
|
contactPhone: (settings?.phone || '').trim(),
|
|
contactEmail: (settings?.email || profile?.email || userEmail || '').trim(),
|
|
}
|
|
|
|
// Load per-day absence (VAB + parental only: sick days go to FK separately).
|
|
const periodStart = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01`
|
|
const periodEndDate = new Date(Date.UTC(run.period_year, run.period_month, 0))
|
|
const periodEnd = periodEndDate.toISOString().slice(0, 10)
|
|
const employeeIds = (runEmployees as Array<{ employee_id: string }>)
|
|
.map((sre) => sre.employee_id)
|
|
.filter(Boolean)
|
|
|
|
const absenceByEmployee = new Map<
|
|
string,
|
|
Array<{
|
|
date: string
|
|
type: 'vab' | 'parental'
|
|
hours: number
|
|
specifikationsnummer: number
|
|
}>
|
|
>()
|
|
if (employeeIds.length > 0) {
|
|
const { data: absenceRows } = await supabase
|
|
.from('salary_absence_days')
|
|
.select('employee_id, absence_date, absence_type, hours, franvaro_specifikationsnummer')
|
|
.eq('company_id', companyId)
|
|
.in('absence_type', ['vab', 'parental'])
|
|
.gte('absence_date', periodStart)
|
|
.lte('absence_date', periodEnd)
|
|
.in('employee_id', employeeIds)
|
|
for (const row of (absenceRows ?? []) as Array<{
|
|
employee_id: string
|
|
absence_date: string
|
|
absence_type: 'vab' | 'parental'
|
|
hours: number
|
|
franvaro_specifikationsnummer: number | null
|
|
}>) {
|
|
// Row should always have a number for vab/parental (trigger assigns
|
|
// on insert + backfill migration covers existing data). Defensive
|
|
// fallback: skip rows missing the number rather than emit a bogus 0,
|
|
// which would collide with Skatteverket's unique key.
|
|
if (row.franvaro_specifikationsnummer == null) continue
|
|
const list = absenceByEmployee.get(row.employee_id) ?? []
|
|
list.push({
|
|
date: row.absence_date,
|
|
type: row.absence_type,
|
|
hours: Number(row.hours ?? 8),
|
|
specifikationsnummer: row.franvaro_specifikationsnummer,
|
|
})
|
|
absenceByEmployee.set(row.employee_id, list)
|
|
}
|
|
}
|
|
|
|
// Validate the joined rows up-front so a malformed Supabase response
|
|
// (missing column, wrong type, null specification_number, …) surfaces as
|
|
// a clean AGIIncompleteDataError instead of silently emitting wrong
|
|
// flags later. See SalaryRunEmployeeRowSchema definition above.
|
|
const parsedRows: SalaryRunEmployeeRow[] = (runEmployees as unknown[]).map((raw, idx) => {
|
|
const parsed = SalaryRunEmployeeRowSchema.safeParse(raw)
|
|
if (!parsed.success) {
|
|
const fields = parsed.error.issues.map((iss) => iss.path.join('.')).join(', ')
|
|
throw new AGIIncompleteDataError(
|
|
`salary_run_employees rad ${idx} har ogiltig form (saknar/felaktiga fält: ${fields}). ` +
|
|
'Detta blockerar AGI-generering eftersom Skatteverket annars skulle få bogus värden ' +
|
|
'(till exempel emitterade flaggor eller specifikationsnummer = 0).',
|
|
['salary_run_employees'],
|
|
)
|
|
}
|
|
return parsed.data
|
|
})
|
|
|
|
// Cutoff for the Växa-stöd FK062/FK063 split: pre-2024-05-01 hires get the
|
|
// legacy "första anställda"-flag (FK062); 2024-05-01 and later get the
|
|
// utvidgat växa-stöd flag (FK063). Cutoff from Skatteverket spec (Prop.
|
|
// 2023/24:80, RAML revisionshistorik 1.19).
|
|
const VAXA_STOD_FK063_CUTOFF = '2024-05-01'
|
|
|
|
const employeeData: AGIEmployeeData[] = parsedRows.map((sre) => {
|
|
const emp = sre.employee
|
|
const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }>
|
|
|
|
const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car'])
|
|
const benefitFuel = sumLineItemAmounts(lineItems, ['benefit_fuel'])
|
|
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
|
|
// FK015 kostförmån has its own field: never fold into FK012.
|
|
// Skatteverket cross-checks the krona-amount against the PBB-schablon.
|
|
const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals'])
|
|
// FK012 SkatteplOvrigaFormanerUlagAG is the catch-all for taxable
|
|
// benefits without their own FK code (bike, wellness, "other") PLUS
|
|
// the krona-amount for housing (since FK041/FK043 carry only the flag).
|
|
const benefitOther = sumLineItemAmounts(lineItems, [
|
|
'benefit_bike',
|
|
'benefit_wellness',
|
|
'benefit_other',
|
|
]) + benefitHousing
|
|
|
|
// Default housing type: if the employee got a housing benefit line
|
|
// item but no housing_benefit_type is set, treat as 'ej_smahus' (the
|
|
// more common case). NULL with no benefit line item → no flag emitted.
|
|
let housingBenefit: 'smahus' | 'ej_smahus' | undefined
|
|
if (benefitHousing > 0) {
|
|
housingBenefit = emp?.housing_benefit_type ?? 'ej_smahus'
|
|
}
|
|
|
|
const absenceEvents = absenceByEmployee.get(sre.employee_id)
|
|
|
|
let vaxaStod: 'forsta_anstalld' | 'vaxa_stod' | undefined
|
|
if (emp?.vaxa_stod_eligible) {
|
|
vaxaStod =
|
|
emp.employment_start && emp.employment_start < VAXA_STOD_FK063_CUTOFF
|
|
? 'forsta_anstalld'
|
|
: 'vaxa_stod'
|
|
}
|
|
|
|
// Växa-stöd (employment-start-gated relief, 10.21 % avgifter) and the
|
|
// ungdomsrabatt (age-gated relief, 'youth' avgifter_category) are
|
|
// distinct statutory programs and must not be claimed for the same
|
|
// employee in the same period. Catching this at generation time
|
|
// avoids emitting an FK062/FK063 flag inconsistent with the FK061
|
|
// category total.
|
|
if (vaxaStod && sre.avgifter_category === 'youth') {
|
|
throw new AGIIncompleteDataError(
|
|
`Anställd ${emp?.specification_number ?? '?'}: kan inte kombinera växa-stöd ` +
|
|
'(FK062/FK063) med ungdomsrabatt (avgifter_category="youth"): programmen är ömsesidigt uteslutande. ' +
|
|
'Välj ett av dem under anställdas inställningar.',
|
|
['vaxa_stod_eligible', 'avgifter_category'],
|
|
)
|
|
}
|
|
|
|
const isFSkatt = emp?.f_skatt_status === 'f_skatt'
|
|
// Honor advanced-mode per-employee overrides set during review.
|
|
const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld
|
|
const effectiveAvgifterBasis = sre.avgifter_basis_override ?? sre.avgifter_basis
|
|
return {
|
|
personnummer: emp?.personnummer ?? '',
|
|
specificationNumber: emp?.specification_number ?? 0,
|
|
removed: Boolean(sre.removed_from_agi),
|
|
grossSalary: sre.gross_salary,
|
|
taxWithheld: effectiveTax,
|
|
avgifterBasis: effectiveAvgifterBasis,
|
|
fSkattPayment: isFSkatt ? sre.gross_salary : undefined,
|
|
// F-skatt payees: cash goes to FK131 and benefits to the ej-UlagSA
|
|
// variants (FK132/FK133/FK134/FK137/FK138/FK139). Regular employees
|
|
// get FK011 + FK012/FK013/FK015/FK018/FK041/FK043.
|
|
benefitsExcludedFromSAUnderlag: isFSkatt ? true : undefined,
|
|
benefitCar: benefitCar > 0 ? benefitCar : undefined,
|
|
benefitFuel: benefitFuel > 0 ? benefitFuel : undefined,
|
|
benefitMeals: benefitMeals > 0 ? benefitMeals : undefined,
|
|
housingBenefit,
|
|
benefitOther: benefitOther > 0 ? benefitOther : undefined,
|
|
benefitsAdjusted: Boolean(sre.benefits_adjusted),
|
|
vaxaStod,
|
|
sickDays: (sre.sick_days ?? 0) > 0 ? (sre.sick_days ?? 0) : undefined,
|
|
vabDays: (sre.vab_days ?? 0) > 0 ? (sre.vab_days ?? 0) : undefined,
|
|
parentalDays:
|
|
(sre.parental_days ?? 0) > 0 ? (sre.parental_days ?? 0) : undefined,
|
|
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
|
|
}
|
|
},
|
|
)
|
|
// Drop individuppgifter with nothing to report. An employee who took 0 kr
|
|
// and had no benefits, tax or absence this month is simply omitted (you
|
|
// only file an IU for a person who received something). This yields a clean
|
|
// HU-only nolldeklaration for a full nollkörning, and omits zero-paid
|
|
// employees in a mixed run. Borttag (removed) tombstones are always kept.
|
|
.filter(
|
|
(e) =>
|
|
e.removed === true ||
|
|
(e.grossSalary ?? 0) > 0 ||
|
|
(e.taxWithheld ?? 0) > 0 ||
|
|
(e.fSkattPayment ?? 0) > 0 ||
|
|
(e.benefitCar ?? 0) > 0 ||
|
|
(e.benefitFuel ?? 0) > 0 ||
|
|
(e.benefitMeals ?? 0) > 0 ||
|
|
(e.benefitOther ?? 0) > 0 ||
|
|
e.housingBenefit !== undefined ||
|
|
(e.sickDays ?? 0) > 0 ||
|
|
(e.vabDays ?? 0) > 0 ||
|
|
(e.parentalDays ?? 0) > 0 ||
|
|
(e.absenceEvents?.length ?? 0) > 0,
|
|
)
|
|
|
|
// 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs).
|
|
// Removed-from-AGI rows (FK205 borttag) are tombstones: they must not
|
|
// contribute to FK497/FK487/FK499 because the prior submission's amounts
|
|
// remain on file at Skatteverket; the borttag just removes the IU itself.
|
|
const activeEmployees = parsedRows.filter((sre) => !sre.removed_from_agi)
|
|
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
|
|
for (const sre of activeEmployees) {
|
|
const dbCategory = sre.avgifter_category ?? null
|
|
const category = dbCategory
|
|
? dbCategory === 'reduced_65plus'
|
|
? 'reduced65plus'
|
|
: dbCategory === 'vaxa_stod'
|
|
? 'standard'
|
|
: dbCategory
|
|
: sre.avgifter_rate <= 0.1022
|
|
? 'reduced65plus'
|
|
: sre.avgifter_rate <= 0.2082
|
|
? 'youth'
|
|
: 'standard'
|
|
const cat = (avgifterByCategory as Record<string, { basis: number; amount: number }>)[
|
|
category
|
|
] || { basis: 0, amount: 0 }
|
|
cat.basis += sre.avgifter_basis_override ?? sre.avgifter_basis
|
|
cat.amount += sre.avgifter_amount_override ?? sre.avgifter_amount
|
|
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
|
|
}
|
|
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
|
|
(sum, cat) => sum + (cat?.amount ?? 0),
|
|
0,
|
|
)
|
|
|
|
// FK499 sjuklönekostnad: sum of paid sjuklön (days 2-14) across all
|
|
// employees. Day 1 is karens (unpaid); day 15+ is Försäkringskassan.
|
|
const calcParams = ((run.calculation_params as Record<string, unknown>) ?? {}) as {
|
|
sjuklonRate?: number
|
|
sjuklon_rate?: number
|
|
}
|
|
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8
|
|
let totalSjuklonekostnad = 0
|
|
for (const sre of activeEmployees) {
|
|
const monthly = sre.monthly_salary ?? 0
|
|
if (!monthly) continue
|
|
const dailyRate = monthly / 21
|
|
const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }>
|
|
for (const li of lineItems) {
|
|
if (li.item_type === 'sick_day2_14') {
|
|
const days = li.quantity ?? 0
|
|
totalSjuklonekostnad += dailyRate * sjuklonRate * days
|
|
}
|
|
}
|
|
}
|
|
|
|
// FK497 SummaSkatteavdr must equal the sum of FK001 on active IUs (not
|
|
// run.total_tax, which includes removed rows). Same for FK487.
|
|
// Coalesce override → computed so manual jämkning/FoU adjustments flow
|
|
// into the filed declaration.
|
|
const totalTax = activeEmployees.reduce(
|
|
(sum, sre) => sum + ((sre.tax_withheld_override ?? sre.tax_withheld) || 0),
|
|
0,
|
|
)
|
|
|
|
const totals: AGITotals = {
|
|
totalTax: Math.round(totalTax * 100) / 100,
|
|
totalAvgifterBasis: activeEmployees.reduce(
|
|
(s, e) => s + ((e.avgifter_basis_override ?? e.avgifter_basis) || 0),
|
|
0,
|
|
),
|
|
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
|
|
totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100,
|
|
avgifterByCategory,
|
|
}
|
|
|
|
// Soft AGI deadline check: warn (but don't block) when generating for a
|
|
// future period or one whose Skatteverket correction window is clearly
|
|
// past. Filing deadline is the 12th (17th in Jan/Aug for small employers)
|
|
// of the month after the period; SKV accepts corrections for a long time
|
|
// after, but a period > 13 months in the past is almost certainly a
|
|
// misclick. Surface via the logger so audit log + Sentry both see it.
|
|
{
|
|
const now = new Date()
|
|
const currentYM = now.getUTCFullYear() * 100 + (now.getUTCMonth() + 1)
|
|
const periodYM = run.period_year * 100 + run.period_month
|
|
if (periodYM > currentYM) {
|
|
opLog.warn('AGI generated for future period', {
|
|
companyId,
|
|
periodYear: run.period_year,
|
|
periodMonth: run.period_month,
|
|
})
|
|
} else if (currentYM - periodYM > 13) {
|
|
opLog.warn('AGI generated for period > 13 months past', {
|
|
companyId,
|
|
periodYear: run.period_year,
|
|
periodMonth: run.period_month,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 6. Existing AGI determines correction status. Use `.maybeSingle()`
|
|
// because the lookup must tolerate the no-row case without throwing:
|
|
// that's the FIRST-time generation path. `.single()` would surface a
|
|
// PGRST116 row-not-found error and abort what should be a clean insert.
|
|
const { data: existingAgi } = await supabase
|
|
.from('agi_declarations')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('period_year', run.period_year)
|
|
.eq('period_month', run.period_month)
|
|
.maybeSingle()
|
|
|
|
const isCorrection = !!existingAgi
|
|
|
|
// 7. Generate XML.
|
|
let xml: string
|
|
try {
|
|
xml = generateAGIXml(companyData, employeeData, totals, isCorrection)
|
|
} catch (err) {
|
|
if (err instanceof AGIIncompleteDataError) {
|
|
return {
|
|
ok: false,
|
|
code: 'AGI_INCOMPLETE_DATA',
|
|
details: { missing_fields: err.missingFields, message: err.message },
|
|
}
|
|
}
|
|
if (err instanceof AGIPayloadTooLargeError) {
|
|
return {
|
|
ok: false,
|
|
code: 'AGI_PAYLOAD_TOO_LARGE',
|
|
details: {
|
|
message: err.message,
|
|
size_bytes: err.sizeBytes,
|
|
limit_bytes: err.limitBytes,
|
|
},
|
|
status: 413,
|
|
}
|
|
}
|
|
throw err
|
|
}
|
|
const individuppgifter = buildIndividuppgifterSnapshot(employeeData)
|
|
|
|
// 8. UPSERT agi_declarations.
|
|
let agiDeclarationId: string
|
|
if (existingAgi) {
|
|
const { error: updErr } = await supabase
|
|
.from('agi_declarations')
|
|
.update({
|
|
xml_content: xml,
|
|
individuppgifter,
|
|
total_gross: run.total_gross,
|
|
total_tax: run.total_tax,
|
|
total_avgifter_basis: totals.totalAvgifterBasis,
|
|
// Use the per-category sum that drives the XML rather than the
|
|
// run-level denormalised total. Both should agree, but a
|
|
// round-then-sum vs sum-then-round can produce öre drift; the
|
|
// agi_declarations row should align with what was actually
|
|
// serialised into the XML (which Skatteverket sees).
|
|
total_avgifter: totals.totalAvgifterAmount,
|
|
employee_count: employeeData.length,
|
|
is_correction: true,
|
|
salary_run_id: run.id,
|
|
})
|
|
.eq('id', existingAgi.id)
|
|
if (updErr) {
|
|
return { ok: false, code: 'DATABASE_ERROR', details: updErr }
|
|
}
|
|
agiDeclarationId = existingAgi.id as string
|
|
} else {
|
|
const { data: inserted, error: insErr } = await supabase
|
|
.from('agi_declarations')
|
|
.insert({
|
|
company_id: companyId,
|
|
user_id: userId,
|
|
salary_run_id: run.id,
|
|
period_year: run.period_year,
|
|
period_month: run.period_month,
|
|
xml_content: xml,
|
|
individuppgifter,
|
|
total_gross: run.total_gross,
|
|
total_tax: run.total_tax,
|
|
total_avgifter_basis: totals.totalAvgifterBasis,
|
|
// Use the per-category sum that drives the XML rather than the
|
|
// run-level denormalised total. Both should agree, but a
|
|
// round-then-sum vs sum-then-round can produce öre drift; the
|
|
// agi_declarations row should align with what was actually
|
|
// serialised into the XML (which Skatteverket sees).
|
|
total_avgifter: totals.totalAvgifterAmount,
|
|
employee_count: employeeData.length,
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (insErr) {
|
|
// Concurrent-call race: two :generate-agi requests for the same
|
|
// (company, period) reached the INSERT branch simultaneously. The
|
|
// earlier read of `existingAgi` returned null for both, but the
|
|
// first INSERT wins and the second hits the unique constraint.
|
|
// Postgres error 23505 is the unique-violation code; recover by
|
|
// re-fetching the now-existing row and treating this call as a
|
|
// correction (the second caller's XML supersedes the first).
|
|
if ((insErr as { code?: string }).code === '23505') {
|
|
const { data: nowExisting, error: refetchErr } = await supabase
|
|
.from('agi_declarations')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('period_year', run.period_year)
|
|
.eq('period_month', run.period_month)
|
|
.maybeSingle()
|
|
if (refetchErr || !nowExisting) {
|
|
return { ok: false, code: 'DATABASE_ERROR', details: refetchErr || insErr }
|
|
}
|
|
const { error: raceUpdErr } = await supabase
|
|
.from('agi_declarations')
|
|
.update({
|
|
xml_content: xml,
|
|
individuppgifter,
|
|
total_gross: run.total_gross,
|
|
total_tax: run.total_tax,
|
|
total_avgifter_basis: totals.totalAvgifterBasis,
|
|
// Use the per-category sum that drives the XML rather than the
|
|
// run-level denormalised total. Both should agree, but a
|
|
// round-then-sum vs sum-then-round can produce öre drift; the
|
|
// agi_declarations row should align with what was actually
|
|
// serialised into the XML (which Skatteverket sees).
|
|
total_avgifter: totals.totalAvgifterAmount,
|
|
employee_count: employeeData.length,
|
|
is_correction: true,
|
|
salary_run_id: run.id,
|
|
})
|
|
.eq('id', nowExisting.id)
|
|
if (raceUpdErr) {
|
|
return { ok: false, code: 'DATABASE_ERROR', details: raceUpdErr }
|
|
}
|
|
agiDeclarationId = nowExisting.id as string
|
|
opLog.warn('agi_declarations insert raced; recovered via update', {
|
|
companyId,
|
|
periodYear: run.period_year,
|
|
periodMonth: run.period_month,
|
|
})
|
|
// Note: the caller-facing `isCorrection` flag (set above based on
|
|
// the pre-INSERT existingAgi lookup) reports `false` even though
|
|
// the database state is now technically a correction. Edge case
|
|
// limited to the race window; the agi_declarations row is
|
|
// correctly marked is_correction=true and the next call will
|
|
// see it.
|
|
} else {
|
|
return { ok: false, code: 'DATABASE_ERROR', details: insErr }
|
|
}
|
|
} else if (!inserted) {
|
|
return { ok: false, code: 'DATABASE_ERROR', details: insErr }
|
|
} else {
|
|
agiDeclarationId = inserted.id as string
|
|
}
|
|
}
|
|
|
|
// 9. Stamp generation timestamp on salary_runs.
|
|
await supabase
|
|
.from('salary_runs')
|
|
.update({ agi_generated_at: new Date().toISOString() })
|
|
.eq('id', salaryRunId)
|
|
|
|
// 10. Emit agi.generated (best-effort: never block the success path).
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'agi.generated',
|
|
payload: {
|
|
agiId: agiDeclarationId,
|
|
periodYear: run.period_year,
|
|
periodMonth: run.period_month,
|
|
userId,
|
|
companyId,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
opLog.warn('agi.generated emit failed', err as Error)
|
|
}
|
|
|
|
// 11. Auto-complete the arbetsgivardeklaration deadline for this period
|
|
// (Skatteförfarandelagen: AGI generation satisfies the filing
|
|
// obligation). Optimistic-lock on status='pending'.
|
|
const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
|
await supabase
|
|
.from('deadlines')
|
|
.update({
|
|
status: 'completed',
|
|
completed_at: new Date().toISOString(),
|
|
completed_by: userId,
|
|
})
|
|
.eq('company_id', companyId)
|
|
.eq('type', 'arbetsgivardeklaration')
|
|
.eq('period', period)
|
|
.eq('status', 'pending')
|
|
|
|
opLog.info('AGI declaration generated', {
|
|
requestId,
|
|
salaryRunId,
|
|
agiDeclarationId,
|
|
isCorrection,
|
|
employeeCount: employeeData.length,
|
|
})
|
|
|
|
return {
|
|
ok: true,
|
|
xml,
|
|
agiDeclarationId,
|
|
periodYear: run.period_year,
|
|
periodMonth: run.period_month,
|
|
employeeCount: employeeData.length,
|
|
isCorrection,
|
|
totals,
|
|
orgNumber: companyData.orgNumber,
|
|
}
|
|
}
|