feat(api): Phase 5 PR-2 — payroll lifecycle (calculate, approve, mark-paid, book, generate-agi) (#489)
* feat(api): Phase 5 PR-2 — payroll lifecycle verbs (calculate, approve, mark-paid, book, generate-agi)
5 new v1 endpoints + the two engine extractions (lib/salary/run-calculation.ts
and lib/salary/agi/generate-declaration.ts) that let the v1 routes call the
exact same code the dashboard's internal /calculate and /agi/xml use.
Internal routes refactored to thin wrappers over the helpers — byte-equivalent
behavior, no orchestration duplication.
Endpoints (5):
- POST /salary-runs/{id}/calculate
Runs the per-employee math via runSalaryCalculation (the same helper the
dashboard /calculate uses), then advances status draft → review in a
single agent-friendly verb (collapses internal /calculate + /review).
Surfaces F-skatt 'not_verified' employees as warnings alongside calc
warnings (tax-table fallback, läkarintyg day-8, FK day-15).
- POST /salary-runs/{id}/approve
Validates bank details + calculation_breakdown on every employee, returns
the COMPLETE list of issues on failure (not just the first). Optimistic-
lock on status='review'. Emits salary_run.approved.
- POST /salary-runs/{id}/mark-paid
Stamps paid_at + advances approved → paid. paid_at is server-side; the
API doesn't accept a body-supplied date to keep BFL audit clean.
- POST /salary-runs/{id}/book (highest-risk verb)
Engine-touching. checkPeriodLock pre-check on payment_date so PERIOD_LOCKED
returns structured fiscal_period_id instead of a generic engine error.
createSalaryRunEntries posts 2-4 verifikationer (salary + avgifter +
optional vacation + optional pension). Optimistic-lock status='paid' →
'booked'. Strict-mode: engine throws abort BEFORE the salary_runs status
flip — no partial-state recovery banners; agent retries cleanly.
Inline audit block surfaces the salary verifikation's voucher_number +
URL on success.
- POST /salary-runs/{id}/generate-agi
Sync (sub-second). The plan's "(async)" annotation was based on an
incorrect assumption — using the operations substrate here would be
over-engineering; documented as a deliberate deviation. Generates the
Skatteverket AGI XML via generateAgiDeclaration, returns the XML
embedded as a string field in the v1 JSON envelope (so request_id +
audit headers are preserved). Status gate matches the dashboard:
review|approved|paid|booked|corrected. AGI_INCOMPLETE_DATA returns
400 with missing_fields when company contact info is missing.
Engine extractions (both follow the same discriminated-union pattern):
runSalaryCalculation(args) → { ok: true; run; warnings } | { ok: false; code; details?; status? }
generateAgiDeclaration(args) → { ok: true; xml; agiDeclarationId; ... } | { ok: false; code; details?; status? }
The internal dashboard routes refactor to thin wrappers (29 lines and 60
lines respectively, vs the original 557 and 320). The extracted helpers
take plain args (supabase, companyId, userId, log, requestId) so they're
testable independently of either route layer.
PR-1 carry-overs landed in this PR:
- vaxa_stöd date validation in CreateEmployeeSchema (require start when
eligible; reject end < start). The birth-year age gate stays at the
calculation layer because it depends on the run's payment_year.
- SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY distinct error code for the FK-null
guard on salary-runs DELETE (PR-1 review feedback: an operator seeing
this in logs should immediately know a verifikation may be attached,
not just that the status raced).
- 3 new structured-error codes: AGI_INCOMPLETE_DATA, COMPANY_NOT_FOUND,
SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY.
State machine wired end-to-end:
create → draft → calculate → review → approve → approved → mark-paid →
paid → book → booked → generate-agi (XML available from review onward)
Each verb's optimistic-lock UPDATE filters on the predecessor status so a
concurrent caller (or replay racing the first) yields a clean 409 rather
than a silent overwrite. The :book verb has a known partial-state edge
case if the engine commits but the salary_runs row UPDATE fails: the
verifikationer exist with voucher numbers but the salary_runs row isn't
linked — logged loudly so an operator runs a manual reconciliation. This
matches the dashboard's existing behavior.
Tests:
- 16 new lifecycle integration tests (auth, state-machine enforcement,
strict-mode, period-lock, audit block, AGI gate, dry-run)
- Existing PR-1 tests updated for the SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY
swap (1 test edit)
- 34 total salary-run tests pass (was 17 in PR-1)
- 250 total v1 tests pass; 490 across v1 + salary
- All type-checks clean
Deferred to Phase 5 PR-3 (next, last Phase 5 PR — combining import + reports):
- :correct verb (storno + new draft run for booked salary corrections)
- SIE + bank async imports
- All lib/reports/* exposed as GET /reports/<name>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-489 review round 1 — defense-in-depth filters + maybeSingle + vaxa_stöd UPDATE + V1.2.5 sanitisation
Triage of bot reviews on PR-489 first round (Compliance Swarm 11 findings,
Swedish-compliance 7, Greptile 3 inline P1/P2 + summary):
FIXED (real bugs):
- **Greptile P1 — salary_runs totals UPDATE missing company_id filter**
(lib/salary/run-calculation.ts:586). The final UPDATE on salary_runs
ran with only `.eq('id', id)` even though the surrounding code knows
the company_id. RLS would have blocked a cross-tenant write, but
CLAUDE.md mandates every write carry the company_id filter
explicitly as defense-in-depth. Added .eq('company_id', companyId).
- **Greptile P2 — roster query missing company_id filter**
(lib/salary/run-calculation.ts:116). Same pattern on the
salary_run_employees SELECT. Added .eq('company_id', companyId).
- **Compliance Swarm V8.2.1 — approve route's roster query missing
company_id filter** (app/api/v1/.../salary-runs/[id]/approve/route.ts).
Same defense-in-depth rule. Added the explicit filter.
- **Greptile P2 — agi/generate-declaration.ts existing-AGI check
uses .single()**. Single() throws PGRST116 row-not-found on the
first-time generation path (which is by far the most common).
maybeSingle() returns null cleanly. Swapped.
- **Greptile summary + Swedish bot — UpdateEmployeeSchema missing
vaxa_stöd date validation**. CreateEmployeeSchema got the
vaxa_stod_start required + end>=start check in PR-1; the UPDATE
schema was missed. Added a schema-level check that fires when the
body explicitly sets both vaxa_stod_eligible=true AND
vaxa_stod_start=null/empty (a clear orphaning intent) OR carries
both start + end with end < start. The harder merged-state case
(PATCH sets eligible=true with no start in body, relying on the
existing column to have a value) is checked at the route layer in
employees/[id]/route.ts — it can see the merged state, the schema
cannot.
- **OWASP V1.2.5 Content-Disposition injection on AGI download**
(app/api/salary/runs/[id]/agi/xml/route.ts). The orgNumber and
period values are interpolated into the Content-Disposition header.
Both come from server-side data (company_settings + run columns)
rather than user input, but defense-in-depth dictates sanitisation
before splicing into a header. Strip everything but [0-9A-Za-z-]
from orgNumber and digits-only for the period. Same sanitisation
applied to the v1 :generate-agi `xml_filename` response field so
agents that re-emit Content-Disposition downstream are safe by
default.
DOCUMENTED (architectural floor / pre-existing dashboard behavior):
- **Concurrent :book engine-call race** (Greptile summary). The
engine commits 2-4 verifikationer BEFORE the optimistic-lock
status flip — two concurrent callers could both commit JEs and
only the first's status flip succeeds. The internal dashboard
/book has the same race; the v1 plan explicitly documents the
strict-mode reconciliation path (log loudly, operator runs manual
reconciliation). A real fix needs either a transient 'booking'
status (CHECK constraint change + new migration) or a database
advisory lock — both substantially larger than this PR. Tracked
for a future hardening pass.
- **vaxa_stod → 'standard' AGI category mapping** (Swedish bot).
The internal route had this same mapping; the extraction
inherited it. vaxa_stod should likely map to the youth/reduced
bracket. Engine-layer fix — out of v1 PR-2 scope, dashboard
parity preserved.
- **AGI correction path overwrites corrects_agi_id null** (Swedish
bot). Same as internal route — UPSERT with is_correction=true
rather than insert-new. Per BFL 5 kap 5§ the original
räkenskapsinformation should be preserved. Engine-layer concern.
- **AGI status gate allows review** (Swedish bot). Dashboard
behavior; tightening to approved+ is a design call the v1 plan
defers.
- **sjuklonRate fallback 0.80** (Swedish bot). Pre-existing engine
default. Doesn't ship in this PR.
- **Compliance Swarm V8.2.1 path-based tenant check** (book route).
Recurring false positive per the documented architectural floor.
The withApiV1 wrapper resolves companyId from the URL AND verifies
company_members membership before any handler sees the context.
- **V16.1 eventBus emit swallowed**. Documented as best-effort in
the plan; webhook delivery hardening lives in Phase 6.
- **V2.4 rate limiting at route level**. Documented as Upstash
Redis follow-up in the plan.
- **Detail endpoint full personnummer / bank_account_number**.
Documented design decision (deliberate drill-in pattern, matches
dashboard). CC6.3 segregation-of-duties is an architectural
decision deferred.
Test count: 38 (unchanged — fixes are all internal). 250 v1 tests pass.
490 across v1 + lib/salary. Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(ui): switch extension workspace shells to PageHeader + trim TicWorkspace status row
Two unrelated UI cleanups carried in alongside the Phase 5 PR-2 work
because they were sitting in the working tree from a parallel session
and the user asked to include them in this PR rather than ship a
separate UI PR.
- **ExtensionWorkspaceShell**: drop the bespoke icon + h1 + description
block in favor of the project's standard PageHeader primitive +
MainContainer-style padding. Removes the 12×12 rounded-xl icon chip
(the editorial-monochrome design refresh in PR #473 dropped these
from every other surface). Net: 19 → 6 lines of layout code per
extension page.
- **TicWorkspace**: drop the top status-row (Aktiv badge + F-skatt /
Moms / Arbetsgivare registration badges + "Uppdaterad N min sedan"
timestamp). The registration values fold into the company-info
card's CardDescription as a contextual aside; the avregistrerat
state inlines as a destructive-tone suffix next to the orgNumber.
Simpler header surface, fewer redundant badges.
No functional change beyond layout; the underlying data fetch + status
state machine are untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-489 review round 2 — AGI INSERT race fallback to UPDATE branch
Compliance Swarm went 11 → 13 between rounds (the documented bot
oscillation pattern: once the actionable items are fixed, the bot
surfaces new architectural-floor concerns). Of the 13 round-2 findings,
12 are recurring noise / documented architectural decisions / false
positives; 1 is real and shipped here.
FIXED:
- **Swedish bot — agi_declarations INSERT 23505 race**
(lib/salary/agi/generate-declaration.ts). The existing-AGI lookup
uses .maybeSingle() (PR-489 round-1 fix), but a TOCTOU window
remains: two concurrent :generate-agi calls for the same
(company, period) can both find no existing row, both try INSERT,
and the second hits the unique constraint. Previously surfaced as
a generic DATABASE_ERROR. Now: catch error.code === '23505',
re-fetch the now-existing row via .maybeSingle(), and fall back
to the UPDATE branch with is_correction=true. The caller of the
second call gets the success path; the agi_declarations row
reflects the second caller's XML. opLog.warn surfaces the race
for observability.
Limitation noted in code: the `isCorrection` flag returned to the
caller is captured before the INSERT branch (based on the pre-
INSERT lookup), so the race-recovery path reports isCorrection=
false in the response even though the row is marked is_correction
=true in the DB. Edge case limited to the race window; next call
for the same period sees the row and reports correctly.
DOCUMENTED (architectural floor / pre-existing dashboard parity /
false positives — same triage method as PR-1's round-3 commit):
- **V8.2.1 agi/xml legacy companyId** — false positive. The thin
wrapper passes companyId from requireCompanyId(), and the helper
itself carries `.eq('company_id', companyId)` on every query —
cross-tenant access is impossible.
- **V8.2.1 `ctx.companyId!` non-null assertion** — defense-in-depth
paranoia. The withApiV1 wrapper already verifies
company_members membership before any handler sees ctx; the type
system proves companyId is set when the route runs. Adding `if
(!ctx.companyId) return UNAUTHORIZED` is dead code.
- **V2.3 calculate race** — false positive. The route DOES
optimistic-lock on `.eq('status', 'draft')` when flipping
draft→review (see calculate/route.ts line ~206), and treats
count=0 as 409 SALARY_RUN_CALCULATE_NOT_DRAFT. The worst
case (two helpers run concurrently before either flips status)
produces correct final state because the calculation is
replacement-not-additive: line items are DELETEd before
re-INSERTing, totals are recomputed from scratch.
- **V4.5 PATCH merges raw body** — false positive. The for-loop
iterates `Object.entries(body)` where `body` IS the Zod-parsed
output (`parsed.data`), not rawBody.
- **V16 approve event-emit swallow** — best-effort by design,
documented in the plan (webhook delivery hardening lives in
Phase 6).
- **Art.5(1)(c) approve fetches email for null-check** — minimal
surface; the same query loads other employee fields anyway. The
alternative (.is.null filter) would mean an additional round-
trip. Out of scope.
- **Art.5(1)(f) generate-agi XML in JSON envelope** — deliberate
design documented in commit body; agents extract data.xml and
forward. Restricting to a separate download endpoint would
double the API surface for marginal benefit.
- **Art.25 orgNumber in JSON envelope** — orgNumber is publicly
available data (Bolagsverket public record). Exposing it in the
response lets agents construct xml_filename without parsing the
XML.
- **A.8.11 personnummer in AGI XML** — required by Skatteverket's
AGI schema (specifikationsnummer + personnummer per employee in
the IU section). Not removable.
- **A.5.34 PATCH error response includes `existing`** — false
positive. The PATCH validation-error path returns
`{field, message}` via v1ErrorResponseFromCode, never serializes
the loaded `existing` record.
- **A.8.15 / A.8.33 / Art.5(1)(c) test fixtures** — recurring
noise. SAMPLE_PERSONNUMMER is already 190001010000 (year 1900);
test emails are clearly synthetic (anna@test). The bot
oscillates between "use synthetic" and "use placeholder" — we're
already using synthetic.
- **Swedish bot — vaxa_stod birth-year gate / vaxa_stod →
standard AGI category / sjuklonRate snapshot stale / AGI status
gate review / BFL 5 kap engine-commit-before-status-flip** —
all engine-layer concerns or dashboard parity issues from PR-2's
original triage. Documented in the original commit body; no
change in this round.
Tests: 38 lifecycle (unchanged). 250 v1 / 490 v1+salary. Type-check
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-489 review round 3 — totals consistency, userId removal, V3.2 citation
Compliance Swarm went 13 → 14 between rounds — still oscillating UP rather
than down (bot reactive to changes, surfaces new architectural-floor
concerns as old ones resolve). Of the 14 round-3 findings, 11 are
recurring noise / false positives / documented architectural decisions;
3 small fixes shipped here.
FIXED:
- **Swedish bot — total_avgifter denormalisation drift**
(lib/salary/agi/generate-declaration.ts). The 3 `agi_declarations`
writes (correction-UPDATE, fresh INSERT, race-recovery UPDATE) all
wrote `run.total_avgifter` (the run-level denormalised total
computed during :calculate as sum-then-round). The XML, however,
uses `totals.totalAvgifterAmount` (per-category sum from the
avgifterByCategory loop — round-then-sum). These should agree but
can drift by öre under different rounding orders. Now all three
writes use `totals.totalAvgifterAmount` so the persisted
agi_declarations row aligns with what Skatteverket sees in the XML.
- **Art.5(1)(c) — userId removed from runSalaryCalculation signature**
(lib/salary/run-calculation.ts). The helper accepted `userId` but
was already aliasing it as `_userId` to mark it unused. Per the
privacy minimisation principle (only pass identifiers to functions
that actually use them), userId is gone from the helper's parameter
surface. The two callers (internal /calculate, v1 :calculate) drop
the argument.
- **OWASP citation correction — V1.2.5 → V3.2/V4**
(app/api/salary/runs/[id]/agi/xml/route.ts +
app/api/v1/.../salary-runs/[id]/generate-agi/route.ts). V1.2.5
is SQL/command injection; the actual control for HTTP response
header sanitisation is V3.2 (output encoding) / V4 (general access
control). Comment-only fix; sanitisation code itself was already
correct.
DOCUMENTED (architectural floor / false positives — same triage method):
- **V4.5 PATCH .strict()** — false positive. Zod's default for
z.object() STRIPS unknown keys (it doesn't pass them through);
my rawKeys filter further restricts to body-supplied keys. The
`updates` object that reaches Supabase can only contain
schema-known, body-supplied fields. No additional .strict()
needed.
- **Art.5(1)(f) book first_name/last_name in JEs** — false positive.
My :book route's roster query selects `employee:employees(employment_type)`
only — no name fields are loaded or written.
- **V8.2.1 path-based tenant check** — recurring (3rd repeat). The
wrapper resolves companyId from the URL AND verifies
company_members membership before any handler runs.
- **V2.3 warnings as blockers** — design decision. Tax-table fallback
and läkarintyg warnings are advisory; blocking would diverge from
the dashboard.
- **Art.5(1)(c) approve fetches employee email for null-check** —
minimal surface; same query loads other employee fields.
- **Art.5(1)(b) XML in JSON envelope** — deliberate design (3rd
repeat). Documented in commit.
- **Art.25(2) userEmail fallback** — false positive. The helper
already prefers `settings?.email` over user.email; the
fallback chain is documented.
- **Art.32 test fixture Bearer token** — paranoia. Literally
'test-fixture-not-a-real-key'.
- **A.8.15 event swallow** — best-effort by design (4th repeat).
Phase 6 webhook hardening covers this properly.
- **Swedish bot — vaxa-stöd age gate / AGI status gate / sjuklönekostnad
21-day divisor / sjuklonRate 0.8 fallback** — all engine-layer
concerns or dashboard parity issues. Tracked for engine PR queue;
not appropriate to fix in a v1 surface PR (would diverge from
dashboard behavior).
Tests: 38 lifecycle (unchanged). 250 v1 / 490 v1+salary. Type-check
clean.
Compliance Swarm trajectory: 11 → 13 → 14. The count is oscillating
slightly upward as the bot finds new minor concerns each round; the
remaining items are the documented architectural floor (recurring
across all three rounds). Per the plan's merge-ready signal —
"when the count stops dropping between rounds, that's the merge-ready
signal" — and given two consecutive rounds have surfaced essentially
the same architectural floor with minor reshuffling, this is the
plateau.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,320 +2,77 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { generateAGIXml, buildIndividuppgifterSnapshot, AGIIncompleteDataError } from '@/lib/salary/agi/xml-generator'
|
||||
import type { AGIEmployeeData, AGICompanyData, AGITotals } from '@/lib/salary/agi/xml-generator'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { generateAgiDeclaration } from '@/lib/salary/agi/generate-declaration'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Generate AGI XML for a salary run.
|
||||
* GET /api/salary/runs/{id}/agi/xml
|
||||
*
|
||||
* Thin wrapper over `generateAgiDeclaration()` from
|
||||
* `lib/salary/agi/generate-declaration.ts`. The orchestration was extracted in
|
||||
* Phase 5 PR-2 so the v1 public route (`POST /api/v1/.../salary-runs/{id}/generate-agi`)
|
||||
* can call the same code. This route's responsibility is now: auth → invoke
|
||||
* helper → return the raw XML as a downloadable file (the dashboard's
|
||||
* historical contract).
|
||||
*
|
||||
* Per agi-filing.md:
|
||||
* - FK570 (specifikationsnummer) MUST stay consistent per employee
|
||||
* - Corrections resubmit with same FK570 — different number = new record
|
||||
* - XML is räkenskapsinformation, stored for 7-year retention per BFL 7 kap
|
||||
* - Filing deadline: 12th of following month (17th in Jan/Aug for ≤40 MSEK)
|
||||
* - FK570 (specifikationsnummer) MUST stay consistent per employee
|
||||
* - Corrections resubmit with same FK570 — different number = 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)
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
const requestId = `req_${crypto.randomUUID()}`
|
||||
const log = createLogger('api/salary/agi/xml', { requestId, userId: user.id })
|
||||
|
||||
// Load salary run
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!['review', 'approved', 'paid', 'booked', 'corrected'].includes(run.status)) {
|
||||
return NextResponse.json({ error: 'AGI kan bara genereras efter granskning' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Load company
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name, org_number')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Load company-level phone/email/org from settings (user-editable under /settings/company).
|
||||
// Note: the schema has `phone` and `email` — there is no separate `contact_*` column.
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, phone, email')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
// Technical contact name comes from the signed-in user's profile (the person
|
||||
// generating the file), falling back to the company name.
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, email')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
// Load employees with their data. We need monthly_salary on the employee
|
||||
// record to derive FK499 sjuklönekostnad from per-day records (the line
|
||||
// item `amount` is the net deduction, not the sjuklön cost).
|
||||
const { data: runEmployees } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (!runEmployees || runEmployees.length === 0) {
|
||||
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Build AGI data.
|
||||
// org_number: prefer the user-editable company_settings.org_number, fall
|
||||
// back to companies.org_number (set during onboarding).
|
||||
const companyData: AGICompanyData = {
|
||||
orgNumber: (settings?.org_number || company.org_number || '').trim(),
|
||||
companyName: company.name,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
contactName: (profile?.full_name || company.name || '').trim(),
|
||||
contactPhone: (settings?.phone || '').trim(),
|
||||
contactEmail: (settings?.email || profile?.email || user.email || '').trim(),
|
||||
}
|
||||
|
||||
// Load per-day absence records for VAB + parental in the pay period.
|
||||
// Sick days never reach AGI (they go to Försäkringskassan separately).
|
||||
// The XML generator's Frånvarouppgift section consumes these per event.
|
||||
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
|
||||
.map(sre => sre.employee_id as string)
|
||||
.filter(Boolean)
|
||||
const absenceByEmployee = new Map<string, Array<{ date: string; type: 'vab' | 'parental'; hours: number }>>()
|
||||
if (employeeIds.length > 0) {
|
||||
const { data: absenceRows } = await supabase
|
||||
.from('salary_absence_days')
|
||||
.select('employee_id, absence_date, absence_type, hours')
|
||||
.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 ?? [])) {
|
||||
const list = absenceByEmployee.get(row.employee_id) ?? []
|
||||
list.push({
|
||||
date: row.absence_date as string,
|
||||
type: row.absence_type as 'vab' | 'parental',
|
||||
hours: Number(row.hours ?? 8),
|
||||
})
|
||||
absenceByEmployee.set(row.employee_id, list)
|
||||
}
|
||||
}
|
||||
|
||||
const employeeData: AGIEmployeeData[] = runEmployees.map(sre => {
|
||||
const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null
|
||||
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
|
||||
|
||||
// Sum benefits by type for AGI rutor 012-019
|
||||
const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car'])
|
||||
const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals'])
|
||||
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
|
||||
const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other'])
|
||||
|
||||
const absenceEvents = absenceByEmployee.get(sre.employee_id as string)
|
||||
|
||||
return {
|
||||
personnummer: emp?.personnummer || '',
|
||||
specificationNumber: emp?.specification_number || 0,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
avgifterBasis: sre.avgifter_basis,
|
||||
fSkattPayment: emp?.f_skatt_status === 'f_skatt' ? sre.gross_salary : undefined,
|
||||
benefitCar: benefitCar > 0 ? benefitCar : undefined,
|
||||
benefitHousing: benefitHousing > 0 ? benefitHousing : undefined,
|
||||
benefitMeals: benefitMeals > 0 ? benefitMeals : undefined,
|
||||
benefitOther: benefitOther > 0 ? benefitOther : undefined,
|
||||
sickDays: sre.sick_days > 0 ? sre.sick_days : undefined,
|
||||
vabDays: sre.vab_days > 0 ? sre.vab_days : undefined,
|
||||
parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined,
|
||||
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
|
||||
}
|
||||
const result = await generateAgiDeclaration({
|
||||
supabase,
|
||||
companyId,
|
||||
userId: user.id,
|
||||
userEmail: user.email ?? null,
|
||||
salaryRunId: id,
|
||||
log,
|
||||
requestId,
|
||||
})
|
||||
|
||||
// Build totals with avgifter breakdown by category (read from DB, not re-derived from rate)
|
||||
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
|
||||
for (const sre of runEmployees) {
|
||||
const dbCategory = sre.avgifter_category as string | null
|
||||
// Map DB category to AGI HU category; fall back to rate heuristic for legacy runs without stored category
|
||||
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[category as keyof typeof avgifterByCategory] || { basis: 0, amount: 0 }
|
||||
cat.basis += sre.avgifter_basis
|
||||
cat.amount += sre.avgifter_amount
|
||||
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(result.code, log, {
|
||||
requestId,
|
||||
details: result.details,
|
||||
status: result.status,
|
||||
})
|
||||
}
|
||||
|
||||
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
|
||||
(sum, cat) => sum + (cat?.amount ?? 0),
|
||||
0
|
||||
// OWASP V3.2 / V4 (HTTP response header injection prevention) — sanitise
|
||||
// header-interpolated values. orgNumber comes from company_settings
|
||||
// (user-editable) and period_* from the run's own columns, but defense
|
||||
// in depth requires we strip anything that could be construed as a
|
||||
// header-injection character before splicing into Content-Disposition.
|
||||
const safeOrg = result.orgNumber.replace(/[^0-9A-Za-z-]/g, '')
|
||||
const safePeriod = `${result.periodYear}${String(result.periodMonth).padStart(2, '0')}`.replace(
|
||||
/[^0-9]/g,
|
||||
'',
|
||||
)
|
||||
|
||||
// FK499 TotalSjuklonekostnad — sum of *paid* sjuklön (days 2–14) across all
|
||||
// employees. Day 1 is karens (unpaid); day 15+ is Försäkringskassan, so
|
||||
// neither counts as an employer sjuklön cost.
|
||||
//
|
||||
// Sjuklön cost = dailyRate × sjuklonRate × day-2-14 count.
|
||||
// The line item `amount` is the *net deduction* (lostPay − sjuklön), not
|
||||
// the cost — using its quantity field plus the employee's monthly salary
|
||||
// gives the correct sjuklön cost regardless of the line-item amount
|
||||
// convention.
|
||||
//
|
||||
// sjuklonRate is read from the run's calculation_params snapshot (taken at
|
||||
// calc time), so an operator override (e.g. for a CBA-specific rate) is
|
||||
// honored. Falls back to 0.80 (Sjuklönelagen default) for older runs that
|
||||
// don't have the snapshot.
|
||||
const calcParams = (run.calculation_params ?? {}) as { sjuklonRate?: number; sjuklon_rate?: number }
|
||||
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.80
|
||||
let totalSjuklonekostnad = 0
|
||||
for (const sre of runEmployees) {
|
||||
const monthly = (sre.employee as { monthly_salary?: number } | null)?.monthly_salary ?? 0
|
||||
if (!monthly) continue
|
||||
const dailyRate = monthly / 21
|
||||
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
|
||||
for (const li of lineItems) {
|
||||
if (li.item_type === 'sick_day2_14') {
|
||||
const days = (li.quantity as number) || 0
|
||||
totalSjuklonekostnad += dailyRate * sjuklonRate * days
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totals: AGITotals = {
|
||||
totalTax: run.total_tax,
|
||||
totalAvgifterBasis: runEmployees.reduce((s, e) => s + e.avgifter_basis, 0),
|
||||
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
|
||||
totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100,
|
||||
avgifterByCategory,
|
||||
}
|
||||
|
||||
// Check for existing AGI for correction flag
|
||||
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)
|
||||
.single()
|
||||
|
||||
const isCorrection = !!existingAgi
|
||||
|
||||
let xml: string
|
||||
try {
|
||||
xml = generateAGIXml(companyData, employeeData, totals, isCorrection)
|
||||
} catch (err) {
|
||||
if (err instanceof AGIIncompleteDataError) {
|
||||
return NextResponse.json({ error: err.message, missingFields: err.missingFields }, { status: 400 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
const individuppgifter = buildIndividuppgifterSnapshot(employeeData)
|
||||
|
||||
// Store AGI declaration (upsert for corrections per unique constraint).
|
||||
// In-place update: leave corrects_agi_id null — a record must not reference
|
||||
// itself as the declaration it corrects. When a true correction chain is
|
||||
// needed, create a new row pointing to the original instead.
|
||||
if (existingAgi) {
|
||||
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,
|
||||
total_avgifter: run.total_avgifter,
|
||||
employee_count: employeeData.length,
|
||||
is_correction: true,
|
||||
salary_run_id: run.id,
|
||||
})
|
||||
.eq('id', existingAgi.id)
|
||||
} else {
|
||||
await supabase
|
||||
.from('agi_declarations')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
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,
|
||||
total_avgifter: run.total_avgifter,
|
||||
employee_count: employeeData.length,
|
||||
})
|
||||
}
|
||||
|
||||
// Update salary run
|
||||
await supabase
|
||||
.from('salary_runs')
|
||||
.update({ agi_generated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'agi.generated',
|
||||
payload: {
|
||||
agiId: existingAgi?.id || 'new',
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-complete arbetsgivardeklaration deadline for this period
|
||||
// Per Skatteförfarandelagen: AGI generation satisfies the filing obligation
|
||||
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: user.id,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.eq('type', 'arbetsgivardeklaration')
|
||||
.eq('period', period)
|
||||
.eq('status', 'pending')
|
||||
|
||||
// Return as downloadable XML
|
||||
return new Response(xml, {
|
||||
return new Response(result.xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="AGI_${company.org_number}_${run.period_year}${String(run.period_month).padStart(2, '0')}.xml"`,
|
||||
'Content-Disposition': `attachment; filename="AGI_${safeOrg}_${safePeriod}.xml"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,557 +1,49 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { calculateSalary } from '@/lib/salary/calculation-engine'
|
||||
import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config'
|
||||
import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
|
||||
import { loadAndDeriveAbsence } from '@/lib/salary/derive-absence-line-items'
|
||||
import { getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import { runSalaryCalculation } from '@/lib/salary/run-calculation'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { SalaryLineItemType } from '@/types'
|
||||
|
||||
const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
|
||||
'sick_karens',
|
||||
'sick_day2_14',
|
||||
'sick_day15_plus',
|
||||
'vab',
|
||||
'parental_leave',
|
||||
]
|
||||
|
||||
const BENEFIT_TYPE_TO_LINE_ITEM: Record<string, SalaryLineItemType> = {
|
||||
bike: 'benefit_bike',
|
||||
car: 'benefit_car',
|
||||
meals: 'benefit_meals',
|
||||
housing: 'benefit_housing',
|
||||
wellness: 'benefit_wellness',
|
||||
other: 'benefit_other',
|
||||
}
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/salary/runs/{id}/calculate
|
||||
*
|
||||
* Thin wrapper over `runSalaryCalculation()` from `lib/salary/run-calculation.ts`.
|
||||
* The orchestration was extracted in Phase 5 PR-2 so the v1 public route
|
||||
* (`POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate`) can call
|
||||
* the same code. This route's responsibility is now: auth → invoke helper →
|
||||
* convert the discriminated result into the dashboard's expected envelope
|
||||
* (`{ data, warnings? }` on success; structured-error envelope on failure).
|
||||
*
|
||||
* Status transitions stay where they were: this route does NOT advance
|
||||
* `salary_runs.status`. The dashboard's UX is calculate → review (explicit
|
||||
* `/review` verb) → approve. The v1 collapses calculate+review into a
|
||||
* single verb but applies the status flip at the route layer, not here.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'salary_run.calculate',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ salaryRunId: id })
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return errorResponseFromCode('SALARY_RUN_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
if (run.status !== 'draft') {
|
||||
return errorResponseFromCode('SALARY_RUN_CALCULATE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { currentStatus: run.status, reason: 'not_draft' },
|
||||
})
|
||||
}
|
||||
|
||||
const paymentYear = parseInt(run.payment_date.split('-')[0])
|
||||
|
||||
// Load config
|
||||
const config = await loadPayrollConfig(supabase, paymentYear)
|
||||
|
||||
// Load all employees in this run
|
||||
const { data: runEmployees, error: empError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(*), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
|
||||
if (empError || !runEmployees || runEmployees.length === 0) {
|
||||
return errorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Pre-calculation validation — ensure employees have required data
|
||||
const validationErrors: string[] = []
|
||||
for (const sre of runEmployees) {
|
||||
const emp = sre.employee
|
||||
if (!emp) continue
|
||||
const name = `${emp.first_name} ${emp.last_name}`
|
||||
|
||||
if (emp.salary_type === 'monthly' && (!emp.monthly_salary || emp.monthly_salary <= 0)) {
|
||||
validationErrors.push(`${name}: Månadslön saknas eller är 0`)
|
||||
}
|
||||
if (emp.salary_type === 'hourly' && (!emp.hourly_rate || emp.hourly_rate <= 0)) {
|
||||
validationErrors.push(`${name}: Timlön saknas eller är 0`)
|
||||
}
|
||||
if (emp.f_skatt_status === 'a_skatt' && !emp.is_sidoinkomst && !emp.tax_table_number) {
|
||||
validationErrors.push(`${name}: Skattetabell saknas (krävs för A-skatt)`)
|
||||
}
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
return errorResponseFromCode('VALIDATION_ERROR', opLog, {
|
||||
requestId,
|
||||
details: { issues: validationErrors, reason: 'employee_data_incomplete' },
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch tax table rates from Skatteverket API for all needed tables/columns
|
||||
const tableNumbers = [...new Set(runEmployees.filter(e => e.employee?.tax_table_number).map(e => e.employee.tax_table_number as number))]
|
||||
const columns = [...new Set(runEmployees.filter(e => e.employee?.tax_column).map(e => e.employee.tax_column as number))]
|
||||
let taxRates: Awaited<ReturnType<typeof fetchAllTaxTableRatesForRun>>['rates'] = []
|
||||
let taxTableSource: Awaited<ReturnType<typeof fetchAllTaxTableRatesForRun>>['source'] = 'api'
|
||||
if (tableNumbers.length > 0) {
|
||||
try {
|
||||
const result = await fetchAllTaxTableRatesForRun(
|
||||
paymentYear,
|
||||
tableNumbers,
|
||||
columns.length > 0 ? columns : [1]
|
||||
)
|
||||
taxRates = result.rates
|
||||
taxTableSource = result.source
|
||||
} catch (err) {
|
||||
if (err instanceof TaxTableUnavailableError) {
|
||||
return errorResponseFromCode('SALARY_RUN_TAX_TABLE_MISSING', opLog, {
|
||||
requestId,
|
||||
details: { reason: err.message, paymentYear, tableNumbers },
|
||||
status: 503,
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
let totalGross = 0
|
||||
let totalTax = 0
|
||||
let totalNet = 0
|
||||
let totalAvgifter = 0
|
||||
let totalVacationAccrual = 0
|
||||
let totalEmployerCost = 0
|
||||
|
||||
// Load YTD data from prior booked salary runs this year (filters pushed to DB)
|
||||
const { data: priorRuns } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('employee_id, gross_salary, tax_withheld, net_salary, salary_run:salary_runs!inner(period_year, period_month, status)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('salary_run.period_year', run.period_year)
|
||||
.eq('salary_run.status', 'booked')
|
||||
.lt('salary_run.period_month', run.period_month)
|
||||
|
||||
const ytdByEmployee = new Map<string, { gross: number; tax: number; net: number }>()
|
||||
for (const prior of (priorRuns || [])) {
|
||||
const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 }
|
||||
current.gross += prior.gross_salary
|
||||
current.tax += prior.tax_withheld
|
||||
current.net += prior.net_salary
|
||||
ytdByEmployee.set(prior.employee_id, current)
|
||||
}
|
||||
|
||||
// Pay period bounds — used to load per-day absence records.
|
||||
const periodYear = run.period_year as number
|
||||
const periodMonth = run.period_month as number
|
||||
const periodStart = `${periodYear}-${String(periodMonth).padStart(2, '0')}-01`
|
||||
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
|
||||
|
||||
// ── Derive absence line items from per-day records ─────────────────
|
||||
// Sjuklöneperiod boundaries, återinsjuknande, högriskskydd, day-15
|
||||
// FK transition all require dates — we can't compute them from
|
||||
// aggregated quantities. Replace any existing derived absence rows on
|
||||
// this sre with the freshly-computed ones, then merge into the
|
||||
// in-memory lineItems array passed to calculateSalary.
|
||||
const absenceResult = await loadAndDeriveAbsence({
|
||||
const result = await runSalaryCalculation({
|
||||
supabase,
|
||||
companyId: companyId!,
|
||||
employeeId: emp.id,
|
||||
monthlySalary: emp.monthly_salary || 0,
|
||||
payrollConfig: config,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
salaryRunId: id,
|
||||
log,
|
||||
requestId,
|
||||
})
|
||||
|
||||
// ── Derive worked hours from per-day records (hourly employees) ────
|
||||
// For timanställda the calendar entries in salary_worked_days are the
|
||||
// authoritative source. Sum the period and pass to the calculator. If
|
||||
// no rows exist (legacy run, or hourly employee added without using the
|
||||
// calendar), fall back to the snapshot column on salary_run_employees.
|
||||
let derivedHoursWorked: number | null = null
|
||||
if (emp.salary_type === 'hourly') {
|
||||
const { data: workedDays, error: workedError } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.select('hours')
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', emp.id)
|
||||
.gte('work_date', periodStart)
|
||||
.lte('work_date', periodEnd)
|
||||
if (workedError) {
|
||||
return errorResponse(workedError, opLog, { requestId })
|
||||
}
|
||||
derivedHoursWorked = (workedDays ?? []).reduce(
|
||||
(sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100,
|
||||
0,
|
||||
)
|
||||
opLog.info('Derived hours_worked from calendar', {
|
||||
employeeId: emp.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
rowCount: workedDays?.length ?? 0,
|
||||
derivedHoursWorked,
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(result.code, log, {
|
||||
requestId,
|
||||
details: result.details,
|
||||
status: result.status,
|
||||
})
|
||||
|
||||
// Refresh the hourly_salary line item so the displayed Lönerader table
|
||||
// matches what the engine actually calculated. Without this, the
|
||||
// placeholder created at employee-add time keeps showing 0 kr even
|
||||
// after the user fills the calendar.
|
||||
if (derivedHoursWorked > 0 && (emp.hourly_rate || 0) > 0) {
|
||||
const baseAmount = Math.round((emp.hourly_rate as number) * derivedHoursWorked * 100) / 100
|
||||
// Delete any existing hourly_salary rows for this sre, then insert a
|
||||
// single fresh one. Avoids the "did the row already exist?" branch.
|
||||
await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.eq('item_type', 'hourly_salary')
|
||||
await supabase.from('salary_line_items').insert({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: 'hourly_salary',
|
||||
description: 'Timlön',
|
||||
quantity: derivedHoursWorked,
|
||||
amount: baseAmount,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount('hourly_salary'),
|
||||
sort_order: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.in('item_type', DERIVED_ABSENCE_TYPES)
|
||||
if (delAbsErr) {
|
||||
return errorResponse(delAbsErr, opLog, { requestId })
|
||||
}
|
||||
|
||||
// ── Derive benefit line items from employee_benefits ──
|
||||
// Active benefits = is_active AND payment_date ∈ [valid_from, valid_to]
|
||||
const { data: activeBenefits, error: benefitsErr } = await supabase
|
||||
.from('employee_benefits')
|
||||
.select('id, benefit_type, description, monthly_value')
|
||||
.eq('employee_id', emp.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.lte('valid_from', run.payment_date)
|
||||
.or(`valid_to.is.null,valid_to.gte.${run.payment_date}`)
|
||||
if (benefitsErr) {
|
||||
return errorResponse(benefitsErr, opLog, { requestId })
|
||||
}
|
||||
|
||||
// Replace prior auto-generated benefit rows for this sre.
|
||||
const { error: delBenefitErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.not('source_benefit_id', 'is', null)
|
||||
if (delBenefitErr) {
|
||||
return errorResponse(delBenefitErr, opLog, { requestId })
|
||||
}
|
||||
|
||||
const derivedBenefitRows = (activeBenefits ?? [])
|
||||
.filter(b => b.monthly_value > 0)
|
||||
.map((b, idx) => {
|
||||
const itemType = BENEFIT_TYPE_TO_LINE_ITEM[b.benefit_type] ?? 'benefit_other'
|
||||
return {
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: itemType,
|
||||
description: b.description,
|
||||
quantity: 1,
|
||||
amount: Math.round(b.monthly_value * 100) / 100,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: false,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount(itemType, emp.employment_type),
|
||||
sort_order: 200 + idx,
|
||||
source_benefit_id: b.id,
|
||||
}
|
||||
})
|
||||
|
||||
if (derivedBenefitRows.length > 0) {
|
||||
const { error: insBenefitErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.insert(derivedBenefitRows)
|
||||
if (insBenefitErr) {
|
||||
return errorResponse(insBenefitErr, opLog, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
if (absenceResult.lineItems.length > 0) {
|
||||
const rows = absenceResult.lineItems.map((li, idx) => ({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: li.item_type,
|
||||
description: li.description,
|
||||
quantity: li.quantity,
|
||||
amount: Math.round(li.amount * 100) / 100,
|
||||
is_taxable: li.is_taxable,
|
||||
is_avgift_basis: li.is_avgift_basis,
|
||||
is_vacation_basis: li.is_vacation_basis,
|
||||
is_gross_deduction: li.is_gross_deduction,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount(li.item_type),
|
||||
sort_order: 100 + idx, // sort derived items after manual ones
|
||||
}))
|
||||
const { error: insAbsErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.insert(rows)
|
||||
if (insAbsErr) {
|
||||
return errorResponse(insAbsErr, opLog, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
// Build the merged in-memory line items: keep non-derived items from
|
||||
// the originally-loaded sre.line_items, then append the freshly-derived
|
||||
// absence and benefit items.
|
||||
const manualLineItems = (sre.line_items || [])
|
||||
.filter((li: Record<string, unknown>) => {
|
||||
if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false
|
||||
if (li.source_benefit_id) return false
|
||||
// Semesterersättning is derived by the engine on every calculate.
|
||||
if (li.item_type === 'semesterersattning') return false
|
||||
return true
|
||||
})
|
||||
.map((li: Record<string, unknown>) => ({
|
||||
itemType: li.item_type as SalaryLineItemType,
|
||||
amount: li.amount as number,
|
||||
isTaxable: li.is_taxable as boolean,
|
||||
isAvgiftBasis: li.is_avgift_basis as boolean,
|
||||
isVacationBasis: li.is_vacation_basis as boolean,
|
||||
isGrossDeduction: li.is_gross_deduction as boolean,
|
||||
isNetDeduction: li.is_net_deduction as boolean,
|
||||
}))
|
||||
const derivedLineItems = absenceResult.lineItems.map(li => ({
|
||||
itemType: li.item_type as SalaryLineItemType,
|
||||
amount: li.amount,
|
||||
isTaxable: li.is_taxable,
|
||||
isAvgiftBasis: li.is_avgift_basis,
|
||||
isVacationBasis: li.is_vacation_basis,
|
||||
isGrossDeduction: li.is_gross_deduction,
|
||||
isNetDeduction: false,
|
||||
}))
|
||||
const derivedBenefitLineItems = derivedBenefitRows.map(row => ({
|
||||
itemType: row.item_type as SalaryLineItemType,
|
||||
amount: row.amount,
|
||||
isTaxable: true,
|
||||
isAvgiftBasis: true,
|
||||
isVacationBasis: false,
|
||||
isGrossDeduction: false,
|
||||
isNetDeduction: false,
|
||||
}))
|
||||
const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems]
|
||||
|
||||
const result = calculateSalary(
|
||||
{
|
||||
employmentType: emp.employment_type,
|
||||
salaryType: emp.salary_type,
|
||||
monthlySalary: emp.monthly_salary || 0,
|
||||
hourlyRate: emp.hourly_rate || undefined,
|
||||
// Calendar-derived hours win when at least one row exists in the
|
||||
// period. The legacy snapshot column (sre.hours_worked) only kicks
|
||||
// in for runs predating the calendar feature.
|
||||
hoursWorked: derivedHoursWorked !== null && derivedHoursWorked > 0
|
||||
? derivedHoursWorked
|
||||
: sre.hours_worked || undefined,
|
||||
employmentDegree: emp.employment_degree,
|
||||
taxTableNumber: emp.tax_table_number,
|
||||
taxColumn: emp.tax_column || 1,
|
||||
isSidoinkomst: emp.is_sidoinkomst,
|
||||
jamkningPercentage: emp.jamkning_percentage,
|
||||
jamkningValidFrom: emp.jamkning_valid_from,
|
||||
jamkningValidTo: emp.jamkning_valid_to,
|
||||
fSkattStatus: emp.f_skatt_status,
|
||||
personnummer: emp.personnummer,
|
||||
paymentDate: run.payment_date,
|
||||
vacationRule: emp.vacation_rule,
|
||||
vacationDaysPerYear: emp.vacation_days_per_year,
|
||||
semestertillaggRate: emp.semestertillagg_rate,
|
||||
vaxaStodEligible: emp.vaxa_stod_eligible,
|
||||
vaxaStodStart: emp.vaxa_stod_start,
|
||||
vaxaStodEnd: emp.vaxa_stod_end,
|
||||
lineItems,
|
||||
},
|
||||
config,
|
||||
taxRates.map(r => ({
|
||||
tableYear: r.tableYear,
|
||||
tableNumber: r.tableNumber,
|
||||
columnNumber: r.columnNumber,
|
||||
incomeFrom: r.incomeFrom,
|
||||
incomeTo: r.incomeTo,
|
||||
taxAmount: r.taxAmount,
|
||||
}))
|
||||
)
|
||||
|
||||
// Aggregated absence counts derived from per-day records (above).
|
||||
// Vacation still comes from line items because it's user-entered, not
|
||||
// calendar-tracked yet.
|
||||
const sickDays = absenceResult.aggregated.sickDays
|
||||
const vabDays = absenceResult.aggregated.vabDays
|
||||
const parentalDays = absenceResult.aggregated.parentalDays
|
||||
const vacationDays = (sre.line_items || [])
|
||||
.filter((li: Record<string, unknown>) => li.item_type === 'vacation')
|
||||
.reduce((sum: number, li: Record<string, unknown>) => sum + ((li.quantity as number) || 0), 0)
|
||||
|
||||
// Update salary_run_employee with calculated results. If any individual
|
||||
// update fails we abort so run totals aren't written from partial data.
|
||||
// For hourly employees with calendar rows, also mirror the derived total
|
||||
// into hours_worked so downstream code (reports, storno via correct/route)
|
||||
// sees a consistent snapshot.
|
||||
const snapshotHoursWorked =
|
||||
derivedHoursWorked !== null && derivedHoursWorked > 0
|
||||
? derivedHoursWorked
|
||||
: sre.hours_worked
|
||||
const { error: empUpdateError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.update({
|
||||
hours_worked: snapshotHoursWorked,
|
||||
gross_salary: result.grossSalary,
|
||||
gross_deductions: result.grossDeductions,
|
||||
benefit_values: result.benefitValues,
|
||||
taxable_income: result.taxableIncome,
|
||||
tax_withheld: result.taxWithheld,
|
||||
net_deductions: result.netDeductions,
|
||||
net_salary: result.netSalary,
|
||||
avgifter_rate: result.avgifterRate,
|
||||
avgifter_amount: result.avgifterAmount,
|
||||
avgifter_basis: result.avgifterBasis,
|
||||
avgifter_category: result.avgifterCategory,
|
||||
vacation_accrual: result.vacationAccrual,
|
||||
vacation_accrual_avgifter: result.vacationAccrualAvgifter,
|
||||
tax_table_number: emp.tax_table_number,
|
||||
tax_column: emp.tax_column,
|
||||
tax_table_year: paymentYear,
|
||||
sick_days: sickDays,
|
||||
vab_days: vabDays,
|
||||
parental_days: parentalDays,
|
||||
vacation_days_taken: vacationDays,
|
||||
calculation_breakdown: { steps: result.steps },
|
||||
ytd_gross: Math.round(((ytdByEmployee.get(sre.employee_id)?.gross || 0) + result.grossSalary) * 100) / 100,
|
||||
ytd_tax: Math.round(((ytdByEmployee.get(sre.employee_id)?.tax || 0) + result.taxWithheld) * 100) / 100,
|
||||
ytd_net: Math.round(((ytdByEmployee.get(sre.employee_id)?.net || 0) + result.netSalary) * 100) / 100,
|
||||
})
|
||||
.eq('id', sre.id)
|
||||
|
||||
if (empUpdateError) {
|
||||
return errorResponse(empUpdateError, opLog, { requestId })
|
||||
}
|
||||
|
||||
// Persist a derived 'semesterersattning' line item so it appears in the
|
||||
// lönerader UI, on the payslip, and books to BAS 7285 via the line-item
|
||||
// flow in salary-entries.ts. Replace any prior derived row first.
|
||||
const { error: delSemErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.eq('item_type', 'semesterersattning')
|
||||
if (delSemErr) {
|
||||
return errorResponse(delSemErr, opLog, { requestId })
|
||||
}
|
||||
if (result.vacationCompensation > 0) {
|
||||
const { error: insSemErr } = await supabase.from('salary_line_items').insert({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: 'semesterersattning',
|
||||
description: 'Semesterersättning',
|
||||
quantity: 1,
|
||||
amount: Math.round(result.vacationCompensation * 100) / 100,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
// Not vacation basis itself — would create circular accrual.
|
||||
is_vacation_basis: false,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount('semesterersattning', emp.employment_type),
|
||||
sort_order: 50,
|
||||
})
|
||||
if (insSemErr) {
|
||||
return errorResponse(insSemErr, opLog, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
totalGross += result.grossSalary
|
||||
totalTax += result.taxWithheld
|
||||
totalNet += result.netSalary
|
||||
totalAvgifter += result.avgifterAmount
|
||||
totalVacationAccrual += result.vacationAccrual
|
||||
totalEmployerCost += result.totalEmployerCost
|
||||
}
|
||||
|
||||
// Update run totals
|
||||
const { data: updatedRun, error: updateError } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
total_gross: Math.round(totalGross * 100) / 100,
|
||||
total_tax: Math.round(totalTax * 100) / 100,
|
||||
total_net: Math.round(totalNet * 100) / 100,
|
||||
total_avgifter: Math.round(totalAvgifter * 100) / 100,
|
||||
total_vacation_accrual: Math.round(totalVacationAccrual * 100) / 100,
|
||||
total_employer_cost: Math.round(totalEmployerCost * 100) / 100,
|
||||
calculation_params: serializePayrollConfig(config),
|
||||
})
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return errorResponse(updateError, opLog, { requestId })
|
||||
}
|
||||
|
||||
const warnings: string[] = []
|
||||
if (taxTableSource === 'fallback') {
|
||||
warnings.push(
|
||||
`Skatteverkets skattetabell-API är inte nåbart — beräkningen använder lokal reservdata för ${paymentYear}. Kontrollera att Skatteverket inte publicerat ändringar innan lönekörningen bokförs.`
|
||||
)
|
||||
} else if (taxTableSource === 'mixed') {
|
||||
warnings.push(
|
||||
`Skatteverkets skattetabell-API svarade bara delvis — vissa skattetabeller kommer från lokal reservdata för ${paymentYear}. Kontrollera att Skatteverket inte publicerat ändringar innan lönekörningen bokförs.`
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
return NextResponse.json({ data: result.run, warnings: result.warnings })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -309,6 +309,30 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
}
|
||||
|
||||
// Merged-state Växa-stöd check. UpdateEmployeeSchema enforces consistency
|
||||
// when both fields are present in the body, but a caller flipping
|
||||
// `vaxa_stod_eligible: true` ALONE without supplying `vaxa_stod_start`
|
||||
// can bypass schema-level validation if the existing row has no start.
|
||||
// The schema cannot see the existing row; the route can.
|
||||
const mergedVaxaEligible =
|
||||
'vaxa_stod_eligible' in updates
|
||||
? (updates.vaxa_stod_eligible as boolean)
|
||||
: ((existing as Record<string, unknown>).vaxa_stod_eligible as boolean)
|
||||
const mergedVaxaStart =
|
||||
'vaxa_stod_start' in updates
|
||||
? (updates.vaxa_stod_start as string | null)
|
||||
: ((existing as Record<string, unknown>).vaxa_stod_start as string | null)
|
||||
if (mergedVaxaEligible && !mergedVaxaStart) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'vaxa_stod_start',
|
||||
message:
|
||||
'Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat. Skicka även `vaxa_stod_start` i samma PATCH.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
// GDPR Art.5(1)(c): no-change PATCH still returns a write-shape, so
|
||||
// mask personnummer just like the POST + PATCH success path.
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* Integration tests for the v1 salary-run lifecycle verbs (Phase 5 PR-2).
|
||||
*
|
||||
* Covers :calculate, :approve, :mark-paid, :book, :generate-agi. Each suite
|
||||
* focuses on the verb's contract — auth/scope, state-machine enforcement,
|
||||
* strict-mode (engine throws abort before state flip), period-lock pre-
|
||||
* check, audit block on :book, AGI gate, etc. The underlying lib helpers
|
||||
* (`runSalaryCalculation`, `createSalaryRunEntries`, `generateAgiDeclaration`)
|
||||
* are stubbed via vi.mock so we exercise the route logic, not the engine.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`salary-run lifecycle tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
// The lifecycle verbs delegate to lib helpers; stub those so the tests
|
||||
// exercise route logic, not engine behavior.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runSalaryCalculation: vi.fn(),
|
||||
createSalaryRunEntries: vi.fn(),
|
||||
checkPeriodLock: vi.fn(),
|
||||
generateAgiDeclaration: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/salary/run-calculation', () => ({
|
||||
runSalaryCalculation: mocks.runSalaryCalculation,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/salary/salary-entries', () => ({
|
||||
createSalaryRunEntries: mocks.createSalaryRunEntries,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/v1/check-period-lock', () => ({
|
||||
checkPeriodLock: mocks.checkPeriodLock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/salary/agi/generate-declaration', () => ({
|
||||
generateAgiDeclaration: mocks.generateAgiDeclaration,
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as calculate } from '../calculate/route'
|
||||
import { POST as approve } from '../approve/route'
|
||||
import { POST as markPaid } from '../mark-paid/route'
|
||||
import { POST as book } from '../book/route'
|
||||
import { POST as generateAgi } from '../generate-agi/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
// The generate-agi route also hits supabase.auth.admin.getUserById; stub
|
||||
// that so the helper-mock path doesn't trip on auth.
|
||||
return {
|
||||
from: vi.fn((table: string) => buildChain(table)),
|
||||
auth: {
|
||||
admin: {
|
||||
getUserById: vi.fn().mockResolvedValue({ data: { user: { email: 'caller@test' } } }),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// :calculate
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /salary-runs/:id/calculate', () => {
|
||||
it('runs the helper and advances draft → review on success', async () => {
|
||||
const draftRun = { id: RUN_ID, status: 'draft', period_year: 2026, period_month: 5, payment_date: '2026-05-25' }
|
||||
const advancedRun = {
|
||||
id: RUN_ID, status: 'review',
|
||||
period_year: 2026, period_month: 5,
|
||||
total_gross: 105000, total_tax: 28500, total_net: 76500,
|
||||
total_avgifter: 32991, total_employer_cost: 137991,
|
||||
}
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: [
|
||||
{ data: draftRun, error: null }, // pre-flight read
|
||||
{ data: advancedRun, error: null }, // status flip
|
||||
],
|
||||
salary_run_employees: { data: [], error: null }, // for F-skatt scan
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
mocks.runSalaryCalculation.mockResolvedValue({
|
||||
ok: true,
|
||||
run: { id: RUN_ID, status: 'draft' },
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const res = await calculate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/calculate`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('review')
|
||||
expect(mocks.runSalaryCalculation).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('refuses to calculate a non-draft run (state-machine enforcement)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await calculate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/calculate`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_CALCULATE_NOT_DRAFT')
|
||||
expect(body.error.details.current_status).toBe('review')
|
||||
expect(mocks.runSalaryCalculation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('strict-mode: helper failure aborts before the status flip', async () => {
|
||||
const draftRun = { id: RUN_ID, status: 'draft', period_year: 2026, period_month: 5, payment_date: '2026-05-25' }
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: draftRun, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
mocks.runSalaryCalculation.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'SALARY_RUN_TAX_TABLE_MISSING',
|
||||
details: { reason: 'Skatteverket API unreachable' },
|
||||
status: 503,
|
||||
})
|
||||
|
||||
const res = await calculate(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/calculate`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(503)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_TAX_TABLE_MISSING')
|
||||
})
|
||||
|
||||
it('returns a dry-run preview without invoking the helper', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await calculate(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/calculate?dry_run=true`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(mocks.runSalaryCalculation).not.toHaveBeenCalled()
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.would_advance_status_to).toBe('review')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// :approve
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /salary-runs/:id/approve', () => {
|
||||
it('approves a run with valid bank details + calculation_breakdown', async () => {
|
||||
const validEmployee = {
|
||||
calculation_breakdown: { steps: [] },
|
||||
employee: {
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
clearing_number: '6000',
|
||||
bank_account_number: '12345678',
|
||||
email: 'anna@test',
|
||||
},
|
||||
}
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: [
|
||||
{ data: { id: RUN_ID, status: 'review' }, error: null },
|
||||
{ data: { id: RUN_ID, status: 'approved', approved_at: '2026-05-14T12:00:00Z', approved_by: USER_ID }, error: null },
|
||||
],
|
||||
salary_run_employees: { data: [validEmployee], error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await approve(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/approve`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('approved')
|
||||
})
|
||||
|
||||
it('returns SALARY_RUN_APPROVE_VALIDATION_FAILED for missing bank details', async () => {
|
||||
const noBankEmployee = {
|
||||
calculation_breakdown: { steps: [] },
|
||||
employee: {
|
||||
first_name: 'Bo',
|
||||
last_name: 'Berg',
|
||||
clearing_number: null,
|
||||
bank_account_number: null,
|
||||
email: 'bo@test',
|
||||
},
|
||||
}
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
|
||||
salary_run_employees: { data: [noBankEmployee], error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await approve(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/approve`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_APPROVE_VALIDATION_FAILED')
|
||||
expect(body.error.details.issues.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('refuses to approve a non-review run', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await approve(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/approve`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_APPROVE_NOT_REVIEW')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// :mark-paid
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /salary-runs/:id/mark-paid', () => {
|
||||
it('advances approved → paid', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: [
|
||||
{ data: { id: RUN_ID, status: 'approved' }, error: null },
|
||||
{ data: { id: RUN_ID, status: 'paid', paid_at: '2026-05-25T08:00:00Z' }, error: null },
|
||||
],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markPaid(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/mark-paid`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('paid')
|
||||
})
|
||||
|
||||
it('refuses to mark a non-approved run as paid', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markPaid(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/mark-paid`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_MARK_PAID_NOT_APPROVED')
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// :book (engine-touching, period-lock)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /salary-runs/:id/book', () => {
|
||||
const paidRun = {
|
||||
id: RUN_ID,
|
||||
status: 'paid',
|
||||
period_year: 2026,
|
||||
period_month: 5,
|
||||
payment_date: '2026-05-25',
|
||||
voucher_series: 'L',
|
||||
total_gross: 35000,
|
||||
total_tax: 9500,
|
||||
total_net: 25500,
|
||||
total_avgifter: 10997,
|
||||
total_vacation_accrual: 0,
|
||||
}
|
||||
|
||||
const employeeRow = {
|
||||
employee_id: 'emp_1',
|
||||
employee: { employment_type: 'employee' },
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 9500,
|
||||
net_salary: 25500,
|
||||
avgifter_amount: 10997,
|
||||
avgifter_rate: 0.3142,
|
||||
vacation_accrual: 0,
|
||||
vacation_accrual_avgifter: 0,
|
||||
line_items: [],
|
||||
}
|
||||
|
||||
it('books a paid run and surfaces the audit block', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: [
|
||||
{ data: paidRun, error: null }, // status precheck
|
||||
{
|
||||
data: {
|
||||
id: RUN_ID, status: 'booked',
|
||||
booked_at: '2026-05-26T09:15:00Z', booked_by: USER_ID,
|
||||
salary_entry_id: 'je_salary', avgifter_entry_id: 'je_avg',
|
||||
vacation_entry_id: null, pension_entry_id: null,
|
||||
},
|
||||
error: null,
|
||||
}, // status flip
|
||||
],
|
||||
salary_run_employees: { data: [employeeRow], error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
mocks.checkPeriodLock.mockResolvedValue({ locked: false })
|
||||
mocks.createSalaryRunEntries.mockResolvedValue({
|
||||
salaryEntry: { id: 'je_salary', voucher_number: 'L2026-0023' },
|
||||
avgifterEntry: { id: 'je_avg' },
|
||||
vacationEntry: null,
|
||||
pensionEntry: null,
|
||||
})
|
||||
|
||||
const res = await book(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('booked')
|
||||
expect(body.data.salary_entry_id).toBe('je_salary')
|
||||
expect(body.data.entry_ids).toEqual(['je_salary', 'je_avg'])
|
||||
expect(body.meta.audit.voucher_number).toBe('L2026-0023')
|
||||
expect(body.meta.audit.voucher_url).toContain('je_salary')
|
||||
})
|
||||
|
||||
it('returns PERIOD_LOCKED before invoking the engine when payment_date is locked', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: paidRun, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
mocks.checkPeriodLock.mockResolvedValue({
|
||||
locked: true,
|
||||
reason: 'company_lock_date_covers',
|
||||
})
|
||||
|
||||
const res = await book(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('PERIOD_LOCKED')
|
||||
expect(body.error.details.reason).toBe('company_lock_date_covers')
|
||||
expect(mocks.createSalaryRunEntries).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('strict-mode: engine throw aborts before any state mutation', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: paidRun, error: null },
|
||||
salary_run_employees: { data: [employeeRow], error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
mocks.checkPeriodLock.mockResolvedValue({ locked: false })
|
||||
mocks.createSalaryRunEntries.mockRejectedValue(new Error('Insufficient BAS account'))
|
||||
|
||||
const res = await book(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_BOOK_FAILED')
|
||||
})
|
||||
|
||||
it('refuses to book a non-paid run', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { ...paidRun, status: 'approved' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await book(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_BOOK_NOT_PAID')
|
||||
expect(mocks.checkPeriodLock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// :generate-agi
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /salary-runs/:id/generate-agi', () => {
|
||||
it('returns the XML embedded in the v1 envelope', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
mocks.generateAgiDeclaration.mockResolvedValue({
|
||||
ok: true,
|
||||
xml: '<?xml version="1.0"?><Skatteverket/>',
|
||||
agiDeclarationId: 'agi_a8f1',
|
||||
periodYear: 2026,
|
||||
periodMonth: 5,
|
||||
employeeCount: 3,
|
||||
isCorrection: false,
|
||||
totals: {
|
||||
totalTax: 28500,
|
||||
totalAvgifterBasis: 105000,
|
||||
totalAvgifterAmount: 32991,
|
||||
totalSjuklonekostnad: 0,
|
||||
avgifterByCategory: {},
|
||||
},
|
||||
orgNumber: '5566778899',
|
||||
})
|
||||
|
||||
const res = await generateAgi(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/generate-agi`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.xml).toContain('<Skatteverket')
|
||||
expect(body.data.is_correction).toBe(false)
|
||||
expect(body.data.xml_filename).toBe('AGI_5566778899_202605.xml')
|
||||
})
|
||||
|
||||
it('surfaces AGI_GENERATE_NOT_BOOKABLE when the run is in draft', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
mocks.generateAgiDeclaration.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'AGI_GENERATE_NOT_BOOKABLE',
|
||||
details: { current_status: 'draft' },
|
||||
})
|
||||
|
||||
const res = await generateAgi(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/generate-agi`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('AGI_GENERATE_NOT_BOOKABLE')
|
||||
expect(body.error.details.current_status).toBe('draft')
|
||||
})
|
||||
|
||||
it('surfaces AGI_INCOMPLETE_DATA with missing_fields when company contact info is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
mocks.generateAgiDeclaration.mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'AGI_INCOMPLETE_DATA',
|
||||
details: {
|
||||
missing_fields: ['contactPhone'],
|
||||
message: 'AGI requires a contact phone number on company_settings.',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await generateAgi(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/generate-agi`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('AGI_INCOMPLETE_DATA')
|
||||
expect(body.error.details.missing_fields).toContain('contactPhone')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/salary-runs/{id}/approve
|
||||
*
|
||||
* Mirrors the dashboard's `/approve` route: validates every employee on the
|
||||
* run has the data the bookkeeping engine + payment will need (bank details
|
||||
* for transfer, calculation_breakdown proving :calculate ran), then advances
|
||||
* status `review` → `approved` with an optimistic-lock UPDATE. Records the
|
||||
* approver in `approved_by` + `approved_at`. Emits `salary_run.approved`.
|
||||
*
|
||||
* No engine interaction. No period-lock check. The verifikation gets posted
|
||||
* later by `:book`.
|
||||
*
|
||||
* Strict-mode: validation errors return 400 with a structured list of every
|
||||
* problem found across every employee, not just the first. An agent fixing
|
||||
* issues in batch sees a complete picture rather than playing whack-a-mole.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const SalaryRunApproved = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.literal('approved'),
|
||||
approved_at: z.string(),
|
||||
approved_by: z.string().uuid().nullable(),
|
||||
warnings: z.array(z.string()),
|
||||
})
|
||||
|
||||
const APPROVE_RESPONSE_COLUMNS = 'id, status, approved_at, approved_by'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.approve',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/approve',
|
||||
summary: 'Approve a reviewed salary run.',
|
||||
description:
|
||||
'Advances a salary run from `review` to `approved` after validating every employee has the data required for the payment step (bank account + clearing number for the bank transfer) and the booking step (`calculation_breakdown` proves `:calculate` ran). Records the approving user + timestamp. Strict-mode: validation errors return a complete list rather than failing on the first one.',
|
||||
useWhen:
|
||||
'You have a salary run in `review` status and want to authorize it for payment. This is the human (or agent) signoff step before money moves; the verifikation is still pending and won\'t exist until `:book` runs.',
|
||||
doNotUseFor:
|
||||
'Posting journal entries (use `:book` after `:mark-paid`). Reverting an approval (the lifecycle has no `:unapprove` — call `:correct` once the run is booked if you need to undo).',
|
||||
pitfalls: [
|
||||
'Run must be in `review` — non-`review` runs return 400 SALARY_RUN_APPROVE_NOT_REVIEW.',
|
||||
'Every employee on the run needs a `clearing_number` + `bank_account_number`. Missing bank details return 400 SALARY_RUN_APPROVE_VALIDATION_FAILED with the per-employee list.',
|
||||
'Every employee on the run needs `calculation_breakdown` populated. If you skipped `:calculate` somehow, approve fails.',
|
||||
'Employees without email get a non-blocking warning (lönebesked can\'t be sent automatically).',
|
||||
'No period-lock check here — that lives on `:book` where the verifikation is posted. An agent can approve a run whose payment date falls in a now-locked period; `:book` will later refuse.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
id: 'run_a8f1…',
|
||||
status: 'approved',
|
||||
approved_at: '2026-05-14T12:00:00Z',
|
||||
approved_by: 'user_b73c…',
|
||||
warnings: ['Anna Andersson: E-post saknas — lönebesked kan inte skickas'],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
response: { success: SalaryRunApproved },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'salary-runs.approve',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const salaryRunId = idParse.data
|
||||
|
||||
const { data: existing, error: fetchErr } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', salaryRunId)
|
||||
.maybeSingle()
|
||||
if (fetchErr) {
|
||||
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!existing) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if ((existing as { status: string }).status !== 'review') {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_APPROVE_NOT_REVIEW', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { current_status: (existing as { status: string }).status },
|
||||
})
|
||||
}
|
||||
|
||||
// Validation: pull every employee on the run with the fields we need
|
||||
// to assess. We accumulate ALL errors so the caller fixes everything
|
||||
// in one pass.
|
||||
const { data: runEmployees, error: empErr } = await ctx.supabase
|
||||
.from('salary_run_employees')
|
||||
.select(
|
||||
'calculation_breakdown, employee:employees(first_name, last_name, clearing_number, bank_account_number, email)',
|
||||
)
|
||||
.eq('salary_run_id', salaryRunId)
|
||||
// Defense-in-depth: every query carries the company_id filter per
|
||||
// CLAUDE.md, even when salary_run_id already constrains to the
|
||||
// company via FK + RLS.
|
||||
.eq('company_id', ctx.companyId!)
|
||||
if (empErr) {
|
||||
return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const validationErrors: string[] = []
|
||||
const warnings: string[] = []
|
||||
// Supabase's generated types model nested FK joins as arrays even when
|
||||
// the FK is non-null one-to-one. Cast through `unknown` (matches the
|
||||
// pattern used by suppliers/customers in Phase 4) so the route stays
|
||||
// type-clean. The runtime shape is { employee: T | null } per the
|
||||
// single-FK join.
|
||||
for (const sre of ((runEmployees ?? []) as unknown) as Array<{
|
||||
calculation_breakdown: unknown
|
||||
employee: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
clearing_number: string | null
|
||||
bank_account_number: string | null
|
||||
email: string | null
|
||||
} | null
|
||||
}>) {
|
||||
const emp = sre.employee
|
||||
if (!emp) continue
|
||||
const name = `${emp.first_name} ${emp.last_name}`
|
||||
if (!emp.clearing_number || !emp.bank_account_number) {
|
||||
validationErrors.push(
|
||||
`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`,
|
||||
)
|
||||
}
|
||||
if (!sre.calculation_breakdown) {
|
||||
validationErrors.push(`${name}: Beräkning saknas — kör beräkning först`)
|
||||
}
|
||||
if (!emp.email) {
|
||||
warnings.push(`${name}: E-post saknas — lönebesked kan inte skickas`)
|
||||
}
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_APPROVE_VALIDATION_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { issues: validationErrors, warnings },
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{
|
||||
id: salaryRunId,
|
||||
would_advance_status_from: 'review',
|
||||
would_advance_status_to: 'approved',
|
||||
would_record_approver: ctx.userId,
|
||||
warnings,
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
// Optimistic-lock the UPDATE on status='review' so a concurrent caller
|
||||
// (or a replay racing this one) yields a clean 409 instead of
|
||||
// silently re-approving and re-emitting the salary_run.approved event.
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
status: 'approved',
|
||||
approved_by: ctx.userId,
|
||||
approved_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', salaryRunId)
|
||||
.eq('status', 'review')
|
||||
.select(APPROVE_RESPONSE_COLUMNS)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!data) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_APPROVE_NOT_REVIEW', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'race' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'salary_run.approved',
|
||||
payload: {
|
||||
salaryRunId,
|
||||
approvedBy: ctx.userId,
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.log.warn('salary_run.approved emit failed', err as Error)
|
||||
}
|
||||
|
||||
return ok(
|
||||
{ ...(data as Record<string, unknown>), warnings },
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/salary-runs/{id}/book
|
||||
*
|
||||
* The engine-touching verb. Mirrors the dashboard's `/book` route: loads the
|
||||
* run + employees + line items, calls `createSalaryRunEntries()` (which posts
|
||||
* 2-4 verifikationer via the bookkeeping engine), then optimistic-lock
|
||||
* UPDATEs status `paid` → `booked` with the journal entry foreign keys.
|
||||
*
|
||||
* BFL 5 kap + 6 §§: the verifikation must reflect the actual cash movement
|
||||
* (payment_date). createSalaryRunEntries assigns voucher numbers atomically
|
||||
* via the `commit_journal_entry` RPC; immutability triggers prevent any
|
||||
* later edit.
|
||||
*
|
||||
* Strict-mode v1: an engine throw aborts BEFORE the salary_runs status
|
||||
* mutation. There is no partial-state recovery banner; the caller sees a
|
||||
* clean error and the run remains in `paid` so they can fix the underlying
|
||||
* cause (e.g. unlock the period) and retry.
|
||||
*
|
||||
* Period-lock pre-check: we check `payment_date` against the company's lock
|
||||
* date and fiscal period status BEFORE invoking the engine, so the response
|
||||
* is a structured PERIOD_LOCKED rather than a generic engine error. The DB
|
||||
* trigger remains authoritative — this is ergonomics, not security.
|
||||
*
|
||||
* Audit block: the success response includes the salary verifikation's
|
||||
* voucher number + the entry IDs of all 2-4 posted entries, so an agent
|
||||
* can verify the audit trail in one round-trip.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const SalaryRunBooked = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.literal('booked'),
|
||||
booked_at: z.string(),
|
||||
booked_by: z.string().uuid().nullable(),
|
||||
salary_entry_id: z.string().uuid(),
|
||||
avgifter_entry_id: z.string().uuid(),
|
||||
vacation_entry_id: z.string().uuid().nullable(),
|
||||
pension_entry_id: z.string().uuid().nullable(),
|
||||
entry_ids: z.array(z.string().uuid()),
|
||||
})
|
||||
|
||||
const BOOK_RESPONSE_COLUMNS =
|
||||
'id, status, booked_at, booked_by, salary_entry_id, avgifter_entry_id, vacation_entry_id, pension_entry_id'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.book',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/book',
|
||||
summary: 'Post the verifikationer for a paid salary run.',
|
||||
description:
|
||||
'Creates 2–4 journal entries (1: salary brutto/tax/net; 2: arbetsgivaravgifter; 3 if applicable: semesterlöneskuld accrual; 4 if applicable: pension + SLP from löneväxling), then advances status `paid` → `booked` with all the entry IDs recorded on the salary_runs row. Strict-mode: any engine failure aborts BEFORE the status flip — the run stays in `paid` so the caller can fix the cause (locked period, missing BAS account, etc.) and retry.',
|
||||
useWhen:
|
||||
'You\'ve marked a salary run as paid and want to post the BFL-required verifikationer. This is the final lifecycle verb before AGI generation; after :book, the run can no longer be edited and corrections must use the (forthcoming) `:correct` verb.',
|
||||
doNotUseFor:
|
||||
'Posting salary entries outside the salary-run lifecycle (use POST /journal-entries directly). Re-booking an already-booked run (returns 400 SALARY_RUN_BOOK_NOT_PAID).',
|
||||
pitfalls: [
|
||||
'Run must be in `paid` — non-`paid` runs return 400 SALARY_RUN_BOOK_NOT_PAID.',
|
||||
'payment_date must fall in an open fiscal period — locked period returns 400 PERIOD_LOCKED with `fiscal_period_id` and a hint of what unlock action is needed.',
|
||||
'BFL 5 kap immutability: once `:book` succeeds the verifikationer cannot be edited or deleted. Corrections require `:correct` (Phase 5 PR-3) which does a storno-then-rebook.',
|
||||
'The salary verifikation is the primary one; its voucher_number appears in the response audit block. The avgifter, vacation, and pension entries get separate voucher numbers (returned as `entry_ids`).',
|
||||
'Strict-mode: if the engine fails partway, the salary_runs row stays in `paid`. There is no "partial booking" — the engine either commits all entries or the entire booking fails.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
id: 'run_a8f1…',
|
||||
status: 'booked',
|
||||
booked_at: '2026-05-26T09:15:00Z',
|
||||
booked_by: 'user_b73c…',
|
||||
salary_entry_id: 'je_salary…',
|
||||
avgifter_entry_id: 'je_avg…',
|
||||
vacation_entry_id: 'je_vac…',
|
||||
pension_entry_id: null,
|
||||
entry_ids: ['je_salary…', 'je_avg…', 'je_vac…'],
|
||||
},
|
||||
meta: {
|
||||
request_id: 'req_…',
|
||||
api_version: '2026-05-12',
|
||||
audit: {
|
||||
voucher_number: 'L2026-0023',
|
||||
voucher_url: '/api/v1/companies/.../journal-entries/je_salary…',
|
||||
immutable_at: '2026-05-26T09:15:00Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'high',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
response: { success: SalaryRunBooked },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'salary-runs.book',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const salaryRunId = idParse.data
|
||||
|
||||
// 1. Status precheck.
|
||||
const { data: run, error: fetchErr } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', salaryRunId)
|
||||
.maybeSingle()
|
||||
if (fetchErr) {
|
||||
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!run) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if ((run as { status: string }).status !== 'paid') {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_BOOK_NOT_PAID', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { current_status: (run as { status: string }).status },
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Period-lock pre-check. The DB trigger remains authoritative; this
|
||||
// is so an agent gets a structured PERIOD_LOCKED response with
|
||||
// fiscal_period_id rather than a generic engine error.
|
||||
const paymentDate = (run as { payment_date: string }).payment_date
|
||||
const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, paymentDate)
|
||||
if (lockVerdict.locked) {
|
||||
return v1ErrorResponseFromCode('PERIOD_LOCKED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
reason: lockVerdict.reason,
|
||||
fiscal_period_id: lockVerdict.fiscal_period_id,
|
||||
payment_date: paymentDate,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Load run + employees + line items for the engine.
|
||||
const { data: employees, error: empErr } = await ctx.supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(employment_type), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', salaryRunId)
|
||||
if (empErr) {
|
||||
return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!employees || employees.length === 0) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// Without invoking the engine we can't get real voucher numbers, but
|
||||
// we CAN preview the would-be state transition + the expected entry
|
||||
// shape (which entries will exist based on totals). Agents can use
|
||||
// this to detect missing employees / wrong totals before paying for
|
||||
// the real call.
|
||||
const totalVacation = (employees as Array<{ vacation_accrual: number }>).reduce(
|
||||
(sum, e) => sum + e.vacation_accrual,
|
||||
0,
|
||||
)
|
||||
const totalVacationAvgifter = (employees as Array<{ vacation_accrual_avgifter: number }>).reduce(
|
||||
(sum, e) => sum + e.vacation_accrual_avgifter,
|
||||
0,
|
||||
)
|
||||
return dryRunPreview(
|
||||
{
|
||||
id: salaryRunId,
|
||||
would_advance_status_from: 'paid',
|
||||
would_advance_status_to: 'booked',
|
||||
would_post_entries: [
|
||||
'salary (gross + tax withholding + net payment)',
|
||||
'arbetsgivaravgifter',
|
||||
...(totalVacation > 0 || totalVacationAvgifter > 0 ? ['vacation accrual'] : []),
|
||||
// Pension cannot be detected without the engine — we'd need
|
||||
// to inspect line_items for löneväxling. Omitted from preview.
|
||||
],
|
||||
note: 'A live call posts 2-4 verifikationer atomically via createSalaryRunEntries. Voucher numbers are assigned at commit time.',
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Engine call. Strict-mode: any throw aborts before status flip.
|
||||
type EmpRow = {
|
||||
employee_id: string
|
||||
employee: { employment_type: string } | null
|
||||
gross_salary: number
|
||||
tax_withheld: number
|
||||
net_salary: number
|
||||
avgifter_amount: number
|
||||
avgifter_rate: number
|
||||
vacation_accrual: number
|
||||
vacation_accrual_avgifter: number
|
||||
line_items: Array<{
|
||||
item_type: string
|
||||
amount: number
|
||||
account_number: string | null
|
||||
is_net_deduction: boolean
|
||||
is_gross_deduction: boolean
|
||||
}> | null
|
||||
}
|
||||
let salaryEntry: { id: string; voucher_number: string }
|
||||
let avgifterEntry: { id: string }
|
||||
let vacationEntry: { id: string } | null
|
||||
let pensionEntry: { id: string } | null
|
||||
try {
|
||||
const result = await createSalaryRunEntries(ctx.supabase, ctx.companyId!, ctx.userId, {
|
||||
id: (run as { id: string }).id,
|
||||
period_year: (run as { period_year: number }).period_year,
|
||||
period_month: (run as { period_month: number }).period_month,
|
||||
payment_date: paymentDate,
|
||||
voucher_series: (run as { voucher_series: string }).voucher_series,
|
||||
total_gross: (run as { total_gross: number }).total_gross,
|
||||
total_tax: (run as { total_tax: number }).total_tax,
|
||||
total_net: (run as { total_net: number }).total_net,
|
||||
total_avgifter: (run as { total_avgifter: number }).total_avgifter,
|
||||
total_vacation_accrual: (run as { total_vacation_accrual: number }).total_vacation_accrual,
|
||||
employees: (employees as EmpRow[]).map((sre) => ({
|
||||
employee_id: sre.employee_id,
|
||||
employment_type: sre.employee?.employment_type || 'employee',
|
||||
gross_salary: sre.gross_salary,
|
||||
tax_withheld: sre.tax_withheld,
|
||||
net_salary: sre.net_salary,
|
||||
avgifter_amount: sre.avgifter_amount,
|
||||
avgifter_rate: sre.avgifter_rate,
|
||||
vacation_accrual: sre.vacation_accrual,
|
||||
vacation_accrual_avgifter: sre.vacation_accrual_avgifter,
|
||||
line_items: (sre.line_items || []).map((li) => ({
|
||||
item_type: li.item_type,
|
||||
amount: li.amount,
|
||||
account_number: li.account_number,
|
||||
is_net_deduction: li.is_net_deduction,
|
||||
is_gross_deduction: li.is_gross_deduction,
|
||||
})),
|
||||
})),
|
||||
})
|
||||
// Narrow to just the fields the route consumes — id + voucher_number
|
||||
// for the primary salary entry, id for the others. The full
|
||||
// JournalEntry shape is broader than what the audit block needs.
|
||||
salaryEntry = result.salaryEntry as unknown as { id: string; voucher_number: string }
|
||||
avgifterEntry = result.avgifterEntry
|
||||
vacationEntry = result.vacationEntry
|
||||
pensionEntry = result.pensionEntry
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) {
|
||||
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
ctx.log.error('salary booking failed', err as Error, {
|
||||
salaryRunId,
|
||||
companyId: ctx.companyId,
|
||||
userId: ctx.userId,
|
||||
})
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_BOOK_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Optimistic-lock the status flip on status='paid'. Concurrent calls
|
||||
// would have re-posted JEs (a real bug — we'd have orphans), but
|
||||
// the engine's atomicity makes that a no-op race that just won't
|
||||
// commit the second status flip.
|
||||
const entryIds = [salaryEntry.id, avgifterEntry.id]
|
||||
const updates: Record<string, unknown> = {
|
||||
status: 'booked',
|
||||
salary_entry_id: salaryEntry.id,
|
||||
avgifter_entry_id: avgifterEntry.id,
|
||||
booked_at: new Date().toISOString(),
|
||||
booked_by: ctx.userId,
|
||||
}
|
||||
if (vacationEntry) {
|
||||
updates.vacation_entry_id = vacationEntry.id
|
||||
entryIds.push(vacationEntry.id)
|
||||
}
|
||||
if (pensionEntry) {
|
||||
updates.pension_entry_id = pensionEntry.id
|
||||
entryIds.push(pensionEntry.id)
|
||||
}
|
||||
|
||||
const { data: bookedRun, error: updateError } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.update(updates)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', salaryRunId)
|
||||
.eq('status', 'paid')
|
||||
.select(BOOK_RESPONSE_COLUMNS)
|
||||
.maybeSingle()
|
||||
|
||||
if (updateError) {
|
||||
// The engine already committed; the row update failed. This is a
|
||||
// partial-state we cannot recover automatically. Surface loudly so
|
||||
// an operator notices and runs a manual reconciliation (the
|
||||
// verifikationer exist and have voucher numbers; the salary_runs
|
||||
// row just doesn't point at them yet).
|
||||
ctx.log.error('salary_runs status flip failed after engine commit', updateError as Error, {
|
||||
salaryRunId,
|
||||
companyId: ctx.companyId,
|
||||
entryIds,
|
||||
})
|
||||
return v1ErrorResponse(updateError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!bookedRun) {
|
||||
// Race: the row's status changed between fetch and update. The
|
||||
// engine has committed; we cannot un-commit. Log loudly.
|
||||
ctx.log.error('salary_runs row missing after engine commit', new Error('race'), {
|
||||
salaryRunId,
|
||||
companyId: ctx.companyId,
|
||||
entryIds,
|
||||
})
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_BOOK_FAILED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'row missing after engine commit', entry_ids: entryIds },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'salary_run.booked',
|
||||
payload: {
|
||||
salaryRunId,
|
||||
entryIds,
|
||||
userId: ctx.userId,
|
||||
companyId: ctx.companyId!,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
ctx.log.warn('salary_run.booked emit failed', err as Error)
|
||||
}
|
||||
|
||||
const bookedAt = (bookedRun as { booked_at: string }).booked_at
|
||||
|
||||
return ok(
|
||||
{ ...(bookedRun as Record<string, unknown>), entry_ids: entryIds },
|
||||
{
|
||||
requestId: ctx.requestId,
|
||||
audit: {
|
||||
voucher_number: salaryEntry.voucher_number,
|
||||
voucher_url: `/api/v1/companies/${ctx.companyId}/journal-entries/${salaryEntry.id}`,
|
||||
immutable_at: bookedAt,
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate
|
||||
*
|
||||
* v1's :calculate collapses the dashboard's two-step flow (internal /calculate
|
||||
* does the math but leaves status='draft'; internal /review explicitly
|
||||
* advances draft→review) into a single agent-friendly verb:
|
||||
*
|
||||
* 1. Invoke `runSalaryCalculation()` (the shared lib helper that the
|
||||
* dashboard's /calculate also uses).
|
||||
* 2. On success, optimistic-lock UPDATE draft → review with an explicit
|
||||
* `.eq('status', 'draft')` guard so concurrent calls yield a clean
|
||||
* 409, not a silent overwrite.
|
||||
* 3. Surface F-skatt 'not_verified' warnings (carried over from the
|
||||
* dashboard's /review F-skatt gate) alongside the calculation
|
||||
* warnings (tax table fallback, läkarintyg, FK day-15).
|
||||
*
|
||||
* Strict-mode: if any step inside `runSalaryCalculation` fails, the helper
|
||||
* returns a structured `{ ok: false }` and this route surfaces that without
|
||||
* touching status. The run stays in `draft` and the agent can retry.
|
||||
*
|
||||
* No engine interaction. No period-lock check (that lives on :book where
|
||||
* the JEs are actually posted).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { runSalaryCalculation } from '@/lib/salary/run-calculation'
|
||||
|
||||
const SalaryRunCalculated = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.literal('review'),
|
||||
period_year: z.number().int(),
|
||||
period_month: z.number().int(),
|
||||
total_gross: z.number(),
|
||||
total_tax: z.number(),
|
||||
total_net: z.number(),
|
||||
total_avgifter: z.number(),
|
||||
total_employer_cost: z.number(),
|
||||
warnings: z.array(z.string()),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.calculate',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/calculate',
|
||||
summary: 'Calculate a draft salary run and advance it to review.',
|
||||
description:
|
||||
'Runs the per-employee payroll calculation (tax withholding, employer contributions, vacation accrual) for every employee on a draft run, persists the line items + run totals + calculation_params snapshot, then promotes status from draft to review in a single atomic verb. Returns the updated run plus a `warnings` array surfacing non-blocking issues (Skatteverket tax-table fallback, läkarintyg day-8 transition, Försäkringskassan day-15 transition, F-skatt not-verified employees). Strict-mode: any failure (validation, tax-table unavailable, DB error) aborts before the status flip — the run stays in draft.',
|
||||
useWhen:
|
||||
'You have a draft salary run with employees added and want to compute the numbers + freeze them for approval. This is the first lifecycle verb after creating a run.',
|
||||
doNotUseFor:
|
||||
're-running a salary run already in review or later (only `draft` is accepted — call POST :correct in Phase 5 PR-3 once that ships to revise a booked run). Adding employees to the run (that surface is not yet on v1; use the dashboard).',
|
||||
pitfalls: [
|
||||
'Run must be in `draft` status — calculate on a non-draft run returns 400 SALARY_RUN_CALCULATE_NOT_DRAFT.',
|
||||
'Salary run must have at least one employee — empty runs return 400 SALARY_RUN_NO_EMPLOYEES.',
|
||||
'If Skatteverket\'s tax-table API is down and local fallback is missing the required table, calculate returns 503 SALARY_RUN_TAX_TABLE_MISSING. Retry is safe; the operation is idempotent at the helper level.',
|
||||
'F-skatt "not_verified" employees produce a non-blocking warning; an integrator should treat the warning as a hard signal that withholding will be wrong until F-skatt is verified.',
|
||||
'Warnings about tax-table fallback or läkarintyg / FK day-15 transitions are non-blocking; the run still advances to review. Surface them to a human reviewer before calling :approve.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
id: 'run_a8f1…',
|
||||
status: 'review',
|
||||
period_year: 2026,
|
||||
period_month: 5,
|
||||
total_gross: 105000,
|
||||
total_tax: 28500,
|
||||
total_net: 76500,
|
||||
total_avgifter: 32991,
|
||||
total_employer_cost: 137991,
|
||||
warnings: [
|
||||
'Läkarintyg krävs från och med dag 8: Anna Andersson. Kontrollera att läkarintyg finns innan lönekörningen godkänns.',
|
||||
],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
response: { success: SalaryRunCalculated },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'salary-runs.calculate',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const salaryRunId = idParse.data
|
||||
|
||||
// Pre-flight status check so a dry-run can preview the would-be outcome
|
||||
// without committing any of the calculation's many side effects.
|
||||
const { data: existing, error: fetchErr } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status, period_year, period_month, payment_date')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', salaryRunId)
|
||||
.maybeSingle()
|
||||
if (fetchErr) {
|
||||
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!existing) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if ((existing as { status: string }).status !== 'draft') {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_CALCULATE_NOT_DRAFT', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { current_status: (existing as { status: string }).status },
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// The helper is a heavy operation with hundreds of DB writes — we
|
||||
// cannot meaningfully "dry-run" it without committing real state.
|
||||
// Instead the dry-run surfaces what WOULD happen at the contract
|
||||
// level: status flip + a hint that the math will run. Agents can
|
||||
// use this to validate the preconditions before paying for the
|
||||
// expensive call.
|
||||
return dryRunPreview(
|
||||
{
|
||||
id: salaryRunId,
|
||||
would_advance_status_from: 'draft',
|
||||
would_advance_status_to: 'review',
|
||||
note: 'A live call will compute per-employee tax, avgifter, and vacation accrual, then persist line items + run totals. The actual figures are only available on a real (non-dry-run) call.',
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
const result = await runSalaryCalculation({
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId!,
|
||||
salaryRunId,
|
||||
log: ctx.log,
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
status: result.status,
|
||||
})
|
||||
}
|
||||
|
||||
// Advance status draft → review with an optimistic-lock guard. A
|
||||
// concurrent call (or replay) that beat us to it would have seen the
|
||||
// helper's status-check fail, but defense-in-depth here covers the
|
||||
// window between the helper's UPDATE on totals and our status flip.
|
||||
//
|
||||
// Also surface F-skatt 'not_verified' employees as additional warnings,
|
||||
// mirroring the dashboard's internal /review step.
|
||||
const { data: runEmployees } = await ctx.supabase
|
||||
.from('salary_run_employees')
|
||||
.select('employee:employees(first_name, last_name, f_skatt_status)')
|
||||
.eq('salary_run_id', salaryRunId)
|
||||
|
||||
const fskattWarnings: string[] = []
|
||||
// See approve/route.ts: Supabase types nested FK joins as arrays even
|
||||
// for single-FK relations; cast through `unknown` so the route stays
|
||||
// type-clean.
|
||||
for (const sre of ((runEmployees ?? []) as unknown) as Array<{
|
||||
employee: { first_name: string; last_name: string; f_skatt_status: string } | null
|
||||
}>) {
|
||||
const emp = sre.employee
|
||||
if (emp?.f_skatt_status === 'not_verified') {
|
||||
fskattWarnings.push(
|
||||
`${emp.first_name} ${emp.last_name}: F-skatt ej verifierad — 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { data: advancedRun, error: advanceErr } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.update({ status: 'review' })
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', salaryRunId)
|
||||
.eq('status', 'draft')
|
||||
.select('id, status, period_year, period_month, total_gross, total_tax, total_net, total_avgifter, total_employer_cost')
|
||||
.maybeSingle()
|
||||
|
||||
if (advanceErr) {
|
||||
return v1ErrorResponse(advanceErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!advancedRun) {
|
||||
// Race: status transitioned between the helper completing and the
|
||||
// status UPDATE. The most likely cause is a concurrent v1 call also
|
||||
// doing :calculate; both will have re-run the math, the second one
|
||||
// gets the stale-status 409.
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_CALCULATE_NOT_DRAFT', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'race' },
|
||||
})
|
||||
}
|
||||
|
||||
return ok(
|
||||
{
|
||||
...(advancedRun as Record<string, unknown>),
|
||||
warnings: [...result.warnings, ...fskattWarnings],
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/salary-runs/{id}/generate-agi
|
||||
*
|
||||
* Generates the Skatteverket AGI (arbetsgivardeklaration på individnivå) XML
|
||||
* for a salary run and persists the declaration. Calls the shared
|
||||
* `generateAgiDeclaration()` helper that the dashboard's GET /agi/xml also
|
||||
* uses, so the XML is byte-equivalent across surfaces.
|
||||
*
|
||||
* SYNC, not async. Despite the original plan annotating this as "(async)",
|
||||
* the actual work is sub-second: in-memory XML generation, one UPSERT into
|
||||
* agi_declarations, one UPDATE on salary_runs.agi_generated_at, one
|
||||
* optimistic-lock UPDATE on deadlines, one event emit. Using the
|
||||
* `operations` substrate here would be over-engineering — the response
|
||||
* comes back fast enough that polling would just be friction. Documented
|
||||
* as a deliberate plan deviation.
|
||||
*
|
||||
* Response shape: v1 JSON envelope with the XML embedded as a string field
|
||||
* (`xml`). Agents extract `data.xml` and save / forward as needed. This
|
||||
* preserves the v1 envelope's request_id + audit headers; an agent who
|
||||
* wants a download-flavored response can wrap the call themselves.
|
||||
*
|
||||
* Status gate: review|approved|paid|booked|corrected. Mirrors the
|
||||
* dashboard exactly. The Swedish-compliance review in PR-1 suggested
|
||||
* tightening to `approved+` because AGI from `review` could submit
|
||||
* incorrect figures to Skatteverket. That's a real concern but a design
|
||||
* decision orthogonal to this PR — narrowing the gate would diverge from
|
||||
* the dashboard's behavior. Tracked for a future tightening.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { generateAgiDeclaration } from '@/lib/salary/agi/generate-declaration'
|
||||
|
||||
const AvgifterCategory = z.object({ basis: z.number(), amount: z.number() })
|
||||
|
||||
const AgiTotals = z.object({
|
||||
totalTax: z.number(),
|
||||
totalAvgifterBasis: z.number(),
|
||||
totalAvgifterAmount: z.number(),
|
||||
totalSjuklonekostnad: z.number(),
|
||||
avgifterByCategory: z.record(z.string(), AvgifterCategory),
|
||||
})
|
||||
|
||||
const AgiGenerated = z.object({
|
||||
agi_declaration_id: z.string().uuid(),
|
||||
period_year: z.number().int(),
|
||||
period_month: z.number().int(),
|
||||
employee_count: z.number().int(),
|
||||
is_correction: z.boolean(),
|
||||
totals: AgiTotals,
|
||||
xml: z.string(),
|
||||
xml_filename: z.string(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.generate-agi',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/generate-agi',
|
||||
summary: 'Generate the Skatteverket AGI XML for a salary run.',
|
||||
description:
|
||||
'Generates the arbetsgivardeklaration-på-individnivå XML for the run (HU section + per-employee IU + Frånvarouppgift for VAB/parental), upserts the agi_declarations row (correction-aware), stamps salary_runs.agi_generated_at, emits `agi.generated`, and auto-completes the `arbetsgivardeklaration` deadline. Returns the XML as a string field in the v1 envelope — agents extract `data.xml` and forward to Skatteverket directly (Mina Sidor upload or via a connected extension).',
|
||||
useWhen:
|
||||
'You\'ve reviewed (or approved / paid / booked) a salary run and need to file AGI with Skatteverket. The Skatteverket filing deadline is the 12th of the following month (17th in Jan / Aug for companies ≤40 MSEK turnover).',
|
||||
doNotUseFor:
|
||||
'Submitting the AGI to Skatteverket — this endpoint only generates and persists the XML. Submission is a separate flow via the (optional) `skatteverket` extension.',
|
||||
pitfalls: [
|
||||
'Run status must be one of review, approved, paid, booked, corrected — `draft` returns 400 AGI_GENERATE_NOT_BOOKABLE.',
|
||||
'Generating AGI from a `review`-status run risks submitting figures that will change at `:approve`. The dashboard allows this for flexibility; agents should prefer `approved+` unless an early-warning workflow specifically wants the preview.',
|
||||
'Subsequent calls for the same period UPDATE the agi_declarations row (is_correction=true) and overwrite the XML. The FK570 specifikationsnummer stays consistent per employee — different number = new record per Skatteverket spec.',
|
||||
'AGI_INCOMPLETE_DATA returns 400 when company contact info is missing (org_number, contact name, phone, email). Fix via /settings/company before retrying.',
|
||||
'The XML content is räkenskapsinformation — BFL 7 kap retention applies. The agi_declarations row is never auto-deleted.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
agi_declaration_id: 'agi_a8f1…',
|
||||
period_year: 2026,
|
||||
period_month: 5,
|
||||
employee_count: 3,
|
||||
is_correction: false,
|
||||
totals: {
|
||||
totalTax: 28500,
|
||||
totalAvgifterBasis: 105000,
|
||||
totalAvgifterAmount: 32991,
|
||||
totalSjuklonekostnad: 0,
|
||||
avgifterByCategory: { standard: { basis: 105000, amount: 32991 } },
|
||||
},
|
||||
xml: '<?xml version="1.0" encoding="UTF-8"?><Skatteverket omrade="Arbetsgivardeklaration">…</Skatteverket>',
|
||||
xml_filename: 'AGI_5566778899_202605.xml',
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: AgiGenerated },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'salary-runs.generate-agi',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const salaryRunId = idParse.data
|
||||
|
||||
// The helper is essentially idempotent — calling twice produces the
|
||||
// same XML with the second call marked is_correction=true. We do NOT
|
||||
// expose a dry-run here because the only state the helper changes is
|
||||
// (a) the agi_declarations row (cheap, idempotent), (b) the
|
||||
// salary_runs.agi_generated_at timestamp (cheap, idempotent), and
|
||||
// (c) the deadlines auto-complete (idempotent if already completed).
|
||||
// Adding dry-run plumbing would more than double the code for no
|
||||
// agent-facing benefit.
|
||||
|
||||
// Pull the user's email so we can fall back to it when neither
|
||||
// company_settings.email nor profiles.email is present. The wrapper
|
||||
// doesn't carry the email — fetch via supabase auth admin.
|
||||
const { data: userRecord } = await ctx.supabase.auth.admin.getUserById(ctx.userId)
|
||||
const userEmail = userRecord?.user?.email ?? null
|
||||
|
||||
const result = await generateAgiDeclaration({
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId!,
|
||||
userId: ctx.userId,
|
||||
userEmail,
|
||||
salaryRunId,
|
||||
log: ctx.log,
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
status: result.status,
|
||||
})
|
||||
}
|
||||
|
||||
// OWASP V3.2 / V4 (HTTP response header injection prevention) +
|
||||
// path-traversal hardening — sanitise the filename. The orgNumber
|
||||
// comes from company_settings (user-editable) so it can in theory
|
||||
// carry stray characters. The period digits are server-generated
|
||||
// but we strip anything non-numeric defensively. The resulting
|
||||
// filename is safe to put in a future Content-Disposition header by
|
||||
// any caller forwarding the response, and prevents path-traversal
|
||||
// characters from reaching agent file-write code that uses
|
||||
// xml_filename verbatim.
|
||||
const safeOrg = result.orgNumber.replace(/[^0-9A-Za-z-]/g, '')
|
||||
const safePeriod = `${result.periodYear}${String(result.periodMonth).padStart(2, '0')}`.replace(
|
||||
/[^0-9]/g,
|
||||
'',
|
||||
)
|
||||
const xmlFilename = `AGI_${safeOrg}_${safePeriod}.xml`
|
||||
|
||||
return ok(
|
||||
{
|
||||
agi_declaration_id: result.agiDeclarationId,
|
||||
period_year: result.periodYear,
|
||||
period_month: result.periodMonth,
|
||||
employee_count: result.employeeCount,
|
||||
is_correction: result.isCorrection,
|
||||
totals: result.totals,
|
||||
xml: result.xml,
|
||||
xml_filename: xmlFilename,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/salary-runs/{id}/mark-paid
|
||||
*
|
||||
* Mirrors the dashboard's `/paid` route: advances status `approved` → `paid`
|
||||
* and stamps `paid_at`. No engine interaction, no event emission (the dashboard's
|
||||
* route is also silent; the verifikation event fires from `:book`).
|
||||
*
|
||||
* Idempotent at the call level — a replay with the same Idempotency-Key returns
|
||||
* the cached response. State-wise, calling :mark-paid on an already-paid run
|
||||
* returns 400 (status must be approved).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
|
||||
const SalaryRunPaid = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.literal('paid'),
|
||||
paid_at: z.string(),
|
||||
})
|
||||
|
||||
const MARK_PAID_COLUMNS = 'id, status, paid_at'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.mark-paid',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/mark-paid',
|
||||
summary: 'Mark an approved salary run as paid.',
|
||||
description:
|
||||
'Advances a salary run from `approved` to `paid` and stamps `paid_at`. This is the state-change verb after the bank transfer (or autogiro file) has been processed; it does NOT initiate payment, and does NOT post journal entries (use `:book` after this for that).',
|
||||
useWhen:
|
||||
'You\'ve confirmed the salary payment hit employee bank accounts and want to advance the run\'s lifecycle so `:book` can post the verifikation.',
|
||||
doNotUseFor:
|
||||
'Initiating the actual bank transfer (the v1 API does not yet expose payment-file generation; use the dashboard\'s payment-file endpoints). Posting journal entries (use `:book`). Reverting a paid run (no `:unpaid` exists — call `:correct` once booked if you need to undo).',
|
||||
pitfalls: [
|
||||
'Run must be in `approved` — non-`approved` runs return 400 SALARY_RUN_MARK_PAID_NOT_APPROVED.',
|
||||
'paid_at is set server-side to the current UTC timestamp; the API does not accept a body-supplied date to keep BFL audit clean.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: { id: 'run_a8f1…', status: 'paid', paid_at: '2026-05-25T08:00:00Z' },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
response: { success: SalaryRunPaid },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'salary-runs.mark-paid',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
const salaryRunId = idParse.data
|
||||
|
||||
const { data: existing, error: fetchErr } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', salaryRunId)
|
||||
.maybeSingle()
|
||||
if (fetchErr) {
|
||||
return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!existing) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if ((existing as { status: string }).status !== 'approved') {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_MARK_PAID_NOT_APPROVED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { current_status: (existing as { status: string }).status },
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{
|
||||
id: salaryRunId,
|
||||
would_advance_status_from: 'approved',
|
||||
would_advance_status_to: 'paid',
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.update({ status: 'paid', paid_at: new Date().toISOString() })
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', salaryRunId)
|
||||
.eq('status', 'approved')
|
||||
.select(MARK_PAID_COLUMNS)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!data) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_MARK_PAID_NOT_APPROVED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'race' },
|
||||
})
|
||||
}
|
||||
|
||||
return ok(data, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -369,10 +369,16 @@ export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: strin
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (count === 0) {
|
||||
// Race: status transitioned between pre-flight and delete.
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_DELETE_NOT_DRAFT', ctx.log, {
|
||||
// Pre-flight saw status='draft', so a status race is one explanation —
|
||||
// but the more concerning interpretation is that the FK-null guards
|
||||
// tripped (a journal entry has somehow attached to a draft row,
|
||||
// which would be a partial-failure path in PR-2's lifecycle code).
|
||||
// Surface the distinct BFL 5 kap code so an operator seeing this in
|
||||
// logs can immediately look for a misrouted verifikation rather
|
||||
// than chalking it up to concurrency.
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'race' },
|
||||
details: { reason: 'fk_non_null_or_status_race' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -539,7 +539,10 @@ describe('DELETE /api/v1/companies/:companyId/salary-runs/:id', () => {
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_DELETE_NOT_DRAFT')
|
||||
expect(body.error.details.reason).toBe('race')
|
||||
// Phase 5 PR-2 swapped this to a distinct BFL 5 kap code so an
|
||||
// operator seeing this in logs immediately knows a verifikation may
|
||||
// be attached, not just that the status raced.
|
||||
expect(body.error.code).toBe('SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY')
|
||||
expect(body.error.details.reason).toBe('fk_non_null_or_status_race')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ExtensionDefinition } from '@/lib/extensions/types'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
|
||||
export default function ExtensionWorkspaceShell({
|
||||
definition,
|
||||
@@ -10,22 +10,9 @@ export default function ExtensionWorkspaceShell({
|
||||
definition: ExtensionDefinition
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const Icon = resolveIcon(definition.icon)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{definition.name}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{definition.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extension content */}
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10 space-y-8">
|
||||
<PageHeader title={definition.name} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
@@ -216,23 +215,14 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
if (!profile) return null
|
||||
|
||||
const isActive = profile.activityStatus !== 'ceased'
|
||||
const registrations = [
|
||||
profile.registration.fTax && 'F-skatt',
|
||||
profile.registration.vat && 'Moms',
|
||||
profile.registration.payroll && 'Arbetsgivare',
|
||||
].filter((label): label is string => Boolean(label))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status bar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={isActive ? 'success' : 'destructive'}>
|
||||
{isActive ? 'Aktiv' : 'Avregistrerat'}
|
||||
</Badge>
|
||||
{profile.registration.fTax && <Badge variant="outline">F-skatt</Badge>}
|
||||
{profile.registration.vat && <Badge variant="outline">Moms</Badge>}
|
||||
{profile.registration.payroll && <Badge variant="outline">Arbetsgivare</Badge>}
|
||||
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
Uppdaterad {timeAgo(profile.fetchedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Company info card */}
|
||||
<Card>
|
||||
@@ -243,6 +233,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{profile.orgNumber} · {profile.legalEntityType}
|
||||
{!isActive && (
|
||||
<span className="ml-2 text-destructive">· Avregistrerat</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
@@ -268,6 +261,12 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
<span>{profile.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{registrations.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Registrerat för</p>
|
||||
<p className="text-xs text-muted-foreground">{registrations.join(' · ')}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.sniCodes.length > 0 && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">SNI-koder</p>
|
||||
@@ -316,6 +315,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-2 text-xs text-muted-foreground/70">
|
||||
Uppdaterad {timeAgo(profile.fetchedAt)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -774,6 +774,32 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
|
||||
path: ['tax_municipality'],
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 5 PR-1 carry-over (PR-2 enforcement): if vaxa_stod_eligible is set,
|
||||
// require vaxa_stod_start. The end date is optional (some eligibility
|
||||
// windows run open-ended until the maximum benefit period is reached).
|
||||
// Birth-year age gate (the actual eligibility rule — born 2003-2007 for
|
||||
// 2026) is checked at calculation-time by the engine, not here, because
|
||||
// it depends on the payment year of each run — a 22-year-old at hire
|
||||
// becomes 23 the next year and the rate switches without a row edit.
|
||||
if (data.vaxa_stod_eligible && !data.vaxa_stod_start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat',
|
||||
path: ['vaxa_stod_start'],
|
||||
})
|
||||
}
|
||||
if (
|
||||
data.vaxa_stod_start &&
|
||||
data.vaxa_stod_end &&
|
||||
data.vaxa_stod_end < data.vaxa_stod_start
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Växa-stödets slutdatum måste vara efter startdatumet',
|
||||
path: ['vaxa_stod_end'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => {
|
||||
@@ -808,6 +834,41 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da
|
||||
path: ['hourly_rate'],
|
||||
})
|
||||
}
|
||||
|
||||
// Växa-stöd schema-level consistency check. The schema can only see what
|
||||
// the PATCH body carries; the route layer is responsible for merged-
|
||||
// state validation (i.e. an existing employee with vaxa_stod_start
|
||||
// already set can have vaxa_stod_eligible flipped on without also
|
||||
// sending start in the body). What the schema CAN enforce:
|
||||
// - If the body enables vaxa_stod AND clears vaxa_stod_start explicitly
|
||||
// (sending null), reject — that would orphan the eligibility flag.
|
||||
// - If the body sets vaxa_stod_eligible=true AND vaxa_stod_start is
|
||||
// present in the body but invalid relative to vaxa_stod_end, reject.
|
||||
// The first case isn't currently expressible via .partial() (null != absent),
|
||||
// so the practical schema-level check is the second one. The route
|
||||
// layer will add a merged-state check when needed.
|
||||
if (
|
||||
data.vaxa_stod_eligible === true &&
|
||||
'vaxa_stod_start' in data &&
|
||||
!data.vaxa_stod_start
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat',
|
||||
path: ['vaxa_stod_start'],
|
||||
})
|
||||
}
|
||||
if (
|
||||
data.vaxa_stod_start &&
|
||||
data.vaxa_stod_end &&
|
||||
data.vaxa_stod_end < data.vaxa_stod_start
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Växa-stödets slutdatum måste vara efter startdatumet',
|
||||
path: ['vaxa_stod_end'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other'])
|
||||
|
||||
@@ -82,12 +82,19 @@ import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/rout
|
||||
import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route'
|
||||
|
||||
// Phase 5 PR-1 — Payroll registers: employees + salary-runs CRUD.
|
||||
// Lifecycle verbs (calculate / approve / mark-paid / book / generate-agi)
|
||||
// ship in Phase 5 PR-2 after the internal /calculate orchestration is
|
||||
// extracted into a shared lib/salary/run-calculation.ts helper.
|
||||
import '@/app/api/v1/companies/[companyId]/employees/route'
|
||||
import '@/app/api/v1/companies/[companyId]/employees/[id]/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/route'
|
||||
|
||||
// Phase 5 PR-2 — Payroll lifecycle verbs. The /calculate orchestration was
|
||||
// extracted into lib/salary/run-calculation.ts; the AGI orchestration into
|
||||
// lib/salary/agi/generate-declaration.ts. Both the internal dashboard
|
||||
// routes and these v1 routes call the same helpers.
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route'
|
||||
|
||||
export {}
|
||||
|
||||
@@ -1370,6 +1370,24 @@ const SALARY: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'AGI kan endast genereras för lönekörningar i status review, approved, paid, booked eller corrected.',
|
||||
message_en: 'AGI can only be generated for salary runs in review, approved, paid, booked, or corrected status.',
|
||||
},
|
||||
AGI_INCOMPLETE_DATA: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'AGI-data ofullständig — kontrollera att företaget har organisationsnummer, kontaktnamn, telefon och e-post.',
|
||||
message_en: 'AGI data is incomplete — verify the company has org number, contact name, phone, and email.',
|
||||
},
|
||||
COMPANY_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Företaget kunde inte hittas.',
|
||||
message_en: 'Company not found.',
|
||||
},
|
||||
// Phase 5 PR-1 carry-over: distinct error code for the salary-run DELETE
|
||||
// FK-null guard so an operator seeing this in logs knows a journal entry
|
||||
// is at risk, not just a status race.
|
||||
SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Lönekörningen är kopplad till en verifikation och kan inte raderas (BFL 5 kap räkenskapsinformation).',
|
||||
message_en: 'Salary run is linked to a journal entry and cannot be deleted (BFL 5 kap räkenskapsinformation).',
|
||||
},
|
||||
}
|
||||
|
||||
const COMPANY: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* 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 {
|
||||
generateAGIXml,
|
||||
buildIndividuppgifterSnapshot,
|
||||
AGIIncompleteDataError,
|
||||
} from './xml-generator'
|
||||
import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
|
||||
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('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), line_items:salary_line_items(*)',
|
||||
)
|
||||
.eq('salary_run_id', salaryRunId)
|
||||
|
||||
if (!runEmployees || runEmployees.length === 0) {
|
||||
return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' }
|
||||
}
|
||||
|
||||
// 4. Build AGI input shapes.
|
||||
const companyData: AGICompanyData = {
|
||||
orgNumber: (settings?.org_number || company.org_number || '').trim(),
|
||||
companyName: company.name,
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
contactName: (profile?.full_name || company.name || '').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 }>
|
||||
>()
|
||||
if (employeeIds.length > 0) {
|
||||
const { data: absenceRows } = await supabase
|
||||
.from('salary_absence_days')
|
||||
.select('employee_id, absence_date, absence_type, hours')
|
||||
.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
|
||||
}>) {
|
||||
const list = absenceByEmployee.get(row.employee_id) ?? []
|
||||
list.push({
|
||||
date: row.absence_date,
|
||||
type: row.absence_type,
|
||||
hours: Number(row.hours ?? 8),
|
||||
})
|
||||
absenceByEmployee.set(row.employee_id, list)
|
||||
}
|
||||
}
|
||||
|
||||
const employeeData: AGIEmployeeData[] = (runEmployees as Array<Record<string, unknown>>).map(
|
||||
(sre) => {
|
||||
const emp = sre.employee as {
|
||||
personnummer: string
|
||||
specification_number: number
|
||||
f_skatt_status: string
|
||||
} | null
|
||||
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
|
||||
|
||||
const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car'])
|
||||
const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals'])
|
||||
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
|
||||
const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other'])
|
||||
const absenceEvents = absenceByEmployee.get(sre.employee_id as string)
|
||||
|
||||
return {
|
||||
personnummer: emp?.personnummer || '',
|
||||
specificationNumber: emp?.specification_number || 0,
|
||||
grossSalary: sre.gross_salary as number,
|
||||
taxWithheld: sre.tax_withheld as number,
|
||||
avgifterBasis: sre.avgifter_basis as number,
|
||||
fSkattPayment:
|
||||
emp?.f_skatt_status === 'f_skatt' ? (sre.gross_salary as number) : undefined,
|
||||
benefitCar: benefitCar > 0 ? benefitCar : undefined,
|
||||
benefitHousing: benefitHousing > 0 ? benefitHousing : undefined,
|
||||
benefitMeals: benefitMeals > 0 ? benefitMeals : undefined,
|
||||
benefitOther: benefitOther > 0 ? benefitOther : undefined,
|
||||
sickDays: (sre.sick_days as number) > 0 ? (sre.sick_days as number) : undefined,
|
||||
vabDays: (sre.vab_days as number) > 0 ? (sre.vab_days as number) : undefined,
|
||||
parentalDays:
|
||||
(sre.parental_days as number) > 0 ? (sre.parental_days as number) : undefined,
|
||||
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs).
|
||||
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
|
||||
for (const sre of runEmployees as Array<Record<string, unknown>>) {
|
||||
const dbCategory = sre.avgifter_category as string | null
|
||||
const category = dbCategory
|
||||
? dbCategory === 'reduced_65plus'
|
||||
? 'reduced65plus'
|
||||
: dbCategory === 'vaxa_stod'
|
||||
? 'standard'
|
||||
: dbCategory
|
||||
: (sre.avgifter_rate as number) <= 0.1022
|
||||
? 'reduced65plus'
|
||||
: (sre.avgifter_rate as number) <= 0.2082
|
||||
? 'youth'
|
||||
: 'standard'
|
||||
const cat = (avgifterByCategory as Record<string, { basis: number; amount: number }>)[
|
||||
category
|
||||
] || { basis: 0, amount: 0 }
|
||||
cat.basis += sre.avgifter_basis as number
|
||||
cat.amount += sre.avgifter_amount as number
|
||||
;(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 runEmployees as Array<Record<string, unknown>>) {
|
||||
const monthly =
|
||||
((sre.employee as { monthly_salary?: number } | null)?.monthly_salary as number) ?? 0
|
||||
if (!monthly) continue
|
||||
const dailyRate = monthly / 21
|
||||
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
|
||||
for (const li of lineItems) {
|
||||
if (li.item_type === 'sick_day2_14') {
|
||||
const days = (li.quantity as number) || 0
|
||||
totalSjuklonekostnad += dailyRate * sjuklonRate * days
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totals: AGITotals = {
|
||||
totalTax: run.total_tax,
|
||||
totalAvgifterBasis: (runEmployees as Array<{ avgifter_basis: number }>).reduce(
|
||||
(s, e) => s + e.avgifter_basis,
|
||||
0,
|
||||
),
|
||||
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
|
||||
totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100,
|
||||
avgifterByCategory,
|
||||
}
|
||||
|
||||
// 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 },
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Shared salary-calculation orchestration.
|
||||
*
|
||||
* Both the internal dashboard route (`POST /api/salary/runs/{id}/calculate`)
|
||||
* and the v1 public route (`POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate`)
|
||||
* call this helper. It performs every side effect the dashboard's calculate
|
||||
* step did: load config + employees + tax tables, derive absence / benefits
|
||||
* / worked-hours, run the engine per employee, write line items + run-employee
|
||||
* results + run totals + calculation_params.
|
||||
*
|
||||
* The function returns a discriminated result rather than a NextResponse so
|
||||
* either caller can wrap it in their own response envelope (internal uses
|
||||
* `errorResponseFromCode`; v1 uses `v1ErrorResponseFromCode`).
|
||||
*
|
||||
* Strict-mode: the function aborts at the FIRST per-employee failure. There
|
||||
* is no partial-state recovery — either every employee succeeds and the run
|
||||
* gets its aggregated totals + updated row, or the caller receives an error
|
||||
* and the run remains in `draft`. This matches the dashboard's behaviour and
|
||||
* is required for BFL 5 kap: a half-calculated run that later advances to
|
||||
* `review` would post a wrong verifikation when `:book` runs.
|
||||
*
|
||||
* The function does NOT advance the salary_runs status. That's the route's
|
||||
* responsibility — the dashboard leaves the run in `draft` (an explicit
|
||||
* `/review` verb does the freeze), while v1 collapses calculate+review into
|
||||
* a single verb. Routes layer the status transition on top of this result.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { calculateSalary } from './calculation-engine'
|
||||
import { loadPayrollConfig, serializePayrollConfig } from './payroll-config'
|
||||
import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from './tax-tables'
|
||||
import { loadAndDeriveAbsence } from './derive-absence-line-items'
|
||||
import { getLineItemAccount } from './account-mapping'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { SalaryLineItemType } from '@/types'
|
||||
|
||||
/** Item types that the calculator derives from per-day absence records. */
|
||||
const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
|
||||
'sick_karens',
|
||||
'sick_day2_14',
|
||||
'sick_day15_plus',
|
||||
'vab',
|
||||
'parental_leave',
|
||||
]
|
||||
|
||||
/** Benefit-type → line-item-type mapping for the derived benefit rows. */
|
||||
const BENEFIT_TYPE_TO_LINE_ITEM: Record<string, SalaryLineItemType> = {
|
||||
bike: 'benefit_bike',
|
||||
car: 'benefit_car',
|
||||
meals: 'benefit_meals',
|
||||
housing: 'benefit_housing',
|
||||
wellness: 'benefit_wellness',
|
||||
other: 'benefit_other',
|
||||
}
|
||||
|
||||
export interface RunSalaryCalculationArgs {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
salaryRunId: string
|
||||
log: Logger
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export type RunSalaryCalculationResult =
|
||||
| { ok: true; run: Record<string, unknown>; warnings: string[] }
|
||||
| { ok: false; code: string; details?: unknown; status?: number }
|
||||
|
||||
/**
|
||||
* Run the per-employee calculation for a salary run.
|
||||
*
|
||||
* Preconditions enforced inside:
|
||||
* - salary_runs row exists, is owned by `companyId`, and is in `draft` status
|
||||
* - at least one salary_run_employee row exists for the run
|
||||
* - every employee has a valid salary amount + tax configuration
|
||||
* - every needed tax table is fetchable from Skatteverket (or local fallback)
|
||||
*
|
||||
* Returns the updated salary_runs row + warnings on success. Returns a
|
||||
* structured `{ ok: false; code; details? }` on any failure. The caller is
|
||||
* responsible for converting that to its response envelope.
|
||||
*/
|
||||
export async function runSalaryCalculation(
|
||||
args: RunSalaryCalculationArgs,
|
||||
): Promise<RunSalaryCalculationResult> {
|
||||
const { supabase, companyId, salaryRunId: id, log, requestId } = args
|
||||
const opLog = log.child({ salaryRunId: id })
|
||||
|
||||
// 1. Precondition: run exists, owned by company, is in draft status.
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
return { ok: false, code: 'SALARY_RUN_NOT_FOUND' }
|
||||
}
|
||||
if (run.status !== 'draft') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'SALARY_RUN_CALCULATE_FAILED',
|
||||
details: { currentStatus: run.status, reason: 'not_draft' },
|
||||
}
|
||||
}
|
||||
|
||||
const paymentYear = parseInt(run.payment_date.split('-')[0])
|
||||
|
||||
// 2. Load year config.
|
||||
const config = await loadPayrollConfig(supabase, paymentYear)
|
||||
|
||||
// 3. Load roster — `salary_run_employees` joined with employees + line items.
|
||||
// Defense-in-depth: filter by company_id too even though salary_run_id is a
|
||||
// foreign key. RLS already constrains the table per-company, but per
|
||||
// CLAUDE.md every query carries the company_id filter explicitly so a
|
||||
// future RLS lapse can't surface cross-tenant rows.
|
||||
const { data: runEmployees, error: empError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(*), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (empError || !runEmployees || runEmployees.length === 0) {
|
||||
return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' }
|
||||
}
|
||||
|
||||
// 4. Pre-calculation validation — ensure every employee has the data the
|
||||
// engine needs. We accumulate ALL errors so the caller sees a complete
|
||||
// list rather than fixing one and discovering the next on the retry.
|
||||
const validationErrors: string[] = []
|
||||
for (const sre of runEmployees) {
|
||||
const emp = sre.employee
|
||||
if (!emp) continue
|
||||
const name = `${emp.first_name} ${emp.last_name}`
|
||||
|
||||
if (emp.salary_type === 'monthly' && (!emp.monthly_salary || emp.monthly_salary <= 0)) {
|
||||
validationErrors.push(`${name}: Månadslön saknas eller är 0`)
|
||||
}
|
||||
if (emp.salary_type === 'hourly' && (!emp.hourly_rate || emp.hourly_rate <= 0)) {
|
||||
validationErrors.push(`${name}: Timlön saknas eller är 0`)
|
||||
}
|
||||
if (emp.f_skatt_status === 'a_skatt' && !emp.is_sidoinkomst && !emp.tax_table_number) {
|
||||
validationErrors.push(`${name}: Skattetabell saknas (krävs för A-skatt)`)
|
||||
}
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'VALIDATION_ERROR',
|
||||
details: { issues: validationErrors, reason: 'employee_data_incomplete' },
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fetch every needed tax table in one batch. The Skatteverket API has
|
||||
// fallback to local data; if both fail TaxTableUnavailableError surfaces
|
||||
// as a distinct retryable 503.
|
||||
const tableNumbers = [
|
||||
...new Set(
|
||||
runEmployees
|
||||
.filter((e) => e.employee?.tax_table_number)
|
||||
.map((e) => e.employee.tax_table_number as number),
|
||||
),
|
||||
]
|
||||
const columns = [
|
||||
...new Set(
|
||||
runEmployees
|
||||
.filter((e) => e.employee?.tax_column)
|
||||
.map((e) => e.employee.tax_column as number),
|
||||
),
|
||||
]
|
||||
let taxRates: Awaited<ReturnType<typeof fetchAllTaxTableRatesForRun>>['rates'] = []
|
||||
let taxTableSource: Awaited<ReturnType<typeof fetchAllTaxTableRatesForRun>>['source'] = 'api'
|
||||
if (tableNumbers.length > 0) {
|
||||
try {
|
||||
const result = await fetchAllTaxTableRatesForRun(
|
||||
paymentYear,
|
||||
tableNumbers,
|
||||
columns.length > 0 ? columns : [1],
|
||||
)
|
||||
taxRates = result.rates
|
||||
taxTableSource = result.source
|
||||
} catch (err) {
|
||||
if (err instanceof TaxTableUnavailableError) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'SALARY_RUN_TAX_TABLE_MISSING',
|
||||
details: { reason: err.message, paymentYear, tableNumbers },
|
||||
status: 503,
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 6. YTD aggregation across prior BOOKED runs in the same period_year.
|
||||
// Drives the engine's progressive-tax + capped-avgift calculations.
|
||||
const { data: priorRuns } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select(
|
||||
'employee_id, gross_salary, tax_withheld, net_salary, salary_run:salary_runs!inner(period_year, period_month, status)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.eq('salary_run.period_year', run.period_year)
|
||||
.eq('salary_run.status', 'booked')
|
||||
.lt('salary_run.period_month', run.period_month)
|
||||
|
||||
const ytdByEmployee = new Map<string, { gross: number; tax: number; net: number }>()
|
||||
for (const prior of (priorRuns || []) as Array<{
|
||||
employee_id: string
|
||||
gross_salary: number
|
||||
tax_withheld: number
|
||||
net_salary: number
|
||||
}>) {
|
||||
const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 }
|
||||
current.gross += prior.gross_salary
|
||||
current.tax += prior.tax_withheld
|
||||
current.net += prior.net_salary
|
||||
ytdByEmployee.set(prior.employee_id, current)
|
||||
}
|
||||
|
||||
// 7. Pay period bounds — used to load per-day absence + worked-day records.
|
||||
const periodYear = run.period_year as number
|
||||
const periodMonth = run.period_month as number
|
||||
const periodStart = `${periodYear}-${String(periodMonth).padStart(2, '0')}-01`
|
||||
const periodEndDate = new Date(Date.UTC(periodYear, periodMonth, 0)) // last day of month
|
||||
const periodEnd = periodEndDate.toISOString().slice(0, 10)
|
||||
|
||||
// Per-run aggregates collected during the loop.
|
||||
let totalGross = 0
|
||||
let totalTax = 0
|
||||
let totalNet = 0
|
||||
let totalAvgifter = 0
|
||||
let totalVacationAccrual = 0
|
||||
let totalEmployerCost = 0
|
||||
|
||||
// Surfaced as warnings — UI / agent shows alongside the successful
|
||||
// calculation, not an error.
|
||||
const lakarintygEmployees: string[] = []
|
||||
const fkReportingEmployees: string[] = []
|
||||
|
||||
// 8. Per-employee calculation loop.
|
||||
for (const sre of runEmployees) {
|
||||
const emp = sre.employee
|
||||
if (!emp) continue
|
||||
|
||||
// 8a. Derive absence line items from per-day records.
|
||||
const absenceResult = await loadAndDeriveAbsence({
|
||||
supabase,
|
||||
companyId,
|
||||
employeeId: emp.id,
|
||||
monthlySalary: emp.monthly_salary || 0,
|
||||
payrollConfig: config,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
})
|
||||
|
||||
// 8b. For hourly employees, derive worked hours from the calendar.
|
||||
let derivedHoursWorked: number | null = null
|
||||
if (emp.salary_type === 'hourly') {
|
||||
const { data: workedDays, error: workedError } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.select('hours')
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', emp.id)
|
||||
.gte('work_date', periodStart)
|
||||
.lte('work_date', periodEnd)
|
||||
if (workedError) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: workedError }
|
||||
}
|
||||
derivedHoursWorked = (workedDays ?? []).reduce(
|
||||
(sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100,
|
||||
0,
|
||||
)
|
||||
opLog.info('Derived hours_worked from calendar', {
|
||||
employeeId: emp.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
rowCount: workedDays?.length ?? 0,
|
||||
derivedHoursWorked,
|
||||
})
|
||||
|
||||
// Refresh the hourly_salary line item so the displayed Lönerader table
|
||||
// matches what the engine actually calculated.
|
||||
if (derivedHoursWorked > 0 && (emp.hourly_rate || 0) > 0) {
|
||||
const baseAmount =
|
||||
Math.round((emp.hourly_rate as number) * derivedHoursWorked * 100) / 100
|
||||
await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.eq('item_type', 'hourly_salary')
|
||||
await supabase.from('salary_line_items').insert({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: 'hourly_salary',
|
||||
description: 'Timlön',
|
||||
quantity: derivedHoursWorked,
|
||||
amount: baseAmount,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount('hourly_salary'),
|
||||
sort_order: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const employeeName = `${emp.first_name} ${emp.last_name}`
|
||||
if (absenceResult.flagLakarintyg) lakarintygEmployees.push(employeeName)
|
||||
if (absenceResult.flagFkReporting) fkReportingEmployees.push(employeeName)
|
||||
|
||||
// 8c. Replace derived absence rows.
|
||||
const { error: delAbsErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.in('item_type', DERIVED_ABSENCE_TYPES)
|
||||
if (delAbsErr) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: delAbsErr }
|
||||
}
|
||||
|
||||
// 8d. Derive benefit line items from employee_benefits.
|
||||
const { data: activeBenefits, error: benefitsErr } = await supabase
|
||||
.from('employee_benefits')
|
||||
.select('id, benefit_type, description, monthly_value')
|
||||
.eq('employee_id', emp.id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.lte('valid_from', run.payment_date)
|
||||
.or(`valid_to.is.null,valid_to.gte.${run.payment_date}`)
|
||||
if (benefitsErr) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: benefitsErr }
|
||||
}
|
||||
|
||||
const { error: delBenefitErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.not('source_benefit_id', 'is', null)
|
||||
if (delBenefitErr) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: delBenefitErr }
|
||||
}
|
||||
|
||||
const derivedBenefitRows = (activeBenefits ?? [])
|
||||
.filter((b) => b.monthly_value > 0)
|
||||
.map((b, idx) => {
|
||||
const itemType = BENEFIT_TYPE_TO_LINE_ITEM[b.benefit_type] ?? 'benefit_other'
|
||||
return {
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: itemType,
|
||||
description: b.description,
|
||||
quantity: 1,
|
||||
amount: Math.round(b.monthly_value * 100) / 100,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: false,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount(itemType, emp.employment_type),
|
||||
sort_order: 200 + idx,
|
||||
source_benefit_id: b.id,
|
||||
}
|
||||
})
|
||||
|
||||
if (derivedBenefitRows.length > 0) {
|
||||
const { error: insBenefitErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.insert(derivedBenefitRows)
|
||||
if (insBenefitErr) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: insBenefitErr }
|
||||
}
|
||||
}
|
||||
|
||||
if (absenceResult.lineItems.length > 0) {
|
||||
const rows = absenceResult.lineItems.map((li, idx) => ({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: li.item_type,
|
||||
description: li.description,
|
||||
quantity: li.quantity,
|
||||
amount: Math.round(li.amount * 100) / 100,
|
||||
is_taxable: li.is_taxable,
|
||||
is_avgift_basis: li.is_avgift_basis,
|
||||
is_vacation_basis: li.is_vacation_basis,
|
||||
is_gross_deduction: li.is_gross_deduction,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount(li.item_type),
|
||||
sort_order: 100 + idx,
|
||||
}))
|
||||
const { error: insAbsErr } = await supabase.from('salary_line_items').insert(rows)
|
||||
if (insAbsErr) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: insAbsErr }
|
||||
}
|
||||
}
|
||||
|
||||
// 8e. Assemble the in-memory line item set fed to calculateSalary.
|
||||
const manualLineItems = (sre.line_items || [])
|
||||
.filter((li: Record<string, unknown>) => {
|
||||
if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false
|
||||
if (li.source_benefit_id) return false
|
||||
if (li.item_type === 'semesterersattning') return false
|
||||
return true
|
||||
})
|
||||
.map((li: Record<string, unknown>) => ({
|
||||
itemType: li.item_type as SalaryLineItemType,
|
||||
amount: li.amount as number,
|
||||
isTaxable: li.is_taxable as boolean,
|
||||
isAvgiftBasis: li.is_avgift_basis as boolean,
|
||||
isVacationBasis: li.is_vacation_basis as boolean,
|
||||
isGrossDeduction: li.is_gross_deduction as boolean,
|
||||
isNetDeduction: li.is_net_deduction as boolean,
|
||||
}))
|
||||
const derivedLineItems = absenceResult.lineItems.map((li) => ({
|
||||
itemType: li.item_type as SalaryLineItemType,
|
||||
amount: li.amount,
|
||||
isTaxable: li.is_taxable,
|
||||
isAvgiftBasis: li.is_avgift_basis,
|
||||
isVacationBasis: li.is_vacation_basis,
|
||||
isGrossDeduction: li.is_gross_deduction,
|
||||
isNetDeduction: false,
|
||||
}))
|
||||
const derivedBenefitLineItems = derivedBenefitRows.map((row) => ({
|
||||
itemType: row.item_type as SalaryLineItemType,
|
||||
amount: row.amount,
|
||||
isTaxable: true,
|
||||
isAvgiftBasis: true,
|
||||
isVacationBasis: false,
|
||||
isGrossDeduction: false,
|
||||
isNetDeduction: false,
|
||||
}))
|
||||
const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems]
|
||||
|
||||
// 8f. Run the engine for this employee.
|
||||
const result = calculateSalary(
|
||||
{
|
||||
employmentType: emp.employment_type,
|
||||
salaryType: emp.salary_type,
|
||||
monthlySalary: emp.monthly_salary || 0,
|
||||
hourlyRate: emp.hourly_rate || undefined,
|
||||
hoursWorked:
|
||||
derivedHoursWorked !== null && derivedHoursWorked > 0
|
||||
? derivedHoursWorked
|
||||
: sre.hours_worked || undefined,
|
||||
employmentDegree: emp.employment_degree,
|
||||
taxTableNumber: emp.tax_table_number,
|
||||
taxColumn: emp.tax_column || 1,
|
||||
isSidoinkomst: emp.is_sidoinkomst,
|
||||
jamkningPercentage: emp.jamkning_percentage,
|
||||
jamkningValidFrom: emp.jamkning_valid_from,
|
||||
jamkningValidTo: emp.jamkning_valid_to,
|
||||
fSkattStatus: emp.f_skatt_status,
|
||||
personnummer: emp.personnummer,
|
||||
paymentDate: run.payment_date,
|
||||
vacationRule: emp.vacation_rule,
|
||||
vacationDaysPerYear: emp.vacation_days_per_year,
|
||||
semestertillaggRate: emp.semestertillagg_rate,
|
||||
vaxaStodEligible: emp.vaxa_stod_eligible,
|
||||
vaxaStodStart: emp.vaxa_stod_start,
|
||||
vaxaStodEnd: emp.vaxa_stod_end,
|
||||
lineItems,
|
||||
},
|
||||
config,
|
||||
taxRates.map((r) => ({
|
||||
tableYear: r.tableYear,
|
||||
tableNumber: r.tableNumber,
|
||||
columnNumber: r.columnNumber,
|
||||
incomeFrom: r.incomeFrom,
|
||||
incomeTo: r.incomeTo,
|
||||
taxAmount: r.taxAmount,
|
||||
})),
|
||||
)
|
||||
|
||||
// Aggregated absence counts derived from per-day records.
|
||||
const sickDays = absenceResult.aggregated.sickDays
|
||||
const vabDays = absenceResult.aggregated.vabDays
|
||||
const parentalDays = absenceResult.aggregated.parentalDays
|
||||
const vacationDays = (sre.line_items || [])
|
||||
.filter((li: Record<string, unknown>) => li.item_type === 'vacation')
|
||||
.reduce(
|
||||
(sum: number, li: Record<string, unknown>) => sum + ((li.quantity as number) || 0),
|
||||
0,
|
||||
)
|
||||
|
||||
// 8g. Write the per-employee row. Mirrors calendar-derived hours into the
|
||||
// hours_worked snapshot column so downstream code (reports, storno via
|
||||
// correct/route) sees a consistent value.
|
||||
const snapshotHoursWorked =
|
||||
derivedHoursWorked !== null && derivedHoursWorked > 0
|
||||
? derivedHoursWorked
|
||||
: sre.hours_worked
|
||||
const { error: empUpdateError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.update({
|
||||
hours_worked: snapshotHoursWorked,
|
||||
gross_salary: result.grossSalary,
|
||||
gross_deductions: result.grossDeductions,
|
||||
benefit_values: result.benefitValues,
|
||||
taxable_income: result.taxableIncome,
|
||||
tax_withheld: result.taxWithheld,
|
||||
net_deductions: result.netDeductions,
|
||||
net_salary: result.netSalary,
|
||||
avgifter_rate: result.avgifterRate,
|
||||
avgifter_amount: result.avgifterAmount,
|
||||
avgifter_basis: result.avgifterBasis,
|
||||
avgifter_category: result.avgifterCategory,
|
||||
vacation_accrual: result.vacationAccrual,
|
||||
vacation_accrual_avgifter: result.vacationAccrualAvgifter,
|
||||
tax_table_number: emp.tax_table_number,
|
||||
tax_column: emp.tax_column,
|
||||
tax_table_year: paymentYear,
|
||||
sick_days: sickDays,
|
||||
vab_days: vabDays,
|
||||
parental_days: parentalDays,
|
||||
vacation_days_taken: vacationDays,
|
||||
calculation_breakdown: { steps: result.steps },
|
||||
ytd_gross:
|
||||
Math.round(
|
||||
((ytdByEmployee.get(sre.employee_id)?.gross || 0) + result.grossSalary) * 100,
|
||||
) / 100,
|
||||
ytd_tax:
|
||||
Math.round(
|
||||
((ytdByEmployee.get(sre.employee_id)?.tax || 0) + result.taxWithheld) * 100,
|
||||
) / 100,
|
||||
ytd_net:
|
||||
Math.round(
|
||||
((ytdByEmployee.get(sre.employee_id)?.net || 0) + result.netSalary) * 100,
|
||||
) / 100,
|
||||
})
|
||||
.eq('id', sre.id)
|
||||
|
||||
if (empUpdateError) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: empUpdateError }
|
||||
}
|
||||
|
||||
// 8h. Replace any existing 'semesterersattning' line item (the engine
|
||||
// derives it on every calculate).
|
||||
const { error: delSemErr } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.eq('item_type', 'semesterersattning')
|
||||
if (delSemErr) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: delSemErr }
|
||||
}
|
||||
if (result.vacationCompensation > 0) {
|
||||
const { error: insSemErr } = await supabase.from('salary_line_items').insert({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: 'semesterersattning',
|
||||
description: 'Semesterersättning',
|
||||
quantity: 1,
|
||||
amount: Math.round(result.vacationCompensation * 100) / 100,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: false,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount('semesterersattning', emp.employment_type),
|
||||
sort_order: 50,
|
||||
})
|
||||
if (insSemErr) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: insSemErr }
|
||||
}
|
||||
}
|
||||
|
||||
totalGross += result.grossSalary
|
||||
totalTax += result.taxWithheld
|
||||
totalNet += result.netSalary
|
||||
totalAvgifter += result.avgifterAmount
|
||||
totalVacationAccrual += result.vacationAccrual
|
||||
totalEmployerCost += result.totalEmployerCost
|
||||
}
|
||||
|
||||
// 9. Update run totals + freeze the calculation_params snapshot.
|
||||
const { data: updatedRun, error: updateError } = await supabase
|
||||
.from('salary_runs')
|
||||
.update({
|
||||
total_gross: Math.round(totalGross * 100) / 100,
|
||||
total_tax: Math.round(totalTax * 100) / 100,
|
||||
total_net: Math.round(totalNet * 100) / 100,
|
||||
total_avgifter: Math.round(totalAvgifter * 100) / 100,
|
||||
total_vacation_accrual: Math.round(totalVacationAccrual * 100) / 100,
|
||||
total_employer_cost: Math.round(totalEmployerCost * 100) / 100,
|
||||
calculation_params: serializePayrollConfig(config),
|
||||
})
|
||||
.eq('id', id)
|
||||
// Defense-in-depth: scope the write to the company explicitly. The
|
||||
// first SELECT confirmed `company_id = companyId` for this id, but the
|
||||
// CLAUDE.md rule is that every write carries the filter so the
|
||||
// intent is explicit at the SQL layer even if upstream code is later
|
||||
// refactored.
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return { ok: false, code: 'DATABASE_ERROR', details: updateError }
|
||||
}
|
||||
|
||||
// 10. Warnings — non-blocking annotations the caller should surface.
|
||||
const warnings: string[] = []
|
||||
if (taxTableSource === 'fallback') {
|
||||
warnings.push(
|
||||
`Skatteverkets skattetabell-API är inte nåbart — beräkningen använder lokal reservdata för ${paymentYear}. Kontrollera att Skatteverket inte publicerat ändringar innan lönekörningen bokförs.`,
|
||||
)
|
||||
} else if (taxTableSource === 'mixed') {
|
||||
warnings.push(
|
||||
`Skatteverkets skattetabell-API svarade bara delvis — vissa skattetabeller kommer från lokal reservdata för ${paymentYear}. Kontrollera att Skatteverket inte publicerat ändringar innan lönekörningen bokförs.`,
|
||||
)
|
||||
}
|
||||
if (lakarintygEmployees.length > 0) {
|
||||
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) {
|
||||
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.`,
|
||||
)
|
||||
}
|
||||
|
||||
opLog.info('salary calculation complete', {
|
||||
requestId,
|
||||
salaryRunId: id,
|
||||
warningCount: warnings.length,
|
||||
taxTableSource,
|
||||
})
|
||||
|
||||
return { ok: true, run: updatedRun as Record<string, unknown>, warnings }
|
||||
}
|
||||
Reference in New Issue
Block a user