Commit Graph

17 Commits

Author SHA1 Message Date
Mattsson db8983ba9e Add/bokslut (#718)
* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:35:30 +02:00
Jakob Wennberg 0521c385d2 feat(transactions): underlag status badges + attach dialog; auto-expire stale pending ops (#712)
* feat(transactions): per-row underlag status + attach-document dialog

- New "Matcha mot underlag" dialog on /transactions (inbox pick or fresh
  upload), the tx→doc mirror of the Documents view's matcher
- Per-row Underlag/Underlag saknas badges on booked history rows, driven
  by computeJeUnderlagStatus — same posted-only, exemption-aware scope as
  the worklist count so badge and count never disagree
- attach-document route + commit dispatcher now propagate the doc onto
  the verifikation when the tx is already booked (BFL 5 kap 6 §), with a
  409 guard for docs consumed by a different verifikation, idempotent
  re-attach (no same-value rewrite under period lock), and an honest 409
  when the period-lock trigger blocks the propagation
- Booking-dialog doc links also pin the doc to the transaction row
  (first linked doc wins) via the link route's new transaction_id param

messages/{sv,en}.json also carries the strings for the pending-ops
expiry UI that lands in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(pending-operations): auto-expire stale staged operations after 30 days

- New daily cron (02:30 UTC, vercel.json + both docker crontabs) flips
  >30-day-old pending ops to rejected with the dispatcher's
  { auto_rejected: true, reason: 'expired' } result_data shape — rows are
  never deleted, the table is the audit trail
- /pending renders an "Utgick automatiskt" badge + detail line for these,
  orders terminal tabs by resolved_at so a fresh expiry sweep isn't
  buried, and adds a first-time-reviewer explainer
- Origin labels spell out where a proposal came from (AI chat, MCP key,
  API, cron) instead of the raw actor_label
- agent_chat actor type added to PendingOperationActorType/AuditLogEntry
  (DB CHECK already widened in 20260519090000) and to the agent filter
- ApprovalCard notes that ignoring a proposal is safe

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(mcp): surface the client telemetry marker in connect instructions

Tag the connector URLs shown in ApiKeysPanel, the connect-claude doc and
the gnubok-mcp README with ?client=<surface> (claude-connector /
claude-code) and GNUBOK_CLIENT=claude-desktop for the npm bridge.
Telemetry-only — the server already reads the param/header; this just
lets us measure which Claude surface connected.

The claude mcp add copy blocks quote the URL: an unquoted ? in the query
string trips zsh globbing ("no matches found").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: fix stale-closure badge flip + zod-validate link route body (PR #712)

- handleDocumentAttached read journal_entry_id off the render-time
  transactions snapshot; if the list changed while the attach dialog was
  open the optimistic badge flip was silently skipped. Read it off the
  dialog's own subject (attachDocTx) instead.
- POST /api/documents/[id]/link now validates the body against the new
  LinkDocumentSchema (uuid-strict, all four fields) instead of a bare
  presence check on journal_entry_id — same canonical VALIDATION_ERROR
  envelope. Test fixtures switched to real UUIDs accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:13:51 +02:00
Mattsson 64bbeb4021 Fixed user issues (#559)
* Fixed user issues

* feat: add personal_number column to customers for individual identification

* feat: add personal_number field to makeCustomer function for enhanced customer identification

* feat: add personal_number column with constraint check for customer identification
2026-05-22 12:22:15 +02:00
Mattsson e211ab31be UI/settings api mcp (#524)
* feat(voucher): add create voucher and correct entry previews; update commit methods

* feat: add support for pending operations in API key scopes and OAuth client management

- Introduced new API key scopes for reading and approving pending operations.
- Updated the scope groups to include pending operations.
- Added new tools for listing and managing pending operations.
- Implemented OAuth client registration and revocation endpoints.
- Created a UI panel for managing OAuth clients, including registration and revocation.
- Added tests for pending operations tools and OAuth allowlist functionality.
- Implemented a database migration for OAuth client registrations with appropriate policies and constraints.

* feat: Implement OAuth client registration rate limiting and enhance security measures

- Added IP-based rate limiting to the OAuth client registration endpoint to prevent enumeration attacks.
- Introduced a service-role client for allowlist lookups, ensuring trust boundaries are maintained.
- Updated error responses to be uniform across different types of redirect URI validation failures.
- Enhanced tests to reflect changes in OAuth scope handling, ensuring fallback to read-only scopes when no scopes are provided.
- Improved handling of high-risk pending operations, requiring explicit confirmation for approvals.
- Added audit logging for OAuth client revocations and pending operation approvals/rejections to maintain a security audit trail.
- Refactored API key scope management to include default read-only scopes for OAuth-issued keys and added segregation-of-duties checks.

* feat: add recurring invoice scheduling functionality

- Implemented recurring invoice schedules with a new database schema.
- Created API routes for managing recurring invoices (GET and POST).
- Added cron job to automatically generate invoices based on schedules.
- Developed service functions for computing next run dates and executing schedules.
- Added tests for the new functionality, including validation and success cases.
- Introduced error handling for various scenarios in the invoice creation process.

* feat: refine VAT rate validation and enhance recurring invoice handling
2026-05-19 13:48:32 +02:00
Jakob Wennberg e9e0fd726f feat(api): Phase 6 PR-1 — webhooks substrate (delivery pipeline + routes) (#496)
* feat(api): Phase 6 PR-1 — webhooks substrate (delivery pipeline + routes)

First half of the final API plan phase. Ships the webhook delivery
substrate end-to-end: schema, in-process fan-out from the event bus,
per-minute Vercel cron dispatcher with HMAC signing + exponential
backoff, and the seven v1 routes that let an integrator manage
subscriptions and replay failed deliveries. Mirrors the architectural
shape of Phase 4 PR #469 (new substrate + register routes + cron worker
+ audit table with immutability trigger).

Migration (supabase/migrations/20260515170000_webhooks_v2.sql):
- Repurpose automation_webhooks → webhooks. Drops the legacy
  UNIQUE (company_id, event_type) — multiple receivers per event are
  valid (Stripe pattern). Adds name, description, secret,
  created_by_api_key_id, api_version_pinned, disabled_at,
  disabled_reason. Backfills any pre-existing rows with a placeholder
  secret before the NOT NULL constraint is added.
- New webhook_deliveries table — pending|in_flight|delivered|failed|
  dead state machine, attempts + next_attempt_at fields for the
  dispatcher, response_status/body/headers capture for receiver-side
  debugging, partial-index on (next_attempt_at) WHERE
  status IN ('pending','failed') for the worker pickup.
- BFNAR 2013:2 kap 8 § immutability: BEFORE UPDATE trigger blocks
  writes when OLD.status IN ('delivered','dead'). The :retry route
  bypasses this by INSERTing a fresh row pointing at the same payload,
  never mutating the terminal one.
- RLS: members SELECT own-company deliveries; writes restricted to
  service role.

lib/webhooks/{handler,dispatcher,signing,diff}.ts:
- handler.ts subscribes to 24 public CoreEventTypes and inserts one
  webhook_deliveries row per active subscription matching
  (company_id, event_type). Wired into ensureInitialized() via
  registerWebhookHandler() so every API route that emits events also
  enqueues webhook deliveries — same module-level pattern as the
  supplier-invoice and event-log handlers.
- dispatcher.ts is the per-minute cron worker. Claims up to 50 due
  rows, POSTs each one with HMAC signature, updates row to delivered
  (2xx), failed (other → bumps next_attempt_at by exponential
  backoff), or dead (HTTP 410 OR attempts exhausted). HTTP 410
  additionally auto-disables the webhook. 10s request timeout, 4 KB
  response-body cap. Backoff: 1m / 5m / 30m / 2h / 12h / 24h / 48h
  (7 retries, ~72h total) — matches Stripe.
- signing.ts: Stripe-style X-Gnubok-Signature: t=<unix>,v1=<hex>
  with HMAC-SHA256 over `${t}.${rawBody}`. Constant-time verify with
  default 5-min tolerance window for the cookbook examples.
  generateWebhookSecret() returns 256 bits of crypto-random hex.
- diff.ts: computePreviousAttributes() for Stripe-style update events.
  Stubbed in PR-1 (every emit passes null); each route's emit() call
  site captures the prior row in a follow-up so receivers don't need
  a second GET.

v1 routes (app/api/v1/...):
- /companies/{companyId}/webhooks               GET (list) + POST (create)
- /companies/{companyId}/webhooks/{id}           GET / PATCH / DELETE
- /companies/{companyId}/webhooks/{id}/test      POST :test
- /companies/{companyId}/webhooks/{id}/deliveries GET (cursor-paginated)
- /webhook-deliveries/{id}/retry                 POST :retry

POST /webhooks generates the HMAC secret server-side and returns it
EXACTLY ONCE in the response — every subsequent endpoint omits it
(same shape as the existing api_keys table). Idempotency-Key required
on POST; dry-run supported.

PATCH active=false manually pauses (sets disabled_at + disabled_reason
= 'manually_disabled'); active=true clears the disable bookkeeping
that the dispatcher's HTTP-410 auto-disable may have set. event_type
is immutable — delete and recreate to change.

POST /webhook-deliveries/{id}/retry lives outside /companies/{id}/
because callers reference deliveries by id; tenancy is enforced
inside the handler via company_members lookup. Re-enqueues by INSERT
(immutability trigger blocks in-place mutation), so the original row
stays in the audit log.

/api/webhooks/dispatch/cron:
- withCronContext-wrapped, CRON_SECRET-guarded.
- Returns dispatch summary { picked, delivered, failed, dead } in the
  body so an operator can grep Vercel logs to see per-tick throughput.
- Per-minute schedule added to vercel.json (* * * * *).

lib/auth/scopes.ts: webhooks:manage scope (already in API_KEY_SCOPES
since the catalogue placeholder was added pre-Phase-6) extended with
:test, :deliveries, and :retry route entries.

Substrate-only by design. The PR's review-round commits will add:
- claim_due_webhook_deliveries(p_now, p_limit) SQL function for
  proper FOR UPDATE SKIP LOCKED claim (current select-then-update
  has a tight CAS race window that the partial index narrows but a
  SQL function tightens further).
- Integration tests under
  app/api/v1/companies/[companyId]/webhooks/__tests__/ covering list,
  create-returns-secret-once, list-never-returns-secret,
  PATCH active toggle, DELETE cascade, :test enqueue, :retry rejects
  non-terminal status, IDOR (cross-company), missing-Idempotency-Key,
  scope-deny.
- *.pg.test.ts for the immutability trigger (CLAUDE.md mandate for
  any PR touching a trigger / RLS policy).
- 30-day TTL cleanup cron for webhook_deliveries (same shape as the
  existing event_log cleanup at /api/events/cleanup/cron).

Phase 6 PR-2 ships the docs polish (cookbook suite, error reference,
signature-verify samples in Node + Python, versioning + deprecation
policy, llms-full.txt rebuild, spec-snapshot test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-496 review round 1 — 4 real bugs + retention FK

Fixes the 4 real bugs Greptile flagged on the round-1 review of the
Phase 6 PR-1 webhooks substrate, plus the swedish-compliance-bot
finding about 7-year audit retention on accounting-event delivery
rows. Compliance Swarm noise items are documented inline (see end of
this commit body) rather than ping-ponged.

FIXED — real bugs:

1. **dispatcher: SELECT-then-UPDATE double-delivery race**
   (lib/webhooks/dispatcher.ts:claimDueDeliveries)

   The previous implementation returned the full SELECT result set
   regardless of whether the CAS UPDATE actually claimed any rows.
   Per-minute Vercel cron has best-effort single-instance semantics —
   under load (50 deliveries × 10s timeout = up to 500s > 60s) the
   next tick can fire while this one is still running and pick up the
   same SELECT batch. Both ticks would then dispatch the same
   deliveries.

   Fix: have the UPDATE return the IDs it actually claimed via
   `.select('id')`, intersect with the candidate set, and only dispatch
   that intersection. The CAS guard `(status IN ('pending','failed'))`
   ensures at most one tick wins for any given row.

2. **dispatcher: `clearTimeout` called before response body read**
   (lib/webhooks/dispatcher.ts:attemptDelivery)

   The AbortController timeout was cleared before `readBoundedText`,
   so a slow body stream could stall the entire serial dispatch batch
   indefinitely. Fix: move the clearTimeout to a `finally` block AFTER
   the body read so the abort stays armed across the whole HTTP cycle.

3. **signing: `verifySignature` throws RangeError on invalid hex**
   (lib/webhooks/signing.ts)

   The guard compared hex-string lengths before calling timingSafeEqual,
   but `Buffer.from(v1, 'hex')` silently drops invalid hex bytes — a v1
   that is the right hex length (64 chars for SHA-256) but contains
   non-hex characters decodes to a SHORTER buffer than `expected`.
   timingSafeEqual then throws RangeError instead of returning false.
   Receivers using this helper to verify inbound webhook signatures
   would crash on a forged or corrupted header instead of cleanly
   rejecting it.

   Fix: compare buffer lengths AFTER decoding.

4. **GET /webhooks response shape mismatch**
   (app/api/v1/companies/[companyId]/webhooks/route.ts)

   The handler passed a flat array to `paginated()`, producing
   `data: [...]`, but the registered WebhooksListResponse schema and
   the inline example both document `data: { webhooks: [...] }`. Any
   client built against the spec would not find the expected key.

   Fix: switched from `paginated()` (which is for top-level array
   payloads) to `ok()` and wrapped as `{ webhooks: data ?? [] }` to
   match the schema. The webhook-count ceiling per company is bounded,
   so dropping cursor pagination on this surface is fine for v1.0.

FIXED — swedish-compliance:

5. **Webhook DELETE no longer destroys accounting-event audit trail**
   (supabase/migrations/20260515180000_webhook_deliveries_retention.sql,
    app/api/v1/companies/[companyId]/webhooks/[id]/route.ts,
    lib/webhooks/dispatcher.ts)

   swedish-compliance-bot flagged that ON DELETE CASCADE on
   webhook_deliveries.webhook_id let a webhook DELETE silently remove
   terminal delivery rows that constitute behandlingshistorik for
   accounting events (journal_entry.committed, period.locked,
   salary_run.booked, agi.generated, ...). BFNAR 2013:2 kap 8 §
   requires 7-year retention of these rows.

   Fix: new migration changes the FK to ON DELETE SET NULL and makes
   webhook_id nullable. Webhook DELETE now leaves the delivery audit
   trail in place — it just loses the back-reference to the no-longer-
   existing webhook row. The dispatcher SELECT was updated to filter
   `webhook_id IS NOT NULL` so dangling pending/failed rows go dormant
   in the audit trail rather than retrying against nothing.
   Documentation updated on the DELETE route header + endpoint
   description + pitfall list to reflect the new semantic.

FIXED — defense in depth:

6. **Retry route: re-verify webhook still belongs to caller's company
   immediately before INSERT** (app/api/v1/webhook-deliveries/[id]/retry/route.ts)

   Compliance Swarm V8.2.1 (medium) flagged that the retry endpoint
   verified tenancy via the delivery's company → company_members
   lookup, then INSERTed a fresh delivery without re-checking that the
   parent webhook still existed in that company at INSERT time. A
   webhook deleted between the membership check and the INSERT would
   have left a dangling row; a webhook re-registered to a different
   company would let the caller redeliver to a webhook they never
   created.

   Fix: explicit re-fetch of the webhook scoped to (id, company_id)
   immediately before INSERT, with NOT_FOUND if the webhook is gone
   or VALIDATION_ERROR if it's been disabled.

DEFERRED — documented inline:

- **OWASP V14.2 plaintext webhooks.secret**: Inline rationale added
  to lib/webhooks/signing.ts:generateWebhookSecret(). Outbound HMAC
  signing requires the original byte sequence on every delivery, so
  one-way hashing is precluded by definition. Stripe / GitHub / Slack
  / Twilio all follow the same pattern. Defense in depth: service-
  role-only writes on webhooks, column-level select projection on
  every read endpoint (the row never includes secret outside the
  create response), Supabase encryption-at-rest. Re-evaluate when
  KMS-backed signing becomes available without per-call latency cost.

- **Compliance Swarm V13.2 cron uses CRON_SECRET only**: false
  positive — matches the documented Vercel cron pattern used by
  every other cron in the project (deadlines, invoice reminders,
  document verify, sandbox cleanup, event log cleanup, ...).

- **Compliance Swarm V1.2 cursor pagination injection**: false
  positive — `decodeDefaultCursor` in lib/api/v1/pagination.ts
  already validates `ts` against a strict ISO 8601 regex and `id`
  against a UUID regex, returns null otherwise. The bot couldn't
  see the helper's internals.

- **Compliance Swarm V8.2.1 retry-route TOCTOU on tenancy** (high):
  the secondary company_members lookup is deliberate — the route
  lives outside /companies/{id}/ tree because callers reference
  deliveries by id (already noted in the file header). The defense-
  in-depth tightening at INSERT time (item 6 above) closes the
  practical TOCTOU window. Round-2 may add an atomic DB function if
  swarm escalates this.

- **Compliance Swarm V2.4 no rate limits on :test / :retry**: defer
  to Phase 6 PR-2 alongside the per-route rate-limit pass we owe
  across the v1 surface (Phase 3 deferral list).

- **Compliance Swarm V16 audit logging on webhook secret generation
  / deletion**: defer to Phase 6 PR-2 (audit-event durability is on
  the Phase 6 architectural-floor list per Phase 4 lessons-learned).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-496 review round 2 — SSRF, tenancy, retention triggers

Round 2 of the Phase 6 PR-1 review cycle. Compliance Swarm went 23 → 24
between rounds (oscillation pattern documented in Phase 4 lessons). This
commit fixes 7 real items, four of them surfaced by the round-1 commit
opening up new attack surfaces / new audit gaps.

FIXED:

1. **SSRF: webhook_url HTTPS-only + private/loopback/link-local/CGNAT/
   metadata IP rejection** (V12.1, V1.2, CC6.6)

   New helper lib/webhooks/url-guard.ts validates webhook_url at three
   layers:
     - Zod schema (Create + Patch) rejects non-https before the handler
       runs.
     - Route handler runs validateWebhookUrl() which performs DNS lookup
       and rejects IPs in 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16
       (link-local + AWS/GCP/Azure metadata 169.254.169.254 explicitly
       classified), 100.64/10 (CGNAT), 0/8, plus IPv6 ::1, fc00::/7,
       fe80::/10, and IPv4-mapped IPv6 ::ffff:<v4> via recursive
       reclassification.
     - Dispatcher re-runs the same check immediately before each
       outbound POST — DNS rebinding / record swap between webhook
       creation and dispatch is the common bypass and the create-time
       check alone is insufficient. A failure at dispatch time marks
       the delivery dead with reason='url_unsafe:<class>' AND auto-
       disables the webhook.

   The dispatch-time check adds one DNS lookup per delivery, which is
   acceptable on the per-minute cron with batches up to 50.

2. **Cross-tenant dispatch refusal** (A.8.3)

   loadWebhooksByIds now selects company_id alongside id/webhook_url/
   secret. The dispatch loop asserts webhook.company_id ===
   delivery.company_id BEFORE signing. A poisoned delivery row pointing
   at another tenant's webhook (compromised service-role write, future
   buggy code path) is refused with status='dead' and
   reason='cross_tenant_mismatch' rather than dispatched with the wrong
   tenant's secret.

3. **DB-level invariants for retention + tenancy**
   (supabase/migrations/20260515190000_webhook_deliveries_db_guards.sql)

   Two triggers the application can never bypass:
     - block_webhook_delivery_terminal_delete (BEFORE DELETE): raises
       check_violation when OLD.status IN ('delivered','dead'). Closes
       the BEFORE UPDATE-only loophole the round-1 immutability trigger
       left open. BFNAR 2013:2 kap 8 § retention is now enforced
       against DELETE as well as UPDATE.
     - assert_webhook_delivery_company_match (BEFORE INSERT): raises
       check_violation when NEW.company_id doesn't match the parent
       webhooks.company_id. Mirrors the application-layer dispatcher
       assertion at the database boundary so even a misbehaving
       service-role caller can't enqueue a cross-tenant delivery.
       webhook_id IS NULL bypasses the check (dangling rows from
       webhook DELETE under the round-1 ON DELETE SET NULL FK have no
       parent to compare against).

4. **Stuck in_flight row recovery** (operational, swedish-compliance note)

   Before claiming new rows, dispatcher sweeps in_flight rows whose
   updated_at is older than 2× REQUEST_TIMEOUT_MS back to 'failed'
   with next_attempt_at = now. A cron killed mid-flight (Vercel
   function timeout, hard crash, manual termination) would otherwise
   leave rows marked in_flight forever, violating the audit trail's
   "every row reaches a terminal state" invariant.

   2× REQUEST_TIMEOUT_MS gives an unambiguous "this is stuck, not
   in-flight" boundary — a live attempt cannot exceed
   REQUEST_TIMEOUT_MS plus the body read.

5. **Response-body content-type filter + header allowlist**
   (CC7.2, A.8.12, Art.32(1)(b))

   readBoundedText now drops response_body unless Content-Type starts
   with text/plain or application/json — receivers returning HTML error
   pages routinely echo PII, request bodies, or stack traces back from
   their error renderers, all of which would land in our delivery audit
   log otherwise. Bytes are still drained so the connection stays
   reusable.

   headersToObject now filters to a small allowlist (content-type,
   content-length, date, server, x-request-id, cf-ray). Set-Cookie,
   Authorization, WWW-Authenticate, and vendor x-* headers are dropped
   before persistence.

6. **Test payload data minimisation** (Art.25(2))

   The :test event payload no longer includes api_key_id. The
   X-Gnubok-Delivery header on the outbound request already correlates
   to the audit trail on the gnubok side, so the receiver gains nothing
   from seeing an internal credential identifier.

7. **Silent-drop log promoted to error** (PI1.3)

   handler.ts:fanOutToWebhooks logs at error (not warn) when an event
   payload is missing companyId. Every CoreEvent payload variant types
   companyId as required, so a missing value indicates an emit-site bug
   that silently breaks webhook delivery — must be visible in
   monitoring, not buried in routine warn-noise.

DEFERRED (remaining oscillation, documented in commit body):

- **V14.2 / Art.5(1)(f) plaintext webhooks.secret**: documented inline
  in lib/webhooks/signing.ts as accepted-risk per Stripe / GitHub /
  Slack precedent. The bot will continue to flag it every round; the
  documented decision is the established pattern. KMS integration is a
  cross-cutting concern that touches the auth layer too — not a Phase 6
  PR-1 scope.
- **Art.5(1)(e) 90-day TTL cleanup cron for non-accounting deliveries**:
  on the deferred list, ships in Phase 6 PR-2 docs/cron suite.
- **V2.4 rate limits on :test and :retry**: deferred to Phase 6 PR-2
  alongside the v1-wide rate-limit pass (Phase 3 deferral list).
- **V16 audit log on webhook secret/delete lifecycle**: deferred to
  Phase 6 PR-2.
- **A.8.24 plaintext secret in migration backfill log**: false positive,
  the migration comment notes "no production rows" so no real backfill
  ever runs.

Compliance Swarm count expected to drop from 24 → ~10–14 on round 3 as
the SSRF + cross-tenant findings clear together. Architectural floor is
the V14.2 plaintext-secret oscillation + V16 audit-event-durability
(deferred to PR-2) — that's the merge-ready signal per Phase 4 lessons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-496 review round 3 — 5 fixes + migration consolidation

Compliance Swarm went 24 → 16 (5 high / 8 medium / 3 low) after round 2,
clearing the SSRF + cross-tenant cluster as predicted. Round 3 closes
the remaining real items, leaving the architectural floor (V14.2
plaintext, V16 audit log, V2.4 rate limits, Art.5(1)(e) TTL — all
deferred to Phase 6 PR-2). That's the documented merge-ready signal.

FIXED:

1. **Deliveries list — webhook ownership pre-check** (V8.2.1 medium)

   GET /webhooks/{id}/deliveries already filters by (company_id,
   webhook_id) so a cross-tenant id returns nothing, but emitting an
   explicit 404 when the webhook doesn't belong to the caller's company
   matches the pattern used for :retry and :test (round 2 fix not
   propagated to deliveries) and gives a clean signal vs a confusing
   empty list. Defense in depth alongside RLS.

2. **url-guard: enumerate ALL DNS records** (V1.2 medium)

   Replaced single dns.lookup with parallel dns.resolve4 + dns.resolve6.
   A hostname with two A records [public, private] returns either
   non-deterministically per call — single-lookup validation could
   return the public IP at create time and the private IP at dispatch.
   Multi-record enumeration rejects if ANY resolved address is unsafe.

   Per-family ENODATA / ENOTFOUND is normal (v6-only or v4-only host)
   and treated as "no records of that family" rather than hard failure;
   other DNS errors propagate. New 'no_dns_records' reason for the case
   where neither family resolves anything.

   The DNS-rebinding window between dispatch-time validation and the
   actual fetch remains — closing it requires a custom HTTPS agent that
   pins the resolved IP, tracked for follow-up. Multi-record enumeration
   shrinks the practical bypass surface substantially.

3. **markDead no longer stamps delivered_at** (swedish-compliance)

   delivered_at means "the receiver acknowledged the event". For dead
   rows (HTTP 410, attempts exhausted, webhook deleted, cross-tenant
   mismatch, unsafe URL) the receiver did NOT acknowledge — leaving
   delivered_at NULL keeps audit semantics clean. An auditor querying
   `WHERE delivered_at IS NOT NULL` correctly sees only genuinely
   delivered rows. The terminal-state timestamp lives on `updated_at`
   (auto-stamped by the table's BEFORE UPDATE trigger).

4. **Elevated scope check for salary/agi event subscriptions**
   (swedish-compliance, GDPR Art.32)

   Subscribing to salary_run.* or agi.generated routes personnummer +
   lönesummor + skatteavdrag to an external receiver — payroll-grade
   exposure. POST /webhooks now requires BOTH webhooks:manage AND
   payroll:read for these event types. A key minted only for webhook
   management can no longer reach the payroll surface; integrators
   building payroll integrations must mint a key with the payroll scope
   alongside webhook management.

   The check uses a regex (^salary_run\.|^agi\.) so future payroll
   event types automatically inherit the gate. Same pattern will
   extend to other sensitive event families when they ship.

5. **Migration consolidation: fold retention into 170000**
   (swedish-compliance)

   The round-1 retention migration (20260515180000) was a follow-on
   that ALTERed the FK from ON DELETE CASCADE to ON DELETE SET NULL.
   swedish-compliance flagged that if 170000 ever applied in isolation
   (rollback of 180000, partial replay), CASCADE would silently delete
   accounting-event audit rows.

   Edited 170000 to declare the FK with ON DELETE SET NULL and
   nullable webhook_id directly. Deleted 180000. Migration 190000
   (DB guards from round 2) updated to reference 170000 as the source
   of the SET NULL FK. All in-code references to "20260515180000"
   updated to "20260515170000" (DELETE route header, dispatcher
   comments).

   Net result: a single migration shipping a correct table from the
   start, no chained ALTER, no isolation risk.

DEFERRED (architectural floor, all bound for Phase 6 PR-2):

- **V8.2.1 retry ctx.userId may be null for API-key callers**: false
  positive — validateApiKey unconditionally returns a real userId; the
  wrapper sets ctx.userId = auth.userId for every authenticated call.
- **V1.2 DNS rebinding TOCTOU between validate and fetch**: high-effort
  proper fix needs a custom HTTPS agent that pins the resolved IP. The
  multi-record check substantially shrinks the practical bypass window;
  full closure tracked for PR-2 hardening.
- **V16.1 cross-tenant log not in security-event taxonomy**: this
  project doesn't have a separate security-event log substrate —
  log.error with structured fields is the established pattern.
- **V4.3 dispatch summary in cron response body**: same shape every
  other cron uses (deadlines, invoice reminders, document verify, ...).
  CRON_SECRET-gated; project pattern.
- **V5.3 / Art.5(1)(f) response_body returned to API callers**: already
  addressed by round-2 content-type filter — only text/plain or
  application/json gets persisted. Residual oscillation; the bot didn't
  see the new filter.
- **Art.5(1)(e) 90-day TTL non-accounting deliveries**: Phase 6 PR-2
  cron suite.
- **Art.32(1)(b) / V14.2 plaintext webhooks.secret**: established defer,
  documented inline in signing.ts (Stripe / GitHub / Slack precedent).
- **swedish-compliance company_id FK CASCADE**: system-wide pattern
  (every per-company table cascades on company delete). Cross-cutting
  compliance decision, not webhook-specific.
- **swedish-compliance period.unlocked emitted before DB commit**:
  cross-cutting refactor of the entire event-bus emit pattern across
  every v1 route. Project-wide concern, not Phase 6 PR-1 scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-496 review round 4 — 2 critical fixes + 5 hardening

Two critical items + 5 supporting hardening fixes. The criticals were
both surfaced after round 3 — one by the Supabase preview build, one by
swedish-compliance — and would have caused real failures in production.

CRITICAL:

1. **Supabase Preview reconciliation broken by round-3 migration deletion**

   Round 3 deleted supabase/migrations/20260515180000_webhook_deliveries_
   retention.sql after folding its FK fix into 170000. The Supabase
   preview branch had already applied 180000 and tracks the set of
   applied remote migrations — when a previously-applied filename
   disappears locally the preview build fails with "Remote migration
   versions not found in local migrations directory".

   Fix: restored 180000 with the original idempotent ALTER content. On a
   fresh install 170000 creates the FK with SET NULL directly so
   180000's ALTER is a no-op (DROP IF EXISTS + ADD with the same
   constraint shape). On the existing preview branch the second run is
   also a no-op — the FK already has the SET NULL shape from the
   original 180000 application. Idempotent retro-application is
   intentional; documented in the file header.

2. **`recoverStuckInFlight` queries a column that doesn't exist**

   swedish-compliance bot caught that lib/webhooks/dispatcher.ts:
   recoverStuckInFlight filters `.lt('updated_at', stuckBefore)` against
   webhook_deliveries.updated_at, but migration 170000 never declared
   the column. The query would return zero rows at runtime; stuck
   in_flight rows would stall forever, breaking the BFNAR 2013:2 kap 8 §
   audit-log completeness guarantee that every delivery row must reach
   a terminal state.

   Fix: new migration 20260515200000_webhook_deliveries_updated_at.sql
   adds the column with NOT NULL DEFAULT now() and wires it to the
   project-wide update_updated_at_column() trigger function. The new
   trigger runs BEFORE UPDATE — the immutability check_violation guards
   from migrations 170000 + 190000 fire FIRST on terminal rows, so no
   audit-row mutation can occur via the timestamp bump.

HARDENING:

3. **Dispatcher: fetch redirect: 'error'** (V1.2 medium)

   A receiver returning 3xx could redirect the dispatcher to a
   private/internal address AFTER the SSRF guard validated the original
   webhook_url. Pass redirect: 'error' so any redirect throws and the
   delivery enters the failed/retry path with a clean diagnostic.
   Receivers that legitimately move endpoints should ask integrators
   to update the webhook URL via PATCH.

4. **Defensive ctx.companyId early-return** (V8.2.1 medium)

   The deliveries list route used `ctx.companyId!` non-null assertion.
   The wrapper guarantees companyId for routes inside /companies/{id}/,
   but a misconfiguration would silently produce `WHERE company_id =
   NULL` (always-empty result) rather than a hard auth failure. Added
   an explicit early INTERNAL_ERROR return when ctx.companyId is
   falsy. Drops the `!` everywhere in the file.

5. **Per-delivery structured logs** (V16 low)

   Added info/warn-level outcome logs at the dispatch loop boundary
   with deliveryId, webhookId, companyId, eventType, attempt fields.
   Per-tenant audit-trail reconstruction now works from log
   aggregation alone without grepping individual mark*-helper writes.
   Failure types (delivered / failed / dead) emit at correct levels;
   webhook auto-disable surfaces as a distinct warn line.

6. **Strip userId from outbound webhook payloads** (Art.5(1)(c))

   New minimisePayload() in handler.ts drops the internal Supabase
   auth.users.id UUID before insert into webhook_deliveries. The
   companyId stays (it's the tenant scope, useful for multi-tenant
   receivers). Centralising the projection means future tightening
   (e.g. stripping personnummer fields from payroll payloads if those
   ever land in the payload shape) goes here, not per-emit-site.

7. **Migration legal citations** (swedish-compliance precision)

   swedish-compliance noted the citations conflated BFL 7 kap (the
   7-year retention period) with BFNAR 2013:2 kap 8 § (audit-log
   integrity). Both apply but they're distinct grounds. Updated
   comments in 170000 and 190000 + the trigger error message in 190000
   to cite both correctly.

REMAINING DEFERS (architectural floor — Phase 6 PR-2 territory):

- **V14 / Art.32 plaintext webhooks.secret**: established defer per
  Stripe / GitHub / Slack precedent; documented inline in signing.ts.
- **V8.2.1 retry endpoint userId may be null for API-key callers**:
  false positive — validateApiKey unconditionally returns a real
  userId; ctx.userId is always set after auth.
- **V1.2 DNS rebinding TOCTOU between validation and fetch()**: high-
  effort fix needs a custom HTTPS agent that pins the resolved IP.
  Multi-record check (round 3) + redirect: 'error' (this round)
  substantially shrink the practical bypass window. Full closure is
  Phase 6 PR-2 hardening.
- **V2.3 dry-run rate limiting**: Phase 6 PR-2 with the v1-wide
  rate-limit pass.
- **V16.1 cross-tenant log not in security-event taxonomy**: project
  doesn't have a separate security-event log substrate.
- **Art.9 DPIA entry for outbound payroll webhooks**: out-of-repo
  documentation work, tracked separately.
- **Art.5(1)(e) 90-day TTL non-accounting deliveries**: Phase 6 PR-2
  cron suite.
- **swedish-compliance company_id FK CASCADE**: system-wide pattern;
  cross-cutting decision, not webhook-specific.
- **swedish-compliance period.unlocked emit-before-commit**: cross-
  cutting refactor of every v1 route's event-bus emit timing.

Compliance Swarm count expected to drop materially as the V1.2 +
V8.2.1 + V16 cluster clears. If the next round plateaus at the
documented architectural floor (~5–9 findings, all in the deferred
list above), that's the merge-ready signal per Phase 4 lessons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-496 review round 5 — 5 small fixes (audit gaps + hardening)

Round 5 closes the actionable items round 4 surfaced. Compliance Swarm
went 16 → 23 between rounds (severity dropped — 0 critical, 5 high, 10
medium, 8 low — the bot is now surfacing low-severity items it skipped
before; classic plateau approach). Round 5 fixes 3 real gaps + 2
documentation-precision items, all small.

FIXED:

1. **`request_id` populated at every webhook_deliveries INSERT site**
   (swedish-compliance — BFNAR 2013:2 kap 8 § behandlingshistorik)

   The webhook_deliveries.request_id column was declared in migration
   170000 with the documented intent of correlating each delivery row
   back to the originating API request, but no INSERT call site ever
   set it — the column was always NULL, breaking audit-trail traceback.

   - test/route.ts and retry/route.ts now stamp ctx.requestId.
   - handler.ts:fanOutToWebhooks (the async fanout from the event bus)
     can't recover the originating request id — the event bus emit is
     decoupled from the route's request context. Synthesised a
     'whfan_<uuid>' batch correlation id so the column is never NULL
     and rows from the same emission can be grouped. Threading the
     originating request_id through the event payload itself is a
     future-direction improvement (would require touching every emit
     site across the v1 surface).

2. **Retry route re-runs minimisePayload before INSERT** (A.8.12 medium)

   The retry endpoint was inserting o.payload verbatim — a delivery
   from before the round-4 minimisation tightening would have its
   unminimised payload re-delivered on retry. minimisePayload exported
   from handler.ts; retry now applies it. Idempotent on already-
   minimised payloads, so no semantic change for current data.

3. **Stuck-recovery sweep guarded against terminal-row race**
   (swedish-compliance — operational integrity)

   recoverStuckInFlight filtered status='in_flight' but Postgres applies
   the predicate to the CURRENT row state at UPDATE time. A row that
   raced from in_flight to delivered/dead between SELECT and UPDATE
   would be picked up by the bulk UPDATE; the BEFORE UPDATE
   immutability trigger would then raise check_violation, aborting the
   ENTIRE bulk UPDATE statement and leaving legitimately stuck rows
   unrecovered.

   Added `.not('status', 'in', '(delivered,dead)')` as defense in
   depth. The sweep is now safe across mixed batches even when one
   row terminalizes mid-flight.

4. **'server' header dropped from response_headers allowlist** (A.8.12 low)

   Receiver infrastructure version strings (nginx/1.21.6, Apache/2.4.41,
   ...) carry no diagnostic value but routinely leak into a multi-
   tenant audit table. Removed from SAFE_RESPONSE_HEADERS.

5. **Migration citations narrowed: don't over-claim BFL on non-accounting
   rows** (swedish-compliance — legal precision)

   The immutability triggers apply uniformly to all terminal delivery
   rows, but BFL 7 kap 1 § retention only applies to rows derived from
   räkenskapsinformation (journal_entry.*, period.*, salary_run.booked,
   agi.generated, invoice.paid, supplier_invoice.paid). For non-
   accounting events (customer.created, document.uploaded,
   transaction.categorized, webhook.test) the same lock applies as
   gnubok's operational audit-log integrity policy — NOT as a BFL
   obligation. Updated comments in 170000 and the trigger error
   message in 190000 to draw the distinction; BFNAR 2013:2 kap 8 §
   audit-log integrity continues to apply uniformly.

REMAINING DEFERS (architectural floor — Phase 6 PR-2):

- V14 / Art.32 / V9.1 / A.8.24 / CC6.1 plaintext webhooks.secret
  (5 separate findings of the same documented-defer item; established
  Stripe / GitHub / Slack precedent inline in signing.ts).
- V8.2.1 retry endpoint userId may be null for API-key callers — false
  positive, validateApiKey unconditionally returns userId; bot has
  re-flagged 5 rounds in a row (entrenched oscillation).
- V1.2 cursor pagination injection — false positive, decodeDefaultCursor
  validates ISO 8601 + UUID via regex.
- V13 cron secret verification — false positive, withCronContext
  validates Authorization: Bearer.
- V1.2 DNS rebinding TOCTOU — high-effort fix needs custom HTTPS agent
  pinning resolved IP. Multi-record check (round 3) + redirect: 'error'
  (round 4) substantially shrink the practical window. Phase 6 PR-2.
- V2.4 rate limits on :test / :retry — Phase 6 PR-2 v1-wide pass.
- V16.1 / A.8.15 / A.8.16 / CC7.2 SIEM / log drain / monitoring —
  out-of-repo infra, tracked separately.
- Art.5(1)(e) 90-day TTL non-accounting deliveries — Phase 6 PR-2.
- Art.9 DPIA entry for outbound payroll webhooks — out-of-repo doc.
- Art.25(2) payload field-level redaction (response_body for payroll
  events) — defensive defer; current emit-site payloads don't carry
  personnummer or salary fields per the CoreEvent type definitions.
- swedish-compliance company_id FK CASCADE — system-wide pattern,
  cross-cutting decision.
- swedish-compliance period.unlocked emit-before-commit — cross-
  cutting refactor of every v1 route's event-bus emit timing.
- PI1.3 SELECT-then-UPDATE claim race — already addressed in round 1
  with the CAS-then-intersect pattern. Bot's recommended SQL function
  approach is the documented round-1 follow-up.

Compliance Swarm count expected to plateau in the 12–18 range — all
remaining items either deferred to PR-2, recurring oscillation false
positives, or cross-cutting concerns outside the webhook surface.
That's the documented merge-ready signal per Phase 4 lessons-learned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-496 review round 6 — 3 small fixes (last actionable items)

Closes the 3 genuinely-new actionable items round 5 surfaced. Every
remaining swarm finding now falls into one of: established Phase 6 PR-2
defer (V14 plaintext, V2.4 rate limits, V1.2 DNS rebinding, Art.5(1)(e)
TTL, V16/A.8.15/A.8.16/CC7.2 SIEM), oscillation false positive (V8.2.1
retry userId, V1.2 cursor, V13 cron secret), already-addressed (Art.5(1)(c)
response_body content-type filter, response_headers allowlist, BFL
citation narrowing), or cross-cutting (FK CASCADE, period.unlocked emit
timing, plaintext secret variants × 5).

FIXED:

1. **`granted_scopes` removed from INSUFFICIENT_SCOPE response details**
   (Art.5(1)(f) medium)

   POST /webhooks elevated-scope error echoed the API key's full scope
   set back to the caller and into ctx.log structured fields. Required
   scope alone is sufficient for the caller to understand what they
   need; the granted set is sensitive and should not surface in error
   envelopes or logs.

2. **Redirect error → terminal `dead` + auto-disable** (CC6.7 medium)

   Round 4's redirect: 'error' on fetch causes the runtime to throw a
   TypeError when the receiver returns 3xx. The catch was mapping it to
   retryable 'failed', so a stubborn-redirect receiver burned all 8
   retry attempts (~72h) before going dead. Detect the redirect-shaped
   error message and short-circuit to dead + auto-disable, mirroring
   the HTTP 410 treatment. Operator surfaces the misbehaving receiver
   immediately rather than after three days of log noise.

   Detection uses /redirect/i on the error message — Node's undici has
   used several wordings ('unexpected redirect', 'redirect mode is set
   to error', etc.) across versions; case-insensitive substring is the
   stable shape.

3. **Retry route re-runs `validateWebhookUrl` against current URL**
   (CC6.6 medium)

   The retry handler verifies the webhook's existence + active state +
   tenancy match, but never re-ran the SSRF guard against the webhook's
   CURRENT url. A URL changed via PATCH between the original delivery
   and this retry call would slip a fresh delivery row into the queue
   that the dispatch-time guard would only catch on the next cron tick.
   Validating in the retry handler refuses the request up-front with
   VALIDATION_ERROR — the audit trail gets a clean refusal rather than
   a deferred 'dead' row with reason='url_unsafe'.

REMAINING (architectural floor — not blocking merge):

- 5 plaintext webhooks.secret findings (V14 / V11.1 / Art.32 / A.8.24 /
  CC6.1) — established Stripe / GitHub / Slack precedent, documented
  inline in signing.ts.
- V8.2.1 retry endpoint userId may be null for API-key callers — false
  positive, validateApiKey unconditionally returns userId. Bot has
  re-flagged 7 rounds in a row.
- V1.2 cursor pagination injection — false positive, decodeDefaultCursor
  validates ISO 8601 + UUID via regex.
- V13 cron secret verification — false positive, withCronContext
  validates Authorization: Bearer.
- V8.2.1 deliveries cross-webhook leak — false positive, bot
  acknowledges the .eq('webhook_id') filter handles it.
- V1.2 DNS rebinding TOCTOU — Phase 6 PR-2 (custom HTTPS agent that
  pins resolved IP).
- V2.4 rate limits on :test / :create / :retry — Phase 6 PR-2 with
  v1-wide rate-limit pass.
- V16.1 / A.8.15 / A.8.16 / CC7.2 SIEM / log drain / monitoring —
  out-of-repo infra.
- Art.5(1)(c) response_body / response_headers — already addressed by
  round-2 content-type filter + round-2 allowlist + round-5 'server'
  drop.
- Art.5(1)(e) 90-day TTL non-accounting deliveries — Phase 6 PR-2.
- Art.25(2) per-event-type field projection (personnummer / lönesummor)
  — current CoreEvent type definitions don't carry these fields;
  defensive defer.
- Art.9 DPIA / RoPA entries for outbound webhooks — out-of-repo doc.
- A.8.28 computePreviousAttributes diff — previous_attributes is null
  in PR-1; populated in follow-up.
- A.5.17 / V11.1 secret in response logged — depends on whether the
  logging middleware captures response bodies (it doesn't, per project
  pattern). Defensive defer.
- CC9.2 TLS validation / CC3.2 credential-pattern scrub — out-of-scope
  hardening.
- swedish-compliance company_id FK CASCADE — system-wide pattern,
  cross-cutting decision.
- swedish-compliance period.unlocked emit-before-commit — cross-
  cutting refactor of every v1 route's event-bus emit timing.
- swedish-compliance non-terminal accounting row delete — defensible:
  pending/failed transition to terminal within minutes; blocking
  deletes there would prevent legitimate cleanup.
- swedish-compliance BFL citation in trigger error message — addressed
  in round 5 (narrowed to "audit-log integrity policy" with BFL only
  attaching to accounting-event rows).

If round 7 plateaus or the count drops, that's the merge-ready signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:00:04 +02:00
Jakob Wennberg 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>
2026-05-05 17:08:29 +02:00
Jakob Wennberg 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>
2026-05-04 19:01:21 +02:00
Mattsson bb855d2ddc Add/ai native supp (#385)
* feat(branding): implement dynamic branding in service worker and reports

* feat(auth): enhance API key scopes and add bookkeeping write scope

- Updated transaction write scope description to include additional tools.
- Enhanced reports read scope description to reflect new functionality.
- Introduced bookkeeping write scope with relevant description.
- Updated SCOPE_GROUPS to include bookkeeping domain.
- Modified TOOL_SCOPE_MAP to include new bookkeeping operations.
- Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution.

feat(tests): add unit tests for MCP resource registry

- Created tests for data resources to ensure all required fields are present.
- Added tests for resource query parsing and retrieval.

feat(resources): implement MCP resources for company and accounting data

- Added capabilities resource to expose API key capabilities based on granted scopes.
- Implemented chart of accounts resource to retrieve active BAS chart.
- Created company current resource to fetch active company details.
- Developed active fiscal period resource to check posting eligibility.
- Implemented recent activity resource to fetch latest journal entries, invoices, and transactions.
- Added VAT treatments resource to provide available VAT rates per customer type.

feat(pending-operations): introduce risk tiers for operations

- Added risk level classification for pending operations to determine auto-commit eligibility.
- Implemented functions to classify operation risk levels and identify high-risk operations.

feat(migrations): add actor model and risk tier to pending operations

- Updated pending_operations table to include actor type and risk level columns.
- Enhanced audit_log to mirror actor information for compliance.
- Modified validate_and_increment_api_key function to return actor details.
- Expanded operation types in pending_operations to include new high-risk operations.

* feat: add auto-commit functionality for low-risk pending operations

- Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings.
- Created commitPendingOperation function to handle execution of pending operations with consistent status updates.
- Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds.
- Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality.
- Added SQL migration to update the database schema for new auto-commit settings.

* feat(idempotency): implement idempotency key handling for safe retries and cleanup

* feat: expand API key scopes and pending operations for bookkeeping

- Added 'suppliers:write' scope to API key scopes for supplier invoice management.
- Updated SCOPE_GROUPS to include the new 'suppliers:write' scope.
- Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice.
- Implemented corresponding commit functions for the new operations in the pending operations module.
- Enhanced PendingOperation type to include actor model and risk level attributes.
- Added tests for new functionality, ensuring proper behavior and constraints in the database.

* feat: implement unlockPeriod functionality and related tests

* feat: add agent auto-commit settings and related functionality

* feat: add attention resource with comprehensive summary of outstanding tasks

* feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
2026-05-04 11:12:29 +02:00
Mattsson 7a0214c053 feat: implement cloud backup auto-sync feature with scheduling (#280)
* feat: implement cloud backup auto-sync feature with scheduling

- Added a new cron route for auto-syncing Google Drive backups hourly.
- Introduced a schedule management system for enabling/disabling auto-sync and setting the sync hour.
- Updated the logo upload API to handle logo file management more efficiently.
- Created a public storage bucket for company logos with appropriate size and type restrictions.
- Enhanced the LogoUpload component to validate file types and sizes during upload.
- Added tests for the new auto-sync functionality to ensure correct behavior under various conditions.

* Update extensions/general/cloud-backup/lib/sync.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update app/api/settings/logo/route.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* refactor: remove unused parameters from saveExtensionData function

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-20 13:42:37 +02:00
Jakob Wennberg b484e9a7b4 fix: Swedish VAT/SIE compliance, storno hardening, document integrity (#209)
* feat: add INK2 declaration improvements, invoice delivery date, and Swedish compliance skills

Expand INK2 engine with full INK2S/INK2R support and improved SRU generation.
Add delivery_date field to invoices and corresponding PDF/migration support.
Add Claude skills for Swedish asset accounting, invoice compliance, SIE import/export, SRU filing, and tax planning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — map BAS 4500–4899, strip CRLF in SRU, document P3

- Map BAS accounts 4500–4599 (legoarbeten), 4700–4899 (diverse
  varuinköpskostnader) to SRU 7512 so they are not silently dropped
  from INK2R declarations
- Strip \r\n in sanitizeString to prevent CRLF injection in SRU fields
- Document P3 period suffix limitation for brutet räkenskapsår

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct BAS 4500-4599, 4700-4899 mapping from 7512 to 7511

Per the official BAS-to-SRU mapping, these account ranges are cost of
goods (legoarbeten, inkurans, svinn) and belong under 7511 (Råvaror
och förnödenheter), not 7512 (Handelsvaror). 7512 remains 4600-4699.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Swedish VAT compliance — representation VAT, domestic RC, full BAS 26xx mapping, SIE encoding

- Representation expenses now default to reduced_12 VAT (ML 13 kap 24-25 §§);
  income tax deduction was abolished 2017 but VAT deduction at 12% remains
- Domestic reverse charge (byggtjänster etc.) uses 2647 instead of 2645,
  with distinct line descriptions for Swedish vs EU/non-EU RC
- VAT declaration maps all BAS 26xx variant accounts (egna uttag 2612/2622/2632,
  uthyrning 2613/2623/2633, VMB 2616/2626/2636, import 2615/2625/2635,
  domestic RC 2647, frivillig skattskyldighet 2642) and revenue variants
  (3108/3105/3004/3100) to correct momsdeklaration rutor
- SIE parser: remove unreliable #FORMAT PC8 encoding detection (most software
  exports UTF-8 with PC8 header), parse #FLAGGA for import-already-done warning,
  default SIE type to 1 when absent, fix RTRANS/BTRANS documentation
- SIE export: add #RAR -1 (previous fiscal year), fix UB = IB + movements
- Error messages: add pattern matching for locked period trigger errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — update ruta49 JSDoc, use null sentinel in error map

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: harden storno CAS guard, document integrity, and BFNAR archive compliance

- Storno: defer original→reversed until both entries succeed, add CAS guard
  for concurrent reversals, use cancelEntry() instead of delete
- Document: add document.accessed event, enrich archive manifest with metadata,
  add BFNAR 2013:2 systemdokumentation to full archive export
- Verify cron: run daily, configurable batch size, include company_id in audit
- Migrations: integrity audit actions, document version chain, metadata
  immutability, audit deletions, fix immutability for posted/cancelled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — allow is_current_version in immutability trigger, log cancelEntry errors

- Remove is_current_version from blocked fields in enforce_document_metadata_immutability
  trigger so create_document_version RPC can supersede documents linked to posted entries
- Add error logging to cancelEntry for observability on cleanup failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:12:03 +02:00
Jakob Wennberg f3ae3cd361 feat: event log, pending operations, and MCP staging (#135)
* feat: event log, pending operations, and MCP staging

- Event log system: persist bus events to event_log table for external
  automation platforms. Batch insert for transaction.synced. Daily
  cleanup cron at 02:00 UTC.
- Pending operations: MCP write tools (categorize, create customer,
  create invoice) now stage to pending_operations instead of executing
  directly. Users review and commit/reject from /pending in the web UI.
- Granskning page: card-based review UI with expandable previews,
  commit/reject dialogs. Only shown in nav when pending ops exist.
- Commit route re-executes using core lib functions (no extension
  imports). Guards against stale state (double-commit, deleted entities).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: stage new MCP write tools after main merge

Add staging for 4 new write tools from #133:
- mark_invoice_paid, send_invoice, mark_invoice_sent,
  match_transaction_invoice
- Expand pending_operations CHECK constraint
- Add commit executors with full execution logic
- Add UI labels and generic preview component
- Remove confirm parameter from categorize (single-call staging)
- Fix UUID in pending op title (fetch transaction description)
- Hide Granskning nav when no pending ops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback

- Fix TS build error: use `select('*, customer:customers(*)')` for
  match_transaction_invoice to avoid array type inference
- Add status guard to commitSendInvoice (prevents duplicate sends)
- Replace auth.admin.getUserById with user email from session auth
- Restore optimistic lock check in commitMatchTransactionInvoice
- Fix tool description typo: expense_software → expense_office

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:07:36 +01:00
Mattsson 109f860e22 Sandbox (#8)
* feat: add sandbox infrastructure — migration, types, and middleware

Add database migration for sandbox support:
- Add `is_sandbox` boolean column to company_settings
- Update 4 enforcement trigger functions (journal entry immutability,
  journal entry line immutability, retention enforcement, document
  deletion blocking) to bypass checks for sandbox users
- Add `cleanup_sandbox_user()` SECURITY DEFINER function that handles
  FK-safe deletion order (document_attachments → journal_entry_lines →
  journal_entries → supplier_invoices → auth.users cascade)
- Add `cleanup_expired_sandbox_users()` function that loops over
  sandbox users older than N hours with per-user error handling

Update TypeScript types:
- Add `is_sandbox: boolean` to CompanySettings interface
- Add `is_sandbox: false` to makeCompanySettings() test factory

Update middleware:
- Add `/sandbox` to public routes so the landing page is accessible
  without authentication

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add sandbox landing page, seed API, cleanup cron, and banner

Sandbox landing page (app/sandbox/page.tsx):
- Client component matching the existing auth page aesthetic
- Auth check: if logged in as real user, shows message to use incognito
- Otherwise shows feature overview (invoices, transactions, bookkeeping,
  reports) with "Starta sandbox" button
- On click: signInAnonymously() → POST /api/sandbox/seed → redirect
- Uses window.location.href for full page load (ensures middleware
  picks up new session cookies)

Seed API (app/api/sandbox/seed/route.ts):
- POST handler gated to anonymous users only (403 for real users)
- Idempotent: returns { seeded: false } if company_settings exists
- Seeds ~40 rows: profile, company_settings (is_sandbox: true,
  onboarding_complete: true), chart of accounts (via RPC),
  fiscal period, 3 customers (Swedish business, EU business,
  individual), 4 invoices (paid/sent/overdue/draft), 4 invoice
  items, 2 posted journal entries with 5 lines, 8 transactions
  (3 categorized, 2 income, 3 uncategorized), 2 deadlines
- Journal entries inserted directly (not via engine) to avoid
  event emission, using next_voucher_number() RPC

Cleanup cron (app/api/sandbox/cleanup/cron/route.ts):
- GET handler with CRON_SECRET Bearer token auth
- Creates service role Supabase client
- Calls cleanup_expired_sandbox_users RPC (24h default)

Sandbox banner (components/dashboard/SandboxBanner.tsx):
- Amber bar with dismiss button (client state, reappears on reload)
- Text: "Sandlådemiljö — dina data raderas automatiskt efter 24 timmar"
- "Skapa konto" link to /register

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate sandbox into dashboard — banner, nav, settings safeguards

Dashboard layout (app/(dashboard)/layout.tsx):
- Fetch is_sandbox from company_settings
- Render SandboxBanner at top of page for sandbox users
- Pass isSandbox prop to DashboardNav
- Hide RecaptIdentify analytics for sandbox users

Root page (app/page.tsx):
- Same sandbox banner and isSandbox prop treatment as dashboard layout
  (root page has its own layout, not wrapped by (dashboard)/layout)

DashboardNav (components/dashboard/DashboardNav.tsx):
- Add optional isSandbox prop
- Change logout button text to "Avsluta sandbox" when isSandbox
- Redirect to /sandbox instead of /login on logout for sandbox users
- Applied to both desktop sidebar and mobile drawer logout buttons

Settings page (app/(dashboard)/settings/page.tsx):
- Hide "Bank (PSD2)" tab entirely for sandbox users — prevents
  connecting real bank accounts from a temporary anonymous session
- Hide "Radera konto" card for sandbox users — account auto-deletes
  via cron, and the delete flow requires email confirmation

Vercel config (vercel.json):
- Add sandbox cleanup cron at 04:00 UTC daily
  (/api/sandbox/cleanup/cron)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove audit trigger for non-existent tax_codes table

Migration 018 referenced public.tax_codes which was never created
(migration 012 is a placeholder). This caused failures when running
migrations from scratch on a fresh database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove ALTER FUNCTION for 3 non-existent functions

Removed search_path pinning for create_invoice_with_items,
seed_asset_categories, and update_reconciliation_session_counts —
none of these functions were ever created in any migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove ALTER for generate_invoice_number (created in later migration)

The function is created in migration 20260306 with search_path already
set, but migration 20260304 tried to ALTER it before it existed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fixed redirect issue

* Update app/api/sandbox/seed/route.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update app/api/sandbox/seed/route.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update app/sandbox/page.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Fixed catch block issue

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-11 14:54:53 +01:00
Jakob Wennberg 13725ffc16 feat: production readiness — 3-extension deploy with security hardening and observability
- Strip extensions to enable-banking, ai-categorization, ai-chat only
- Remove push-notifications cron from vercel.json
- Add security headers (HSTS, CSP, X-Frame-Options, Permissions-Policy)
- Add /api/health endpoint for uptime monitoring
- Add env var validation in ensureInitialized()
- Fix SIE4 #IB opening balance records from year-end closing entry
- Replace in-memory ai-chat rate limiter with Supabase-backed distributed rate limiting
- Add Sentry error tracking scaffolding (@sentry/nextjs, instrumentation hook)
- Add AI token usage tracking (migration 047, usage-tracker, wired into both AI extensions)
- Include pending enable-banking and dashboard improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:32:43 +01:00
Jakob Wennberg 885f362a29 feat: add bank file import as core, move Enable Banking to extension
Replace PSD2 bank integration as the default with file-based bank
import (CSV/XML), which better suits Swedish sole traders and small
companies. Enable Banking is now an opt-in extension.

- Phase 1: Extract generic transaction ingestion service (ingest.ts)
  with dedup, auto-categorization, and OCR-based invoice matching
- Phase 2: Bank file parser library supporting Nordea, SEB, Swedbank,
  Handelsbanken CSV formats and ISO 20022 camt.053 XML
- Phase 3: Database migration adding import_source, reference columns
  and bank_file_imports tracking table
- Phase 4: Import wizard UI (5-step flow) and API routes for parse/execute
- Phase 5: Move Enable Banking to extensions/enable-banking/ with
  commented-out loader entry for opt-in activation
- Phase 6: 104 new tests (ingestion + all parser formats), fixing
  Nordea detection overlap and camt.053 XML tag collision bugs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:21:28 +01:00
Jakob Wennberg 838dc6b8b5 refactor: clean up codebase, remove dead code and obsolete docs
Remove influencer-era documentation, unused components, boilerplate
assets, and ghost tiktok cron job. Add supplier invoice management,
document API routes, and PWA icons. Replace boilerplate README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:23:17 +01:00
Jakob Wennberg e8743e6e03 New base 2026-02-19 14:32:54 +01:00
Jakob Wennberg a25d10a528 Initial copy from influencer-biz 2026-02-13 14:00:46 +01:00