b9a2ce522b33ecb09b38aa8b60a2b7860a69e121
2
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
432a8b60dc |
feat(skatteverket): rewrite AGI flow against real Skatteverket RAML (#391)
* feat(skatteverket): rewrite AGI flow against real Skatteverket RAML The previous AGI client posted JSON to URL paths that don't exist on Skatteverket's gateway and used invented field names. POST /underlag actually accepts application/xml, and the lock/kvittenser operations live on the separate hanteraredovisningsperiod API. Verified against dev_docs/arbetsgivardeklaration-inlamning(1.7.7) and arbetsgivardeklaration-hantera-redovisningsperiod(1.2.8) RAMLs. - Replace fictional types with real schemas (kontrollresultat, granskningsunderlag, kvittenser, error envelope) - Rewrite agi-client into 9 functions matching the documented flow: /underlag (XML) -> kontrollresultat -> spara -> skapaGranskningsunderlag -> kvittenser, plus las/lasUpp on the hantera API - Drop agi-mappers entirely; lib/salary/agi/xml-generator.ts already produces schema-valid XML, so the extension just feeds agi_declarations.xml_content to POST /underlag - Extend skvRequest with a contentType option so AGI can post XML - AGIPanel state machine: underlag_submitted -> awaiting_signing -> signed, with kontrollresultat polling and normalized findings - Add the agd OAuth scope (confirmed from SKV's Tjanstebeskrivning Arbetsgivardeklaration inlamning v1.7, section 4.1.2.2) - Add Skatteverket connect step to NewUserChecklist alongside the existing SIE/old-system import and bank steps; track hasSkatteverketConnected in OnboardingProgress - Update orchestrator route + tests to point at the new /agi/submit endpoint - Declare new optional base-URL env vars in the manifest Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): address PR review findings on AGI flow - Surface INCORRECT_DATA felrapport link in AGIPanel skapaGranskningsunderlag returns 409 with a felrapport URL when SKV rejects the underlag. The link was persisted as `signeringslank` with status `underlag_rejected`, but the render condition only fired for `awaiting_signing`, leaving the link unreachable. Add a distinct destructive-styled block so the user can open the felrapport in Mina Sidor. - /agi/underlag DELETE clears local submission state Add optional `period` query param. When supplied, clear `agi_submission_{period}` directly. When not, fall back to scanning recent agi_submission_* keys for the matching inlamningId. Without this, an aborted underlag left a stale `underlag_submitted` entry in extension_data and the UI couldn't progress. - Re-add salary-run status guard inside loadAGIXml The orchestrator at app/api/salary/runs/[id]/agi/submit/route.ts has this check, but the extension endpoint is also reachable directly from AGIPanel and must enforce it itself. Per BFL 5 kap and SFL 26 kap, AGI must reflect finalised payroll data; submitting from a draft/cancelled run would emit incorrect figures. - Move agi_declarations.status='exported' from /agi/submit to /agi/spara Setting status on underlag-ingest was wrong because a DONE_REJECTED kontrollresultat would leave the row falsely marked as exported. The transition now happens only after the spara call commits the underlag to Eget utrymme. /agi/spara accepts salaryRunId in the body for the fast path and falls back to scanning agi_submission_* state otherwise. - Move salary_runs.agi_submitted_at stamp to kvittenser observation The orchestrator was stamping at underlag-ingest, but no later code updated the column on signing. Removed the orchestrator stamp; the /agi/kvittenser handler now stamps salary_runs.agi_submitted_at to kvittens.signeradTid (mirroring SKV's own timestamp) when it pins the receipt to the matching agi_declarations row. - Tighten misleading JSDoc in agi-client.ts taBortSparadInlamning is on the inlämning API, not hantera; the old layout grouped it under a "hantera API" heading and tripped an automated reviewer. Restructured into separate "period management" and "cleanup" blocks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): address Swedish compliance review on AGI flow Follow-up to review on https://github.com/erp-mafia/gnubok/pull/391. - Migration adds 'pending_signature' to agi_declarations.status Reusing 'exported' for the spara→kvittens interval misstated the filing outcome — Eget utrymme is a staging area, not a filing — which conflicts with BFNAR 2013:2 kap 8 / BFL 5 kap 5§ behandlingshistorik faithfulness. /agi/spara now sets 'pending_signature'; /agi/kvittenser later promotes to 'submitted' when a uuidKvittens is observed. - AGIPanel auto-polls /agi/kvittenser at 30s, 2 min and 5 min after the signing link is created Previously the kvittens (and therefore salary_runs.agi_submitted_at) was only stamped if the user manually returned to the panel and clicked "Hämta kvittens". Without that follow-up the audit trail showed a NULL submitted-at for an AGI that had actually been filed. Background polls capture the kvittens for the common case where the user signs in Mina Sidor and never returns to gnubok. Cleanup on unmount via useRef + useEffect. - Distinct MISSING_SCOPE error code on 403 invalid_scope Existing tokens lack the new 'agd' scope and surface as a generic ACCESS_DENIED today. The compliance reviewer pointed out that operators may interpret this as a data error and submit a corrected AGI with altered figures. New SkatteverketAuthError code maps SKV's invalid_scope body to a clear "reconnect via Inställningar → Skatteverket" message; routes to 401 (token-level remediation). - Refine deadline copy in AGIPanel The standard AGI deadline is the 12th regardless of company size; the 17th only applies in January and August for employers with turnover ≤ 40 MSEK. Surface that nuance instead of saying just "12:e". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): server-side kvittens reconciliation cron Round 2 of compliance review on https://github.com/erp-mafia/gnubok/pull/391. - /api/extensions/skatteverket/agi/kvittenser/cron Walks every agi_declarations row in 'pending_signature' status, fetches kvittenser via the matching token, and on a hit promotes the row to 'submitted' + stamps salary_runs.agi_submitted_at. Authoritative source for the audit trail per BFNAR 2013:2 kap 8 / BFL 5 kap 5§ — the AGIPanel client-side timers from the previous round remain as the fast-path UX, but no longer carry the audit-trail responsibility on their own. Per-row errors are skipped, not abort-the-run. 50s budget. Scheduled every 2 hours in vercel.json. - AGIStatus union now includes 'pending_signature' Without this update, downstream code reading the union would have rejected the new status as unknown. The migration extending the DB CHECK constraint shipped in the previous commit; this brings the type layer into sync. - Stale comment update in AGIPanel.tsx Referred to status='exported' from before the rename. Now reads 'pending_signature', matching the actual handler behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): close audit-trail gaps from compliance round 3 - Cron now writes submitted_by from the token-owning auth.users row Previously left NULL with a "system actor" comment. The token row was created when the operator authenticated with BankID, and the kvittens' signeradAv refers to the same person — so writing the user_id from skatteverket_tokens captures actor traceability without inventing a system identity. Closes the BFL 5 kap 6§ / BFNAR 2013:2 kap 8 gap on cron-reconciled rows. - /agi/spara monotonicity guard Adds .in('status', ['generated', 'exported']) to the row update so a delayed /agi/spara call after the cron (or interactive /agi/kvittenser) has already promoted the row to 'submitted'/'accepted' won't silently regress it back to 'pending_signature'. behandlingshistorik must advance only. - DONE_REJECTED / DONE_FAILED → status='rejected' /agi/kontrollresultat handler now flips the matching agi_declarations row to 'rejected' on a terminal SKV failure, using the same cached-submission-state lookup pattern /agi/spara already uses. Without this the row sat at 'generated' indefinitely even though SKV considered the underlag failed. Same monotonicity guard prevents regressing a successfully-filed row. - Deadline criterion: lönesumma, not omsättning AGIPanel pendingText. SFL 26 kap's relaxed-deadline criterion (17:e in Jan/Aug) is the employer's total taxable wages, not turnover. Internal reference (.claude/skills/swedish-payroll/references/agi-filing.md) used the colloquial "turnover"; statutory wording is "lönesumma". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): close round-4 audit-trail and UX gaps - agi_submitted_at: NULL when signeradTid absent Both /agi/kvittenser handler and the kvittens cron previously fell back to new Date().toISOString() if SKV's kvittens lacked signeradTid. Substituting wall-clock now() falsifies the filing moment in behandlingshistorik (BFNAR 2013:2 kap 8 / BFL 5 kap 6§). Now leaves the column NULL and logs a warning. Status flip to 'submitted' still happens — the audit gap was timing only. - Proactive missing-agd-scope banner SkatteverketConnectPanel and AGIPanel now warn when the stored token lacks the agd scope. Tokens issued before the agd rollout would otherwise 403 with invalid_scope at submission time, often too close to the AGI deadline. SkatteverketConnectPanel mirrors the existing "skattekonto saknas" pattern; AGIPanel surfaces a banner in the connected state and links to /settings/skatteverket. - Granskningsunderlag isError keys on tillstand only Previous check mixed HTTP 409 with the INCORRECT_DATA tillstand string. A future SKV addition like RECEIVING returned with HTTP 200 would have slipped through as awaiting_signing. Now keys solely on tillstand: only LOCKED_FOR_SIGNING / UNLOCKED are treated as signable; everything else (INCORRECT_DATA, RECEIVING, CALCULATING, SIGNING) routes to underlag_rejected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): close round-5 audit-trail and recovery gaps - agi_submitted_at: stamp with reconciliation time when signeradTid absent Round 4 left the column NULL on missing signeradTid to avoid falsifying the signing moment. Round 5 pointed out that NULL hides that the filing *occurred* — also a behandlingshistorik integrity violation. Resolution: presence of uuidKvittens proves SKV signed and accepted the AGI, so we stamp with signeradTid || now() and warn-log when fallback is used. Both /agi/kvittenser handler and the kvittens cron. - Persist signeradAv + full kvittens in agi_declarations.response_data submitted_by is the auth.users UUID we have on hand (the polling / reconciling user). The legally load-bearing signer identity is kvittens.signeradAv (a personnummer) — which the token user_id does NOT necessarily match (e.g. bookkeeper vs deklarationsombud). The existing response_data jsonb column now holds the full kvittens record, preserving signeradAv for the audit trail (BFL 5 kap 6§ / BFNAR 2013:2 kap 8) without a schema change. Cron path also marks reconciledBy='cron'. - /agi/spara monotonicity: allow recovery from 'rejected' Previously .in('status', ['generated', 'exported']) excluded rejected rows, so a successful re-submission after a prior rejection couldn't promote the row to pending_signature — it silently stayed rejected. The xml-route reuses the same agi_declarations row when re-generating XML, so this is the realistic recovery path. Added 'rejected' to the allowed-from list. 'submitted'/'accepted' still blocked (no regression from filed states). - Fix misleading agi-client.ts comment Claimed users could "fix the errors in Mina Sidor" after a DONE_REJECTED save. Mina Sidor doesn't expose in-place editing; the correct recovery is to regenerate XML and resubmit. Updated the agiSparaUnderlag JSDoc to describe the actual flow. - Deadline copy: "vars sammanlagda lönesumma understiger 40 MSEK" Reads more cleanly than "≤ 40 MSEK" and matches the phrasing the compliance reviewer suggested. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skatteverket): tighten /agi/spara guard and clarify deadline copy (round 6) - Drop 'exported' from /agi/spara allowed-from states Audit confirmed no code path writes status='exported' today; the value is preserved in the schema (and union) for the legacy manual-download path that no longer has a writer. Allowing the spara handler to flip an 'exported' row to 'pending_signature' would conflate two distinct filing attempts on a single row, weakening the chain of custody (BFL 5 kap 6§). Tightened to .in(['generated', 'rejected']) — same recovery path for re-submission after rejection, no path for the dormant state. - Deadline copy: explicit "per år" qualifier The 40 MSEK threshold is annual lönesumma, not per-payment. Adding "per år" closes the (admittedly thin) misread the compliance reviewer flagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f3fd4c0822 |
feat(salary, skatteverket): per-day absence + AGI Frånvarouppgift + skattekonto + hardening (#388)
* feat(salary): per-day absence tracking with calendar UX Replace aggregated-day absence counts with per-day records so payroll calculations can correctly enforce Swedish legal rules that depend on actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per rolling 12 months, day-8 läkarintyg flag, day-15 transition to Försäkringskassan. Adds: - salary_absence_days table (RLS, dedup unique on employee+date+type) - /api/salary/employees/[id]/absence CRUD route - deriveAbsenceLineItems helper that walks per-day records into sjuklöneperioder and emits correctly-classified line items, with the existing absence-calculator formulas reused for VAB / parental - Per-employee pay-spec detail page with month-grid AbsenceCalendar - Calculate route now derives line items from the calendar before running the salary engine, replacing the prior sumQuantity model - Salary run GET surfaces the formatted Skatteverket arbetsgivare ID so downstream UI can build extension URLs without a second round-trip - GET /salary/runs/[id]/employees/[employeeId] for the detail page Tests: 15 new unit tests covering segment merge, återinsjuknande within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag, VAB/parental semesterlönegrundande ceilings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag Three hardening fixes from the prior audit, plus a runtime extension toggle for phased rollout. api-client.ts: - Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a Swedish user message. The 4 req/sec local rate limiter normally prevents this, but the per-consumer gateway quota can still hit. - Extend the error union with TOKEN_CORRUPTED for the token-store fix below. token-store.ts: - Surface decryption failures instead of silently returning null. A rotated key or tampered ciphertext used to look like "not connected"; callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID" message and a structured log line for ops. Extension dispatcher (app/api/extensions/ext/[...path]/route.ts): - Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED is not exactly "true", the dispatcher returns 503 with code EXTENSION_DISABLED, letting ops disable a single integration mid- rollout without redeploying or removing it from extensions.config.json. UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an empty state. Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission AGI XML upgrade: - Emit <gem:Franvarouppgift> top-level blocks for VAB and parental leave events sourced from salary_absence_days, per SKV 4785 + technical doc. Element order matches the spec example file. TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental, with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for hours. Stable 1-based specifikationsnummer per (employee, period), date-sorted. Skipped entirely for periods before 202501. - Sick days are NOT emitted (they go to Försäkringskassan). - FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity × dailyRate × 0.80 instead of Math.abs(amount). The line-item amount is the net deduction (lostPay − sjuklon), not the cost, so the prior formula understated by a factor of four. AGI submission UI: - New AGIPanel mirroring SkatteverketPanel's validate → draft → lock → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED and renders a clear empty state. Replaces the bare "Skicka till Skatteverket" button on /salary/runs/[id], keeping the AGI XML download as a sibling for archival / manual upload fallback. - Salary run rows now link to the per-employee detail page added in the previous commit. Tests: 14 new agi-xml cases covering element order, type↔hour-field mapping, specifikationsnummer ordering, fractional-hour formatting, range clamping (0.01-24.00), period guard at 202501 boundary, placement after Blankett blocks, multi-employee date ordering, required-fields invariant, omission when no events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför Adds read-only Skattekonto v2.1 access via the existing BankID OAuth flow (extends the OAuth scope with `skattekonto`). Daily background sync pulls saldo + transactions, dedupes on (company_id, dedup_key), and surfaces the data in a /skattekonto dashboard plus a settings panel for connection management. Backend: - skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated SkatteverketSkattekontoError. - skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT on (company_id, dedup_key) so kommande rows graduate to tidigare in place. Dedup key uses transaktionsidentitet when available, else sha256 of (date|amount|text). Caches saldo snapshot in extension_data. Emits skattekonto.synced / balance.changed (sign flip) / transaction.upcoming (first appearance) / connection.expired. - skattekonto-booking.ts: keyword→counter-account rules with AB/EF differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650 for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for kostnads-/intäktsränta). Creates a draft journal entry against BAS 1630, leaves it for the user to review and commit. Throws NO_COUNTER_ACCOUNT instead of guessing when no rule matches. - Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company cooldown of 1 hour, time budget 50s, distinct `expired` status for token-exhaustion separate from generic errors. Database: - skattekonto_transactions: company-scoped with RLS, unique (company_id, dedup_key), indexed on (company_id, date DESC) and (company_id, status). journal_entry_id FK with ON DELETE SET NULL so a row can be re-bokförd after entry deletion. Frontend: - /skattekonto/page.tsx: dashboard with saldo card, transactions list (booked + upcoming), per-row "Bokför" action. - /settings/skatteverket: connection panel showing scope/expiry. - Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS). Tests: 9 booking-rule cases (counter-account guessing, AB/EF divergence, no-match throw) + 7 mapper cases (dedup key stability, sign convention, kommande→tidigare graduation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR review findings Build: - Fix Next.js build failure: Zod refuses .partial() on a refined schema. Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence DELETE handler with a fresh z.object that defines its own optional fields. Greptile findings (PR #388): - skattekonto_transactions UPDATE policy was missing WITH CHECK; without it a user could mutate company_id to one they don't belong to. Edit the original migration for fresh applies + add a follow-up migration that drops/recreates the policy with both clauses (already applied to prod via Supabase MCP). - FK499 TotalSjuklonekostnad now reads sjuklonRate from run.calculation_params (snapshot taken at calc time) instead of a hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is honored. Falls back to 0.80 for older runs without the snapshot. - Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the flag is server-side only. NEXT_PUBLIC_* vars are inlined into the client bundle at build time, which would create split-brain (server 503 vs client still rendering enabled flow) on a flag flip without redeploy. UI panels detect 503 by response code, not by reading the env directly, so no client-visible change is needed. - Add pg-real RLS smoke tests for both new tables (salary_absence_days and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH CHECK enforcement, unique-constraint enforcement, cross-tenant dedup key allowed. Swedish compliance review: - Document the högriskskydd cap interpretation in derive-absence-line-items.ts. We count *sjuklöneperioder* in the rolling 12-month window, matching the law's plain reading ("från och med den 11:e sjukperioden ... görs inget karensavdrag"). An alternative reading counts only periods that actually had karens deducted; that requires persisting per-period karens-deduction state, which gnubok doesn't yet do. The period-count reading can over- suppress, never under-suppress, so it's the safer default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/. Inline the saldoResponse + transaktionerResponse fixtures verbatim from the spec; the test still verifies our mappers + dedup-key logic against the same shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |