fbf8348ca3
* 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>