Commit Graph

38 Commits

Author SHA1 Message Date
Mattsson b68c082ef5 feat(bank-sync): close the F2 report: gap backfill, consent and paused chip states, agent-triggered sync (#2165)
* fix(bank-sync): cron backfills the gap since the last successful sync

The daily incremental sync always asked the bank for the last 7 days. Any
pause longer than that (a lapsed subscription paid again, a consent renewed
after expiry, an outage) silently lost the days in between: the connection
came back, looked healthy, and the missing transactions never arrived.

The lookback now widens to cover the gap since last_synced_at plus one day
of overlap, capped at the 90-day PSD2 limit, and a gap of a month or more
asks for strategy=longest like the manual sync route does. Dedup via
external_id makes the overlap harmless. First syncs keep their 90-day path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS

* feat(bank-sync): chip warns seven days before a bank consent expires

The transactions-page chip only reacted once a connection was already dead
(expired/error) or had gone stale. A consent that is about to end looked
healthy until the morning it stopped syncing. New "expiring" state when a
live connection's consent_expires is within seven days, the same threshold
as the consent-expiry email in the sync cron. Precedence: attention,
expiring, stale, healthy.

getChipState moves to lib/transactions/bank-sync-chip-state.ts so the
precedence is unit-tested; the component keeps the rendering only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS

* feat(bank-sync): chip says paused when the subscription lapsed

The daily cron filters connections by the bank_sync capability, so a
company whose trial or subscription ended keeps status=active rows with a
frozen last_synced_at. The chip read that as "stale, check the connection",
which sends the user to re-authorise a connection that is perfectly alive.
56 of 191 active connections on prod were in this state on 2026-09-01.

New "paused" state, ranked above everything else, when the company lacks
bank_sync: hosted points at billing, self-host at the connector key, the
same split BankSyncNowButton already makes. getChipState takes an options
object so the clock stays out of render (react-hooks/purity).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS

* feat(api): agent-triggerable bank sync in v1 and MCP

Closes the first wish in the F2 report: an integration could read bank
data but never refresh it. New POST /api/v1/companies/{id}/bank-connections/
{connectionId}/sync and MCP gnubok_sync_bank, both on a shared runner
(extensions/general/enable-banking/lib/trigger-sync.ts).

Cost is bounded structurally, not by policy: the window is never
caller-controlled (the cron's gap-aware 7 to 90 day lookback), a connection
synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at
(429 + Retry-After on v1; synced=false in-band on MCP so the agent reads on
instead of retrying), and a failing connection is throttled per process by
attempt time. A dead session is flipped to expired with a remediation that
hands the user the connect link: no API call revives a consent.

Gated on bank_sync like gnubok_connect_bank; scope transactions:write.
Registry, scope map, load-routes, spec snapshot and the generated
accounted-api skill updated; five BANK_SYNC_* / BANK_SESSION_EXPIRED codes
added to the structured-error registry. The web Synka-nu route is left as
is (see DECISIONS.md).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS

* test(bank-sync): use the options object in the remaining chip-state calls

Four multi-line calls still passed the clock positionally after
getChipState moved to an options object; tsc flagged them (vitest did not,
the extra argument was ignored at runtime).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS

* fix(api): address skeptic findings on the agent-triggered bank sync

Three refutations from the pre-publish skeptic pass:

1. Core imported the extension. The v1 sync route pulled the runner
   straight from @/extensions, which the core-build gate rejects and which
   left a live bank endpoint on zero-extension builds. The route now
   resolves it through the registry's services channel against a contract
   in lib/bank-sync/trigger-sync-contract.ts (same pattern as the
   Skatteverket read service) and answers EXTENSION_DISABLED when the
   extension is absent.

2. The idempotency cache stored the handler-level 429. A same-key retry
   after Retry-After, which is the documented retry, replayed the stale
   cooldown as a 400 for the cache's 24-hour TTL. withApiV1 no longer
   caches 429 responses; regression test added. The endpoint's pitfall no
   longer claims Idempotency-Key is mandatory (it was never enforced).

3. Two cron tests read the clock twice and failed whenever a millisecond
   passed between the reads. They now pin the clock with fake timers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS

* fix(bank-sync): durable cooldown lease and review wording

Resolves the PR #2165 review findings in one pass.

Superagent P1: the attempt throttle was a process-local Map, so two agent
calls on different serverless instances (or a retry after a cold start on
a failing connection) could each bill an Enable Banking call, contradicting
the one-sync-per-15-minutes promise. New bank_connections.sync_lease_until
(migration 20260902150000), claimed with one conditional UPDATE before the
bank is called; Postgres row locking makes exactly one claimer win, the
rest answer BANK_SYNC_COOLDOWN. The lease stays for the full window on
success and failure. Tests cover the claim order, a failed attempt seen
from a second instance, a lost race, and an expired lease.

CodeRabbit: the =1 plural branch now reads "in 1 day" / "om 1 dag"
(daysUntilConsentExpiry rounds a partial day up, so "tomorrow" could be
today); the cooldown pitfall on the v1 endpoint, the MCP description and
the in-band cooldown instruction now say a cooldown can follow a failed
attempt and tell the agent to compare last_synced_at before deciding.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub

* fix(bank-sync): lease claim as a literal filter for the schema guard

CI's no-phantom-columns guard counts runtime-built query expressions and
its ceiling is exact; the templated `.or('sync_lease_until.is.null,...')`
claim added one. The column now defaults to epoch (NOT NULL), so "never
claimed" is just "expired long ago" and the atomic claim is a single
literal `.lte('sync_lease_until', now)` the guard can check. Migration is
unshipped (same PR), so it is edited in place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub

* fix(bank-sync): runner verifies company membership before the lease

Superagent (round 3): the MCP path reached the shared runner without a
membership check of its own. Both callers do enforce it upstream
(withApiV1's company resolution and resolveMcpCompanyContext in the MCP
dispatcher), but the runner writes transactions and bills a bank call, so
it now checks company_members itself, before the cooldown and the lease
claim, and answers NOT_FOUND for a non-member. The viewer check that was
buried inside the sync block moves up with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:17:41 +02:00
Jakob Wennberg 18cbc4c30a fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)
* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables

Security audit 2026-09-01, critical items.

- api_keys INSERT requires user_id = auth.uid() again (an admin could
  forge a key for any co-member and act as them in every company they
  belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the
  identity and credential columns against user-session UPDATEs.
- rotate_mcp_refresh_token and validate_and_increment_api_key become
  service_role only: they match rows by a presented SHA-256, so a hash
  readable by co-members was a bearer credential.
- validate_and_increment_api_key fails closed when the key's user is no
  longer a member of the key's company.
- provider_consent_tokens and provider_otc: the DELETE policies collapsed
  to "caller has any team row" (correlated subquery on a non-existent
  team_members.company_id). All member policies dropped; service_role
  only, matching every existing code path.

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

* fix(security): role gates, ownership guards and posting integrity in the database

Security audit 2026-09-01, high items at the database layer.

- One table-level guard, enforce_company_writer_role(), blocks the
  read-only viewer role on 55 company-scoped tables including through
  the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role
  claim so it fires inside definer bodies; no-op for service_role and
  trigger cascades.
- company_members user_id/company_id immutable from user sessions;
  invitations can never grant owner; team_members gains a transition
  guard (admins keep non-owner role moves); companies team_id and
  archiving are owner-only and team attachment needs team membership.
- Direct statements (current_user = authenticated) can no longer insert
  posted headers, add lines under posted verifikat, or post a draft with
  a voucher number the sequence never issued. Sanctioned RPCs run as the
  definer and are untouched; the engine's own draft-then-post shapes
  still pass.
- create_document_version refuses viewers and foreign storage paths;
  validate_version_chain needs membership and loses anon EXECUTE;
  match_documents / match_booking_templates lose anon; cron maintenance
  RPCs become service_role only; the production-only
  seed_asset_categories is dropped.

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

* build: pin tsx as an exact devDependency instead of fetching it with npx at build time

prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker
and CI build downloaded tsx@latest and its transitive tree from the
registry with no integrity check, inside the build environment.

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

* fix(security): refuse the viewer role on API-key and MCP write paths

The v1 wrapper and the MCP company routing checked company membership
but never role, and both run as service role, so a read-only viewer
holding an API key could post vouchers and change settings through the
API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY
for viewers on v1; MCP write tools refuse viewers the same way.

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

* fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin

Uploads persisted the browser-declared mime type and the inline proxy
served it verbatim, sandboxing only text/html; the storage proxy
forwarded the uploader's Content-Type. Any writer, or any Peppol sender,
could plant a scripted SVG or XHTML that executed on app.gnubok.se.

- inline route: allow-list of natively safe types (PDF, raster images)
  served as before; everything else gets the opaque sandbox CSP.
- storage proxy: octet-stream + attachment + sandbox unless the DB
  mime for the key is on the allow-list.
- document-service: the stored mime is the magic-byte validated type.
- logo upload: magic-byte validation, SVG refused.

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

* fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG

Same pattern as the company logo route: the logos bucket is public, so a
scripted SVG (or anything declared as an image) must never land there.
The upload pickers stop advertising SVG.

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

* fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user

The callbacks resolved the pending row by oauth_state alone, so a
victim who completed an attacker-initiated consent had their bank
account, merchant account or store attached to the attacker's company.
requireFlowInitiator() now requires the cookie session of the user who
started the flow: no session redirects to login with the callback URL
preserved, a different user is refused and nothing is exchanged.

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

* fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter

WooCommerce and Shopify syncs fetched a member-editable store URL with
plain fetch() and redirect following under the service role, and the
invoice PDF renderer fetched company_settings.logo_url unguarded. All
three go through a new safeFetch() (public-IP validation via url-guard,
https only, redirect: 'manual', body size cap) and re-normalise the
stored host at use time. checkRateLimit() keeps failing open on hosted
but logs one error per process when Upstash is not configured and
exports isRateLimiterConfigured().

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

* fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie

getAuthenticatorAssuranceLevel() without arguments derives nextLevel
from session.user.factors, which comes from the unsigned sb-*-auth-token
cookie. Deleting factors from the cookie made an enrolled account look
like it had nothing to step up to, on every /api route and in
requireAuth. Both gates now read factors from the getUser() result or
listFactors() and the level from the verified JWT claim, and fail closed
on errors. Page-branch gate hardened the same way.

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

* fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user

The arcim-migration callback exchanged the provider code onto whatever
consent the one-time state named, with no check of who completed the
flow and no org-number comparison, so a phished Fortnox admin handed
their ledger to the attacker's company. provider_otc now records the
initiating user (migration 20260902100000); the callback requires that
session and, after the exchange, refuses a provider company whose org
number differs from the consent's company. The Gmail and Skatteverket
callbacks enforce the same initiator check.

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

* fix(security): BankID signup confirms the email before linking the identity

Signup created an email-confirmed, MFA-exempt account for any address
the caller typed and returned a magic link, so an attacker could
pre-register a victim's email and keep a permanent BankID login into the
account the victim later adopted. The user is now created unconfirmed,
the identity carries email_verified_at NULL (migration 20260902101000),
bankid_linked is not set until the mailed confirmation is clicked, and
BankID login of a pending identity is refused with the confirmation
re-sent.

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

* fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes

A user-registered redirect URI was allowlisted globally, the consent page
named no client, and all scopes were pre-checked, so one phishing link
handed an attacker a full-scope key for the victim's company. Registered
URIs now resolve only for the registrant or a colleague sharing a
company; the consent page shows the client identity and redirect host;
non-built-in clients default to read-only pre-checks; scopes are capped
by the user's role (viewer: read only) at consent and at /token.

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

* fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log

- register client handles the new confirmation_sent response from BankID
  signup with the existing inbox screen instead of calling verifyOtp.
- BankID login surfaces the email_unconfirmed explanation.
- WooCommerce settings map woocommerce_error=wrong_user to its own copy.
- Logo help text no longer advertises SVG.
- DECISIONS.md records the audit remediation choices.

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

* fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them

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

* test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts

Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down
by the one legacy error the change removed.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:38:30 +02:00
Mattsson b56da5d6c5 feat(api): expose bank-connection freshness in MCP and v1 REST (#2124)
* feat(api): expose bank-connection freshness in MCP and v1 REST

gnubok_connect_bank now returns last_synced_at, consent_expires and
error_message per connection, and its instructions tell the agent to
flag stale or expiring connections. New read-only endpoint
GET /api/v1/companies/{companyId}/bank-connections exposes the same
fields to API-key integrations (scope companies:read).

Background: a user's PSD2 feed died silently in July; bookkeeping
looked complete while three weeks stale, and nothing on the API/MCP
surface could reveal it. Sync stays cron-driven; an agent-triggerable
sync was considered and deferred (see DECISIONS.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix

* fix(api): address skeptic findings on bank-connection freshness

- Map the bank-connections group into skills/accounted-api (apiskill:check
  crashed on the unmapped group; regenerated skill files included).
- Gate the v1 route on the bank_sync capability, mirroring the MCP twin:
  a lapsed entitlement now answers with a capability error instead of
  status=active with a frozen last_synced_at.
- Reword MCP instructions + v1 pitfalls: null last_synced_at right after
  connecting is normal, staleness threshold aligned to the UI's 36 hours,
  and re-authorisation is only advised for expired/error/consent-out, not
  for stale-but-active connections (lapsed subscription or deselected
  accounts are the usual causes there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix

* fix(mcp): keep gnubok_connect_bank schema under the tools/list token ceiling

The enriched outputSchema plus the worked examples that landed on main
(#2100) pushed the projected tools/list payload 20 tokens over the
61.6K context-budget ceiling. Drop the per-property descriptions from
the new freshness fields; the instructions string (runtime output, not
catalog payload) already explains them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:54:55 +02:00
Mattsson cd40127f0e feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API (#2118)
* feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API

The PSD2 sync has fetched the bank's reported balance for years but the
data was stranded (F7): the Bank-page source picker read a cash_accounts
column no sync ever updated (frozen at connect time), reconciliation
hard-coded external_balance to null for bank accounts, and neither MCP
nor the v1 API exposed any balance at all, so the only path to a current
bank balance was logging into the bank.

- getAccountBalance now returns booked + available from the same
  quota-limited BALANCES response (previously all but one type discarded)
- every sync (manual + cron) mirrors balance, available_balance and
  balance_updated_at into cash_accounts, fixing the stale picker
- new cash_accounts.available_balance column (additive migration)
- reconciliation bank kind: external_balance = bank-reported balance,
  plus bank_reported_* fields and fetch timestamp in the bank block;
  difference math stays movement-based and untouched
- reconciliation view shows "Saldo enligt banken ... hamtat {date}"
- MCP gnubok_list_cash_accounts returns the three balance fields; the
  cash_today prompt now reports the bank's figure instead of teaching
  agents to answer with the bookkept 19xx balance
- new GET /api/v1/companies/{companyId}/cash-accounts endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewu46quXgh9LSr9UwYusxm

* fix(bank): keep external_balance null for bank sign-offs; never fabricate a zero balance; guard the mirror against stale writers

Post-review fixes from the skeptic pass + CodeRabbit on PR #2118:

- external_balance stays null for the bank reconciliation kind: sign-off
  persists it into account_reconciliations and bokslutsbilagor computes
  closing - external from that row, so a today-balance stored on a
  balansdag sign-off printed a phantom warning-red differens in the
  year-end appendix. The bank-reported figure lives only in the
  timestamped bank_reported_* pair in the bank block, and only when its
  fetch timestamp exists (a balance of unknown age is suppressed).
- AccountOverview no longer falls back to today's date when the balance
  timestamp is missing; the line is omitted instead.
- getAccountBalance returns null on an empty BALANCES response instead
  of fabricating amount 0 with a fresh timestamp; sync keeps the
  previous stored value.
- updateBalancesFromSync only writes over an older-or-missing
  balance_updated_at, so an older sync run finishing later cannot move
  the mirrored balance backwards.
- The inline initial backfill (picker save) now mirrors fetched
  balances into cash_accounts too (accounts_data is deliberately not
  re-written there).
- cash_today MCP prompt mentions the gnubok_call_tool bridge for hosts
  that only see the default catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewu46quXgh9LSr9UwYusxm

* fix(bank): express the stale-writer guard as two literal predicates for the schema guard

The .or() with a template literal pushed the no-phantom-columns
unresolvable-expression count over its ceiling. Same semantics, two
updates: one for rows with an older timestamp, one for rows with none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewu46quXgh9LSr9UwYusxm

* fix(bank): rank interimBooked (ITBD) as a booked balance type before the generic fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewu46quXgh9LSr9UwYusxm

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 16:16:29 +02:00
Mattsson aabddb592f feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099)
* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy

Multiple people in one company becomes a paid capability (multi_user, the
eighth PAID key). Derived at access time from capability_grants, no status
column, no enforcement cron:

- entitled: active grant (trial/stripe/team/manual/comp), everyone works
- grace: newest grant expired < 20 days ago; countdown banner for everyone
  in companies with > 1 user; invites still allowed
- frozen: only role=owner resolves; other memberships go dormant (rows
  untouched, paying reactivates instantly); invites 403 with paid-plan upsell

Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin
untouched: they also run on self-hosts, where the gate never bites), gated
query fallback for service-role/API-key paths, setActiveCompany guard, MCP
company-access check, invite route. Middleware routes all-frozen users to a
new /paused page; the switcher greys locked companies.

Migration 20260901081417 (applied to staging): trial trigger seeds
multi_user, backfills for mid-trial companies, active Stripe subs, team
agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20
days) for existing unpaid multi-member companies. Daily cron mails owners at
grace start and last day. Strings in sv+en; pg-real + unit tests included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): multi-user seat gate hardening from skeptic review

- Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting
  it: the 20-day grace window hangs on an expired row, so a deleted one
  froze churned payers' staff instantly with no banner and no mail. Other
  stripe grants keep the freeze-and-retain delete.
- New SECURITY DEFINER company_multi_user_state() RPC (migration
  20260901083726, applied to staging) and RPC-first getMultiUserState:
  capability_grants RLS hides team-scoped rows from non-team users, so
  user-client reads misread byra-covered companies as frozen (switch
  refusal, wrong switcher locks).
- Byra-kind teams get a standing team-scoped multi_user grant (backfill +
  teams trigger): byra client companies have no company-scoped trial by
  design, so a grantless byra team would freeze every consultant and
  client user.
- Comped/manual companies with active PAID-key grants extend to multi_user
  (a comped company must not read as paying while locking out user two).
- /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403).
- PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user
  rows; the gated fallback would have frozen every non-owner mid-deploy).
- Grace cron: covers team-scoped lapses (byra agreement ending) and skips
  the start mail for the hand-mailed grandfather cohort.
- Tests updated/added across all touched surfaces; pg tests for the new
  RPC and byra trigger; trial-suppression pg test extended to 8 keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): decouple seat-gate env check and fail open on gate read throws

CI round 1 on #2099:
- isMultiUserEnforced no longer imports has-capability: several route test
  suites partially mock that module and the vitest mock guard threw from
  inside the v1 seat gate, turning expected 4xx responses into 500s.
  multi_user is never a connector capability, so the bypass reduces to the
  same env reads, now inlined.
- getMultiUserState wraps its resolution in a fail-open try/catch: a client
  without .rpc or a thrown network error must never lock users out.
- no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or()
  scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's
  timestamp .or(); all columns in both strings are literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3)

company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and
were granted to authenticated with a caller-supplied company UUID: any
logged-in user could probe an arbitrary company's billing state and grace
deadline across tenants. Migration 20260901091752 (applied to staging)
requires an auth.uid() membership in the target company when a JWT is
present, keeps service-role/definer contexts unrestricted, and clamps the
grace window to [0, 20] days. pg tests: stranger gets false/NULL, member
reads normally, oversized p_grace_days cannot widen the probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:29:12 +02:00
Jakob Wennberg 413a0978c0 fix(v1): attribute every REST write to the API key that made it (#2074)
commitEntry() already reads getActor() and forwards it to
commit_journal_entry, which stamps journal_entries.committed_actor_* and the
audit_log COMMIT row. But runWithActor had exactly one production call site,
the pending-operations commit, so everything reaching the ledger by any other
route committed anonymously.

Setting the scope once in withApiV1 attributes every v1 write, including the
ones that get to the ledger several frames down through reverseEntry,
correctEntry and the supplier-invoice paths. No signature changes, no
migration.

The severity is not the headline aggregate. 184 873 of the 193 466
unattributed entries are source_type='import', bulk SIE migration with no
meaningful actor. Excluding those it is 8 593 of 11 009, and it concentrates
where it matters most: 99.8% of storno entries and 100% of correction entries
had no actor. Those are the two sanctioned rättelse paths under BFL 5 kap.
5 §, where "who did this, and when" is a legal question.

Only the authenticated branch is wrapped. The anonymous public path has no
attribution worth recording.

Verified by removing the wrapper and watching 3 of 4 tests fail; the fourth,
which checks AsyncLocalStorage does not leak between requests, correctly still
passed.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:49:51 +02:00
Jakob Wennberg dc07ca8872 feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
2026-08-31 08:39:04 +01:00
Mattsson 9f8fa1b692 feat(invoices): draft invoice delete on v1 and MCP with staged approval (#2036)
* feat(invoices): draft invoice delete on v1 and MCP with staged approval

Draft customer-invoice deletion was web-only. This makes the same
semantics available on the v1 API-key surface and as an MCP write tool:
unnumbered drafts are hard deleted (no F-series number was consumed, so
no gap arises), numbered drafts are makulerade (status 'cancelled',
number retained so the F-series stays gap-free per ML 17 kap 24 and
BFNAR 2013:2). Non-drafts are refused; posted invoices can only be
reversed via a credit note.

- extract the web DELETE logic into lib/invoices/delete-draft-invoice.ts
  with an explicit userId param (service-role clients null auth.uid());
  the cookie route behavior is unchanged
- add DELETE /api/v1/companies/{companyId}/invoices/{id}: 409
  INVOICE_DELETE_NOT_DRAFT for non-drafts (status override; the cookie
  route keeps its 400), 404 generic NOT_FOUND, dry-run preview of the
  outcome, mandatory Idempotency-Key; scope invoices:write
- fix the stale v1 PATCH pitfall that claimed a DELETE handler existed
- new MCP tool gnubok_delete_draft_invoice: staged operation requiring
  approval, risk 'high' (both outcomes irreversible, never
  auto-committed), catalogVisibility 'search' (tools/list budget at zero
  headroom)
- delete_draft_invoice commit executor delegating to the shared service,
  plus pending_operations CHECK constraint migration pair
  (20260830100000/100001), risk tier, scope map, Granskning vocabulary
  and sv/en labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98

* fix(migrations): renumber delete_draft_invoice pair after 20260830101500 on main

Merging origin/main brought 20260830101500_seed_agent_atom_bodies; the
constraint pair must sort after every version already on main so it
never applies out of order at merge time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98

* docs(api-skill): regenerate accounted-api skill for the new invoices.delete endpoint

apiskill:check failed on CI: registering DELETE /invoices/{id} makes the
generated skills/accounted-api docs stale. Output of npm run
apiskill:generate, no hand edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98

* fix(invoices): pin staged delete outcome and align v1 risk metadata

Skeptic findings on PR #2036:

- Outcome pin: gnubok_delete_draft_invoice stages
  expected_invoice_number alongside invoice_id; the executor passes it to
  deleteDraftInvoice, which refuses with INVOICE_CANCEL_RACE when the
  draft's number changed since staging. An unnumbered draft finalized
  between staging and approval is now auto-rejected with a message naming
  the new number, instead of silently switching from the approved hard
  delete to a makulering. Ops staged without the pin keep legacy
  semantics; single-phase callers (web, v1) are unaffected.
- v1 invoices.delete registerEndpoint risk raised medium -> high to match
  the delete_draft_invoice pending-op tier (both outcomes irreversible);
  generated accounted-api docs regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98

* fix(migrations): renumber delete_draft_invoice pair after skattekonto collision

Merging origin/main brought PR #2039's 20260830130000/130001 pair, which
collides with this branch's versions AND re-creates the same
pending_operations CHECK wholesale. Renumber to 20260830150000/150001 and
rebuild the value list as a strict superset (skattekonto list plus
delete_draft_invoice) so applying last revokes nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:57:28 +02:00
Mattsson e8aa0670ca feat(salary): agent path to set this month's per-run salary (#2015)
* feat(salary): agent path to set this month's per-run salary

Agents could not do variable owner pay: the only per-run edit tool,
gnubok_update_payslip_line, edits the display-only Grundlon line that
every recalculation rebuilds from salary_run_employees.monthly_salary,
so the fixed employee salary silently won (user-reported).

- lib/salary/run-employees.ts: setRunEmployeeSalary() shared service
  (draft gate, roundOre, 0 = nollkorning, display-line refresh); the
  cookie route PATCH now delegates to it (behavior unchanged)
- MCP: gnubok_set_run_salary staged tool (search catalog: tools/list
  budget at zero headroom), op type set_run_salary (medium risk),
  commitSetRunSalary executor, payroll:write scope, payroll_month
  loadout + payroll-monthly skill step; update_payslip_line description
  now warns that recalc rebuilds base salary lines
- v1 REST: PATCH /salary-runs/{id}/employees/{employeeId} accepting
  monthly_salary (draft only, dry-run, idempotency key)
- Migration pair (NOT VALID + VALIDATE) adds set_run_salary to the
  pending_operations op-type CHECK; base list verified against prod live
- Tests: service, staged tool, executor, cookie route, v1 route; spec
  snapshot updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG

* fix(salary): harden set_run_salary per skeptic + CI findings

- Clear calculation_breakdown when the per-run salary changes so the
  existing book preflights force a recalculation: a run can no longer
  be booked with gross/tax derived from the old salary (skeptic R1)
- Enforce SALARY_OVERRIDE_MAX (10 MSEK) in the shared service and the
  v1 body schema: closes the unbounded/1e307-overflow path that wrote
  Infinity -> NULL -> 500 (skeptic R2)
- Promote gnubok_set_run_salary to the default catalog: a search-only
  WRITE is uncallable on Claude.ai (update_customer lesson) while three
  surfaces pointed agents at it; payload ceiling bumped 63.8K -> 64.4K
  with a ledger entry, read-demotion left as its own change (skeptic R3)
- Granskning label type_set_run_salary in vocabulary.ts + sv/en (R4)
- Display-line refresh is fire-and-forget again (write already
  committed; matches pre-refactor route behavior) and DB error details
  carry the SQLSTATE code for Swedish error mapping
- v1 risk metadata aligned to 'medium'; NOT_DRAFT message now covers
  salary edits, not just roster changes
- npm run apiskill:generate committed (CI apiskill:check failure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG

* chore(migrations): rename set_run_salary pair past main's newest versions

origin/main gained 20260828120000 and 20260828154800 after this branch
staged 20260828110000/1; out-of-order versions are skipped at merge, so
the pair moves to 20260828160000/1 (byte-identical SQL, reference in the
VALIDATE header updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG

* chore: retrigger Supabase preview after migration-version repair

The preview branch tracked 20260828110000/1 before the rename to
20260828160000/1; the orphan rows are deleted from the preview branch's
schema_migrations (preview only, prod never saw those versions) and this
empty commit re-runs the tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP37pE3zk667nP6S766iJG

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 18:14:31 +02:00
Jakob Wennberg 3447da027a feat(api): agent-substrate quick wins: worked examples in the spec, honest Retry-After, and a payload guard that covers the namespace new installs get (#1974)
* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill

EndpointDefinition.example is required and every one of the 125 v1 endpoints
populates example.response, but generateOpenApiSpec() never emitted it. The
examples reached only the docs markdown builder, so /api/v1/openapi.json
carried none and the generated skills/accounted-api had zero json blocks in
all 12 reference files: every agent reading the spec or installing the skill
got schemas with no concrete body.

Emit example on the application/json media types (request body and 200
response) and teach the portable renderOperationMd to print it as a fenced
json block. 178 worked examples now reach the skill. SKILL.md is unchanged:
the examples land in the on-demand reference files, not the entry file.

Attached to JSON media types only, so a multipart body and a binary
application/pdf response do not advertise an example they cannot send.

Adds the one missing example.request (currency-revaluation) so the new
exhaustive coverage assertions hold.

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

* fix(api): emit Retry-After on a v1 429 so the documented contract is real

The published accounted-api skill has told agents to honor Retry-After on a
429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth
failure path early-returns through v1ErrorResponseFromCode, whose finalize()
set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to
pace against and had to back off blindly.

60 seconds is an exact upper bound rather than a guess: the rate limiter is a
fixed one-minute tumbling window per key row and the limited branch does not
slide it. The value moves into an exported constant next to that limiter, so
the MCP server's hardcoded '60' now reads from the same place.

Also corrects the withApiV1 doc comment, which claimed step 8 stamps
X-RateLimit-Limit. It never did.

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

* test(mcp): guard the tools/list payload for the namespace new installs get

The payload ratchet only ever serialized the gnubok_* projection. The
accounted_* projection is inherently larger (every tool reference gains 3
chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP
installs at exactly that namespace, so the payload a new user's client
receives was never measured. It had already drifted ~90 tokens past the
63.4K ceiling while the guarded number sat comfortably under it.

Measure both and assert on the larger. The ceiling moves to 63.6K to cover
the real worst case; this buys no new catalog surface. A second test pins the
direction of the delta so Math.max cannot silently stop describing reality.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:35:47 +02:00
Jakob Wennberg d3869e6694 fix(api): register the v1 stamp endpoint scope and derive the webhook event catalogue from one source (#1930)
POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp registered itself
with scope documents:write but had no V1_ENDPOINT_SCOPES entry, and the
wrapper resolves the required scope from that map before it validates the
bearer token, so the route answered NOT_FOUND to every caller. Add the entry,
drop the three phantom entries that had no route (GET openapi.yaml, GET
companies/:companyId, GET companies/:companyId/events), and add a parity test
that pins the scope map to the endpoint registry in both directions, checks
every pattern against an existing route file, and checks every v1 route file
is imported by load-routes.ts.

The webhook event catalogue was hand-copied in three places and had drifted:
the fan-out handler delivered 28 events while the v1 create enum, the OpenAPI
spec, the generated agent skill and the docs page listed 24, so the four
reconciliation.* events could not be subscribed to. lib/webhooks/public-events.ts
is now the single source; the handler set, the Zod enum and the docs section
derive from it, with tests that pin each surface to the catalogue. The PATCH
webhook docs no longer tell agents to delete and recreate a webhook to rotate
its secret: POST .../rotate-secret exists.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 13:34:27 +02:00
Mattsson 85e039035d feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API (#1909)
* feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API

Requested by a v1/MCP user: the web UI can produce resultat- and
balansrapport for a custom period with PDF export, but REST v1 and the
MCP tools only served whole fiscal years and silently ignored
from_date/to_date.

- v1 income-statement: optional from_date/to_date (validated against the
  fiscal period via the same parseReportDateRange the dashboard uses)
- v1 balance-sheet: same, plus as_of as the natural alias for to_date
  (mutually exclusive with it)
- Unknown query params on these report routes now return
  VALIDATION_ERROR with the unknown and allowed names instead of being
  silently dropped (scoped to these routes, not a global v1 change)
- MCP gnubok_get_income_statement: from_date/to_date;
  gnubok_get_balance_sheet: as_of_date; both validate format, in-period
  and ordering, and reject unknown args (tools/list payload bench held
  under the ceiling by trimming the same tools' descriptions)
- New v1 PDF endpoints reports/{income-statement,balance-sheet}/pdf,
  byte-equivalent to the dashboard export: the K2/K3 grouping and the
  balance gate moved to lib/reports/financial-statement-pdf.ts, shared
  by both surfaces
- Both JSON endpoints echo the effective range in data.period

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

* fix(reports): range semantics, empty-date validation, and review findings on PR #1909

Consolidated resolution of the skeptic refutations, CI failures, and
CodeRabbit findings:

- Ranged income statement summed closing balances, so from_date after
  period start returned year-to-date figures mislabeled as the range
  (July revenue reported as Jan-Jul on JSON, PDF, and MCP). The trial
  balance rolls pre-range P&L activity into opening columns, so
  generateIncomeStatement now builds from period movements whenever
  fromDate is set, matching the resultatrapport convention. Full-period
  behavior is unchanged; generator-level regression tests added.
- from_date dropped from the v1 balance-sheet routes (JSON + PDF): a
  balansraking is a cumulative position, not a flow over a window
  (ÅRL 3 kap); matches the MCP tool's as_of_date-only surface.
- Empty date values (from_date=) now fail validation instead of
  silently producing a full-period report with an empty period echo
  (null-check instead of truthiness in parseReportDateRange).
- dry_run, read by the withApiV1 wrapper on every request, is tolerated
  by the strict param check instead of being rejected as unknown.
- Unbalanced balansrakning on the v1 PDF route returns 400 (caller-data
  condition), matching the dashboard export, instead of 500.
- skills/accounted-api regenerated (apiskill:check gate).
- Removed the ISO_DATE_RE import that collided with the pre-existing
  local declaration in the MCP server (TS2440 on core build).
- CodeRabbit: 401 tests for both PDF endpoints; event bus cleared in
  the new MCP test's beforeEach.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:34:21 +02:00
Jakob Wennberg 31e0cd6e05 feat(onboarding): company setup from the conversation and POST /api/v1/companies (#1814 PR 3) (#1864)
* feat(onboarding): company setup from the conversation and POST /api/v1/companies

Third PR of agent-first onboarding (#1814). Once connected, the agent can
now set up a company end to end without the web wizard, and partner
platforms can provision companies over REST.

- create_company_for_user: service-role-only SECURITY DEFINER twin of
  create_company_with_owner taking the owner explicitly (service clients
  have no auth.uid()). pg-real test covers creation, role gating, unknown
  owner and foreign team.
- lib/company/create-company.ts: the wizard's creation sequence (org
  number, TIC snapshot, BAS chart, settings, first fiscal period, tax
  deadlines, rollback) extracted into createCompanyCore; the Server
  Action delegates to it, behaviour unchanged.
- lib/company/onboarding-input.ts: one Zod schema + planner for the
  agent/API paths; a VAT-registered company without moms_period is
  refused (a missing period silently yields zero VAT deadlines).
- MCP: gnubok_create_company (two-phase: preview, then confirm=true;
  companies:write, company-independent), gnubok_connect_bank and
  gnubok_connect_skatteverket (status + the browser link, gated on
  bank_sync / skatteverket, search-only in the catalog), the
  "onboarding" skill, and initialize instructions pointing at it.
- Consent page pre-ticks companies:write for an account with no company
  yet, so the setup does not dead-end on insufficient scope after signup.
- POST /api/v1/companies (companies:write, dry-run aware) on the same
  core; scope map, registry, spec snapshot and the generated API skill
  updated.
- tools/list payload ceiling raised 59.95K -> 60.4K for the one new
  default-catalog tool (documented in the guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(onboarding): explicit f_skatt, org number when VAT-registered, EF first year ends 31 Dec

Review findings on #1864 (Swedish compliance review):
- f_skatt is required, never defaulted to approved (SE-R-005 risk).
- org_number is required when vat_registered: the invoice
  momsregistreringsnummer derives from it (ML 17 kap 24 §).
- An enskild firma's first fiscal year must end on 31 December and its
  start month is forced to 1 even with first_fiscal_year set, mirroring
  the wizard's own rule text (BFL 3 kap. 1 §).
- POST /api/v1/companies no longer claims Idempotency-Key support (the
  wrapper only honours it on company-scoped routes).
- pg-real: createCompanyCore's chart seed runs under the real
  service_role, which the unit tests could not prove.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* test(pg): starter chart has 41 accounts, assert non-empty

The service_role chart-seed proof passed the part that mattered (no
42501 from seed_chart_of_accounts) and failed on a wrong row-count
guess: the seeded chart is a curated starter set, not the full BAS list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(migrations): move create_company_for_user to 20260825120000

main gained 20260824170000_bulk_book_transactions_service_actor.sql with
the same version while this branch was open; two files on one version
abort every Supabase branch apply and the prod auto-apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* chore(api): refresh spec snapshot and generated skill after rebasing onto main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp): flat create_company result, refuse localhost connect links, test hygiene

CodeRabbit on #1864: the confirmed-create result was wrapped in the
{ data, next } envelope while its outputSchema promised top-level
fields; it now returns the fields with next as a sibling. The two
connect-link tools refuse to build a link when NEXT_PUBLIC_APP_URL is
unset instead of handing a remote user a localhost URL. Tests clear
mocks and the event bus in beforeEach. Not changed: the rollback
already survives user_preferences.active_company_id (that FK is ON
DELETE SET NULL since 20260331010000), and v1 error details stay in the
surface's English developer convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 12:41:02 +02:00
Jakob Wennberg d88df74b85 feat(reconciliation): residual booking + junction-aware bridge (#1862)
When the worksheet selection (N bank rows vs one verifikat) misses by a
few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad /
Ränteintäkt / Öresavrundning och koppla' books the remainder on
6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the
main verifikat and anchors the residual verifikat through
transaction_voucher_links. Bank accounts only (Skatteverket posts ränta
and avgifter as rows of their own), capped at 5 000 kr, direction-checked
against the kind; links are made first and undone if the booking is
refused. Dashboard + v1 doors (transactions:write, Idempotency-Key,
dry run), API skill regenerated.

The bridge now treats transaction_voucher_links as links on both sides:
migration 20260824190000 re-creates get_unlinked_gl_lines and
get_account_gl_lines_for_matching to count junction-linked verifikat as
matched (pg-real test), and the TS engine + items do the same for the
transactions. This also stops bulk-booked samlingsverifikat from
polluting the open buckets. 'Koppla bort' drops the junction rows too.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:30:54 +02:00
Jakob Wennberg f40795896f feat(reconciliation): sign-off, period picker, Hem row and the three doors for it (#1835)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

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

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

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

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

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

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

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

* feat(reconciliation): the Avstämning page, one body for every account with an outside truth

/reconciliation in Arbeta (after Transaktioner), on the approved layout:
an account rail on the left (bank accounts and the skattekonto, logo or
monogram, last fetch, status dot, URL-owned selection), and for the
selected account four tiles (outside, ledger, difference, unexplained),
the bridge that explains the difference, an actions row (link the
proposed pairs, book the unbooked skattekonto events, run the bank
matcher) and a full-width table banded by bucket with proposal rows
linkable one by one. Every read and write goes through the PR 2
dashboard routes, so the page shows exactly what the v1 API and the MCP
tools see.

Also: nav item, command palette entry, sv/en strings. Period picker,
manual match mode and sign-off are deliberately not here (PR 4/5).

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

* feat(reconciliation): sign-off, period picker, Hem row and the three doors for it

"Markera som avstämd t.o.m. <datum>" as an append-only attestation:
account_reconciliations (who signed which account through which date,
with the numbers as they stood; reopen stamps instead of deletes; RLS
members write as themselves, viewers read). Policy in one place
(lib/reconciliation/signoff.ts): refused with an unexplained difference
unless forced with a note, refused past today or past the skattekonto
snapshot, refused at or before an active sign-off; reopen is the undo.
Every status read now carries the latest active sign-off and the rail
shows "avstämt t.o.m.".

Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen),
v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run,
registry + regenerated API skill), MCP gnubok_reconcile_signoff (search
catalog, stages reconciliation_signoff after a policy dry run; executor
+ risk tier + op-type CHECK migration pair). Events
reconciliation.signed_off / reconciliation.reopened, and the four
reconciliation events join the public webhook set (additive; API version
unchanged, changelog section added).

Page: räkenskapsår + range picker in the header (own preset memory,
opens on this month) scoping the bridge, the items and the default
sign-off date; sign-off dialog with the forced-with-note path; reopen
on hover. Hem: worklist category reconciliation_due ("Konton att stämma
av"), zero until the company has signed anything off.

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

* fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard

gnubok_reconcile_signoff carries the deliberately separate
reconciliation:signoff scope; the central viewer guard keys on the
:write/:approve/:manage suffixes, so a viewer could reach the tool (RLS
would still refuse the row, but the guard is the intended layer). Add
:signoff to the classifier; the strictness test that caught it now passes.

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

* fix(providers): serve local rate-limiter waiters in arrival order

Two callers that both found the in-memory bucket empty each set their own
timeout; the timeouts expired at the same instant from different timer
lists and which woke first was platform-dependent. hydrateInvoices relies
on "started first, requested first" to serve open invoices before paid
ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI
(twice on #1817) while holding locally. A promise queue makes the local
waiters FIFO without changing the rate; the Upstash path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d)

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:07:58 +02:00
Jakob Wennberg 3a62c5419e feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools (#1833)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

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

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

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

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

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

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:03:20 +02:00
Mattsson 60920ec794 feat(skatteverket): expose filed VAT declarations and decisions via the v1 API (#1773)
* feat(skatteverket): expose filed VAT declarations and decisions via the v1 API

Add GET /api/v1/companies/:companyId/skatteverket/vat-declarations, returning
a period's momsdeklaration as Skatteverket has it on file: the submitted
declaration (SKV /inlamnat) and Skatteverket's beslut (SKV /beslutat), either
individually via ?state= or both.

- Auth: compliance:read scope; member-visibility read model per #1673
  (resolveReadAuth: caller's token, any member's active token, or system
  credentials with a verified ombud grant).
- Architecture: core reaches the Skatteverket extension through the
  registry-resolved services channel (contract in
  lib/skatteverket/declaration-status.ts), so core never imports from
  @/extensions/.
- New structured error SKATTEVERKET_API_ERROR (502) for upstream SKV
  failures; 404 from SKV maps to submitted/decided = null with HTTP 200.
- 19 new tests (route: auth, validation, extension-disabled, happy path;
  extension service: auth resolution, state filtering, SKV error mapping).

Fixes #1663

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

* fix(skatteverket): address review findings on the vat-declarations read API

Consolidated fixes for PR #1773 review round:

- apiskill sync (core-build Checks): map the new skatteverket endpoint
  group into the periods.md reference and regenerate skills/accounted-api
  (124 -> 125 operations).
- CodeRabbit: parse the SKV 2xx body before writing the audit row, so an
  unreadable body is audited as skv_error and returns the structured
  SKATTEVERKET_API_ERROR 502 instead of escaping as an internal 500;
  regression test added.
- Compliance swarm (ISO A.8.12 / SOC2 CC6.1): stop forwarding the raw
  upstream SKV response body to API consumers; the caller now gets the
  status code and a generic Swedish message, the body is logged
  server-side only.
- Compliance swarm (GDPR Art.30): add the moms.declaration_status_read
  processing activity to .compliance/ropa.yaml (live read, no payload
  persisted, audit-log metadata only).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:12:18 +02:00
Mattsson 619b446c52 fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction (#1685)
* fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction

The Swish payment QR on invoice PDFs encoded the pre-deduction invoice
total (getDisplayTotal), while the totals block and the invoice email
state "Att betala" as total minus the ROT/RUT deduction (getAmountToPay,
fakturamodellen). Since the Swish payload locks the amount (editmask 0),
a customer scanning a RUT/ROT invoice was asked to pay the full total
with no way to correct it: overpaying by the entire skattereduktion.

Swap the QR amount source to getAmountToPay(...).toPay so the QR, the
printed "Att betala" and the email always agree. A fully deducted
invoice (toPay = 0) now renders no QR via the existing amount > 0 guard.
All seven render surfaces (send, preview, pdf, v1 send/pdf, MCP commit,
recurring, issue-and-book) go through this one helper.

Reported by a user: "QR-koden for swish stammer INTE med beloppet man
ska betala. Den tar INTE hansyn till reduktionen."

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

* fix(invoices): select the amount-to-pay columns on the v1 pdf and send surfaces

Skeptic review of the Swish QR fix found it was a silent no-op on the v1
GET pdf route: its column projection predated ROT/RUT and omitted
deduction_total (and ore_rounding), so getAmountToPay saw undefined,
treated it as "no deduction", and the route kept emitting a locked
full-amount QR while the sent email said the deducted "Att betala".
INVOICE_FULL_COLUMNS (v1 send renders from it) likewise omitted
ore_rounding, ignoring the per-invoice oresavrundning override there.

Move INVOICE_PDF_COLUMNS into lib/api/v1/invoice-columns.ts, add
deduction_total, deduction_personnummer_last4 and ore_rounding to it, add
ore_rounding to INVOICE_FULL_COLUMNS, and pin the amount-path columns of
both projections with a test: a projection gap does not error, it renders
the wrong money on one surface only, so it must be caught structurally.

Also records the defect and remediation in DECISIONS.md per the
compliance-swarm change-risk finding (the repo has no risk_register.csv;
the decision log is its equivalent).

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

* fix(invoices): gate the Swish QR to payable documents and restore delivery_date on the v1 pdf

Swedish accounting review round 2: buildSwishQrDataUrl had no non-payable
gate, so a kreditfaktura (a refund document) still produced a locked
Swish payment QR at helper level; the template happens to hide the
payment box for credit notes, but a payment request against a refund
must stay impossible rather than merely unrendered. Apply the same
document gate buildPaymentLinkQrDataUrl already has (invoice documents
without credited_invoice_id only) and pin it with tests replacing the
credit-note parity case.

Also add delivery_date to INVOICE_PDF_COLUMNS: ML 17 kap 24 p.7 requires
leveransdatum on the invoice when it differs from the invoice date, the
template renders exactly that, and the v1 pdf projection silently
dropped it. Same projection-starvation class as the previous commit.

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

* docs(invoices): name the covered render surfaces and drop the contested lagrum point number

CodeRabbit round 3, both documentation-only: the DECISIONS defect record
said "all surfaces" while the editor preview is deferred to #1686, so it
now lists the covered surfaces explicitly; and the delivery_date comment
cited ML 17 kap 24 p.7 where CodeRabbit reads p.8 in SFS 2023:200 while
the repo's swedish-invoice-compliance reference table says p.7, so the
citation drops the point number and stays at the paragraph, which is
correct under either enumeration. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:39:49 +02:00
Jakob Wennberg 25524e1df4 fix(suppliers): stop requiring standardkonto that was never meant to be required (#1636)
* fix(suppliers): stop requiring standardkonto that was never meant to be required

The supplier form initializes every optional field to '' and sent them
as-is, while CreateSupplierSchema validates default_expense_account
with the 4-digit account rule behind .optional(): an empty string is a
present string, so saving a supplier with the field untouched failed
with "Kontonummer måste vara 4 siffror" even though the field carries
no required mark (reported by Björn with a screen recording; the edit
page failed the same way for any supplier without a default account).

Schemas now own the normalization, split by verb: on create '' becomes
undefined (key dropped, column NULL), on update '' becomes null,
because update routes pass fields straight into .update() where
undefined means "leave unchanged" and clearing must actually write
NULL. Email gets the same treatment and the form's old client-side
email strip is removed; stripping empty strings client-side would
break exactly the clear path.

The free-text Standardkonto input is replaced with the shared
AccountCombobox (browsable list filtered to cost classes 4-7, the same
rule the agent-path expenseAccountField enforces), with the selected
account name shown under the field and a clear button when set.
Standardkonto itself stays optional: it only prefills supplier-invoice
lines and the ledger-context suggestion covers the empty case.

Verified end to end against the running app: saving a supplier without
a default account succeeds on the update path, and the combobox
search/select/clear cycle works inside the create dialog.

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

* fix(api-spec): render preprocess pipes by output side, required-ness by undefined-acceptance

The minimal Zod-to-JSON-schema walker described every pipe by its input
side. For .transform() that is right (the caller sends the input), but
z.preprocess() is the mirror image: the callable sits on the input side,
so the supplier schemas' new empty-string normalization rendered email
and default_expense_account as required untyped fields in the OpenAPI
spec and the generated accounted-api skill. Describe the output side
when the input is a transform.

Required-ness now derives from schema.safeParse(undefined) instead of a
top-level discriminator check: a field may be omitted exactly when the
schema accepts undefined. Besides the preprocess pipes, this corrects
several fields the old check misrendered as required (z.unknown()
bodies, union-with-empty-string settings fields, preprocessed
personal_number), so the regenerated skill references only flip
required to optional where runtime validation already allowed omission.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:41:03 +02:00
Jakob Wennberg 11b82cbb91 feat(api): installable accounted-api agent skill + openapi-to-skill generator (#1516)
* feat(api): installable accounted-api agent skill + openapi-to-skill generator

Three layers, per the July/August 2026 agent-skills ecosystem (skills.sh /
npx skills add, as used by Stripe/Cloudflare/Supabase for their APIs):

- skills/openapi-to-skill/: generic, installable skill that turns any
  OpenAPI spec into a consumer-side integration skill, with a portable
  stdlib-only inventory/condenser tool and an output template + quality
  checklist encoding the distill-not-restate methodology.
- skills/accounted-api/: the installable skill for our own API, rendered
  deterministically by scripts/api-skill/generate.ts from the v1 endpoint
  registry + hand-authored overlays (auth, conventions, domain gotchas).
  CI gate: npm run apiskill:check (core-build.yml).
- lib/api/v1/registry.ts: generateOpenApiSpec now emits requestBody (incl.
  multipart binary parts) and path parameters, and the Zod converter learned
  .default()/z.record()/.pipe()/.transform(), so the public spec carries
  request contracts instead of prose-only.

Docs: /docs/api landing + /llms.txt now point agents at the skill install;
corrected the stale test-key description in the landing (test keys read
real data and force dry-run writes; they are not sandbox-company bound).

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

* fix(skills): escape backslashes in markdown table cells (CodeQL js/incomplete-sanitization)

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:45:19 +02:00
Jakob Wennberg 86c6af6976 feat(api): v1 REST company-settings write endpoint (PATCH) (#1405)
* feat(api): v1 REST company-settings write endpoint (PATCH)

Adds PATCH /api/v1/companies/{companyId}/settings, closing the gap where
the v1 REST surface had no company-settings write (only the staged MCP
tool gnubok_update_company_settings could change them).

- Field set is identical to the MCP tool: payment details (bank account,
  bankgiro, plusgiro, swish, iban, bic), invoice contact details (email,
  phone, website), contact_person (aliased onto default_our_reference,
  exactly as the MCP tool maps it), and invoice_email_texts.
- Validation reuses the shared UpdateCompanySettingsParamsSchema (Luhn
  bankgiro/plusgiro, invoice email placeholder whitelist), so REST and
  MCP can never drift apart on the Swedish-domain rules.
- Writes directly with an explicit .eq('company_id', ...) filter,
  following the v1 customers PATCH precedent: no staged operation, since
  REST callers are already gated by the companies:write scope.
- Dry-runnable, mandatory Idempotency-Key, registered in the endpoint
  catalogue, scope map, and load-routes; spec snapshot updated.
- The companies:write scope description now mentions the REST endpoint.

No GET endpoint yet (possible follow-up); reads stay on the MCP tool.

Fixes #1348

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

* test(v1): harden company-settings PATCH contract, align risk tier

Adversarial-review follow-up for the settings PATCH endpoint (#1348):

- Declare risk: 'medium' in registerEndpoint, matching the
  update_company_settings tier in lib/pending-operations/risk-tiers.ts
  (payment settings control where customers send money on future
  invoices). The spec snapshot does not pin the risk field, so no
  snapshot regeneration is needed.
- Pin the partial-PATCH contract: every column the caller did not
  supply must arrive as undefined in the update payload, never null.
  A future ?? null on the literal 13-column payload would silently
  clear every unsupplied column; the new test fails on exactly that
  regression (verified by mutation).
- Cover the body-parsing branches: invalid JSON and non-object JSON
  bodies (bare array, string, number, null) each return 400 with the
  handler's respective message and never reach the update call.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:46:13 +02:00
Mattsson f24b26a139 fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

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

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

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

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

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

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

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

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

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

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

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

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

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

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

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

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

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

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

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

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:34:56 +02:00
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00
Jakob Wennberg 8294899543 test(skatteverket): guard LEGACY_DISCOVERY_HOSTS against config drift (#1093) (#1107)
Add validateLegacyDiscoveryHosts() next to the allowlist in
lib/api/v1/base-url.ts and a unit test that pins the registered
production configuration (app.accounted.se canonical,
app.gnubok.se SKV OAuth pin). The invariant is checked both ways:
the NEXT_PUBLIC_SKV_OAUTH_BASE_URL host must be reflectable by
discovery (canonical or allowlisted), and every allowlist member
must be accounted for by the registered configuration. Drift is
now a red CI test instead of a silent production re-auth failure
near a filing deadline. No runtime behavior changes.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:11:04 +02:00
Jakob Wennberg b420f3e1d9 feat(domains): dual-domain cutover to app.accounted.se (#1087)
* feat(domains): dual-domain cutover to app.accounted.se

The user-facing app moves to app.accounted.se while app.gnubok.se stays
alive for machine traffic (MCP connectors, API keys, third-party OAuth
callbacks, webhooks, crons), so no third-party callback registration is
on the critical path.

- next.config: host redirect app.gnubok.se -> NEXT_PUBLIC_APP_URL for
  page traffic only (/api, /.well-known, /_next excluded). Arms itself
  only once NEXT_PUBLIC_APP_URL leaves the legacy host, so merging this
  is inert and the cutover is a pure env flip + redeploy.
- skatteverket: redirect_uri pinned via NEXT_PUBLIC_SKV_OAUTH_BASE_URL
  (Utvecklarportalen registration is slow to change); the OAuth callback
  now resolves the flow from the state token + stored oauth_user_id via
  the service client instead of session cookies, which no longer exist
  on the OAuth host. Legacy same-domain flows fall back to the session.
- popup listeners (SkatteverketConnectPanel, AGIPanel) accept postMessage
  from the pinned OAuth origin; event.source identity check unchanged.
- /.well-known discovery docs reflect the allowlisted request host so
  existing MCP connectors on app.gnubok.se keep a self-consistent
  issuer/resource after the flip; spoofed hosts fall back to canonical.

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

* docs: log dual-domain cutover decision

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

* fix(review): recency-bound SKV state lookup, exact localhost match in discovery allowlist

- The oauth_state lookup now only considers rows updated in the last 10
  minutes: bounds how long a leaked/phished authorize URL stays
  completable, keeps the row set far below PostgREST's 1000-row cap, and
  surfaces query errors instead of misreporting them as CSRF.
- resolveDiscoveryBaseUrl matches localhost/127.0.0.1 exactly; the
  prefix check reflected spoofed hosts like localhost.evil.example.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:39:42 +02:00
Mattsson b6332e9ff4 Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

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

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

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

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

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

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

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

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

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

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

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

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

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

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

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

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:33 +02:00
Jakob Wennberg cdac1808c9 feat(api): ROT/RUT, articles, and project lifecycle on the v1 API (#904)
* feat(api): ROT/RUT + articles + dimensions on the v1 invoice surface (#895)

- v1 invoice POST now routes through buildInvoiceWriteData, the same
  builder as the dashboard: ROT/RUT deduction lines (server-side compute,
  personnummer encryption), article_id + revenue_account linkage,
  accruals, and line_type no longer get silently dropped on the wire.
- v1 invoice PATCH accepts default_dimensions so integrations can tag a
  draft with a project/cost centre after creation.
- New PATCH/DELETE /dimensions/:id/values/:valueId: rename, archive,
  set end_date on project codes; delete unreferenced values (409 with an
  archive hint when the BFL retention trigger blocks).
- New GET /articles: read-only artikelregister list (incl. housework_type)
  so callers can resolve article_id before composing invoice lines.
- Invoice GET/POST projections now expose deduction fields and full item
  columns; dry-run previews never echo the encrypted personnummer.

Closes #895

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

* refactor(api): address review on #904

- Extract shared v1 invoice projections to lib/api/v1/invoice-columns.ts
  so create/detail/patch responses can't drift; PATCH now returns
  deduction_total + deduction_personnummer_last4 like GET/POST.
- Narrow the v1 create customer fetch back to the three fields the
  builder reads instead of select('*').

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 15:21:53 +02:00
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob Wennberg 8bb49c07a2 feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry (#858)
* feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry

Phase 2 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false (default) see zero change.

API:
- Dashboard CRUD: GET /api/dimensions (lazy-seeds system dims 1/6 via the
  ensure_company_dimensions RPC), PATCH /api/dimensions/[id] (is_system
  rename blocked), POST/PATCH/DELETE values (code immutable after creation;
  strict Fortnox code format ^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$ at the API layer;
  retention-trigger deletes surface the Swedish "arkivera istället" message
  as 409 DIMENSION_VALUE_REFERENCED).
- POST /api/dimensions/import-existing — scans journal_entry_lines.dimensions
  for unregistered codes and mints inactive placeholder registry rows.
- v1 public API: GET dimensions + POST values (Idempotency-Key, dry-run),
  registered in the OpenAPI spec (102→104 endpoints).
- dimensions_enabled boolean on company_settings (new migration,
  UI-visibility only, never correctness-bearing) exposed through the
  existing settings read/update path.

SIE export (lib/reports/sie-export.ts):
- Reads the new dimensions/dimension_values registry; legacy
  cost_centers/projects tables now have zero readers (drop migration next).
- Fixes the latent Visma-rejection bug: #OBJEKT now declared for INACTIVE
  values referenced by lines.
- Generic-N: #DIM/#UNDERDIM loop sorted by sie_dim_no; #TRANS object lists
  serialize from the line JSONB map (sorted, '01'→'1' collapse); orphan
  codes/dims synthesize declarations from the SIE reserved-number seed —
  every referenced (dim, code) pair is guaranteed declared.

UI:
- /dimensions register (Register-recipe): tabs per dimension, search,
  sortable table, value dialog (code immutable on edit, projekt dates on
  dim 6), archive-not-delete affordances.
- DimensionCombobox shipped (mounts in the tagging PR).
- Settings toggle "Aktivera kostnadsställen & projekt" — toggle-on runs the
  import-existing scan and links to the register.
- Nav row in redovisning, rendered only when dimensions_enabled (same
  mechanism as pays_salaries).
- dimensions.* i18n namespace (51 keys, sv/en parity).
- Sandbox seed: demo dims + values, revenue line tagged {"1":"BUTIK","6":"P001"}.

Verified: 6328/6328 unit tests, guard + coverage gate green, tsc parity with
main (210=210), production build passes.

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

* fix(dimensions): PR2 review round — atomic archived-create, UNDERDIM ordering, import robustness, date semantics

- POST values accepts is_active so "create as archived" is atomic; the UI's
  fragile create-then-PATCH fallback is deleted (PR Agent finding 1).
- DimensionCombobox blur revert reads the committed value/values through refs
  so a selection landing inside the 150ms window always wins (finding 2).
- import-existing sanitizes candidate codes like the PR1 backfill and upserts
  with ignoreDuplicates — one bad/duplicate code can no longer abort the
  batch; created counted from returned rows (finding 3).
- SIE export emits all root #DIM before any #UNDERDIM so a parent always
  precedes a lower-numbered child (SIE4 declaration order — Swedish review);
  synthesized placeholder declarations now log one structured warning
  (BFNAR 2013:2 behandlingshistorik) + defence-in-depth comment.
- Value dates rejected (400 DIMENSION_VALUE_DATES_NOT_ALLOWED) when the
  parent dimension is flow-period (resets_annually=true); explicit null
  still clears (Swedish review).
- Sandbox seed logs seeded dimension codes; GET /api/dimensions documents
  the deliberate absence of dimensions_enabled gating (UI-visibility flag,
  not a security boundary — compliance-swarm V8.2.1 rejected by design).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:26:42 +02:00
Jonas Flodén 5b4cefe8ab feat(api): v1 endpoints to stamp invoice inbox items as consumed (#767)
* feat(api): v1 endpoints to stamp invoice inbox items as consumed

Adds inbox_item_id support to POST /api/v1/companies/{companyId}/documents/{id}/link
(best-effort stamp on the originating invoice_inbox_items row) and a new dedicated
POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp endpoint for stamping
independently of the document link — both use documents:write scope and require
Idempotency-Key.

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* fix(api): wrap stamp response in dataEnvelope and register route in load-routes

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

Signed-off-by: Jonas Flodén <jonas@floden.nu>

---------

Signed-off-by: Jonas Flodén <jonas@floden.nu>
2026-06-29 23:08:01 +02:00
Jakob Wennberg fce6faff2c fix(api): stabilize report pagination + declare real { data, meta } envelope on v1 single/write endpoints (#811)
* fix(reports): stabilize fetchAllRows paging to stop doubled/dropped balances (#790, #791)

PostgREST `.range()` paging is only correct when the underlying query has a
stable TOTAL order. Several aggregating report queries (general ledger, trial
balance, grundbok, supplier/AR ledgers, etc.) paginated without `.order()`, so
on datasets larger than one 1000-row page Postgres could return rows in a
different order between requests — silently DUPLICATING or SKIPPING rows on a
page boundary and doubling or dropping financial totals.

- fetch-all.ts: document the ordering invariant and add an optional
  `dedupeBy` defense-in-depth that drops cross-page duplicates and warns when
  it fires (surfaces a missing `.order()` in logs instead of corrupting money).
- Add a stable `.order()` (line PK or account_number) to every paginated query
  in lib/reports/ and the account-balances route; pass `dedupeBy` on the
  money-aggregating line queries.
- Add fetch-all unit tests and update report test fixtures to carry row ids.

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

* fix(api): declare the real { data, meta } envelope on v1 single/write/204 endpoints (#794)

The OpenAPI generator derives each endpoint's documented body purely from its
registered `response.success` Zod schema, and that schema is never validated at
runtime — so a route could advertise a shape its handler never sends. #802
fixed this for list endpoints; the same drift was latent on single-resource and
write endpoints, which declared the bare resource schema instead of the
`{ data, meta }` envelope the handlers actually return.

- registry.ts: extend `ResponseMetaSchema` with the optional `audit` block and
  `partial_expansions` list that writes/expansions emit; add the `NoBodyResponse`
  sentinel so 204 DELETE handlers document a bare 204 instead of a phantom 200.
- Wrap every single/write endpoint's `response.success` in `dataEnvelope(...)`
  (or `NoBodyResponse` for 204s) across the v1 routes.
- Add a response-envelope contract test that fails CI if any JSON endpoint
  forgets to wrap its schema, with binary downloads and 204s as the only
  exemptions.

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

* fix(reports): extend paging dedupeBy to rc-basis-gaps and opening-balances

Address PR review: these two money-aggregating line queries already had the
stable `.order('id')` (so paging was correct) but didn't carry `id` in the
select, so they couldn't use the `dedupeBy` defense-in-depth that general-ledger
and trial-balance got. Select `id` and pass `dedupeBy: r => r.id` so the whole
report layer applies the ordering invariant consistently.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:42:50 +02:00
Jakob Wennberg 9278221616 fix(api): guard params await so static v1 routes don't 500 (#795)
Next.js 16 invokes a static route handler (no [segment]) with
{ params: undefined }. /api/v1/companies is the only authenticated static
route on the v1 surface, so awaiting params.params null-derefs and the catch
turns it into a 500 for every valid API key. Guard the await:
((await params?.params) ?? {}). Dynamic routes are unaffected. Fixes #781.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:29:54 +02:00
Mattsson 241959513b Fix/mcp and req (#753)
* feat(api): test-mode API keys force dry-run on the v1 REST API

A key created with mode='test' (prefix gnubok_sk_test_) binds to the real
company, but the v1 wrapper forces dry_run on every write so nothing is
persisted or sent. Mutations on endpoints that can't be simulated
(dryRunSupported=false or unregistered) are refused with 403
TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every
test-key response carries X-Gnubok-Mode: test. Live keys are unaffected
(mode defaults to 'live').

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

* feat(invoices): company default "Vår referens" + per-line sales-account override

Add company_settings.default_our_reference (settings form, schema, type); the
invoice editor pre-fills our_reference from it on new invoices only, never
overwriting an edited draft. Separately, add an optional per-line
försäljningskonto (class-3) override in the editor — left blank, the engine
still derives the revenue account from the VAT rate, and reverse-charge/export
lines ignore the override.

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

* feat(invoices): render a Swish payment QR on invoice PDFs

Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as
a PNG in the invoice PDF payment box when Swish display is enabled, the invoice
is in SEK, and the amount is positive. Also surface the invoice number in the
payment box. Wired through every PDF render path: send, mark-sent and pdf
routes (both legacy and v1), the recurring-schedule sender, and the staged-send
commit.

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

* feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista

Extend list_fiscal_period_entries_with_related with two opt-in params:
p_exclude_draft (keep drafts off the committed list — they get their own
surface) and p_collapse_corrections (render a correction group as the single
live correction, hiding the mechanical storno and the reversed original).
Both default false; nothing is deleted, every voucher keeps its number, and a
"show all" toggle exposes the full chain.

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

* fix(reports): link multi-year SIE periods so resultatrapport shows the prior year

SIE import now sets fiscal_periods.previous_period_id in both directions when
creating a period, so multi-year files chain correctly regardless of #RAR order.
A backfill migration repairs periods imported before this (idempotent; only
touches NULL links on first-of-month periods). generateResultatrapport falls
back to the date-adjacent prior period when the chain is still null, so the
comparison column works for legacy data too.

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

* fix(articles): hide the VAT field for non-momsregistrerade companies

The article form reads company_settings.vat_registered and, when false, hides
the moms field and forces vat_rate to 0 on submit — mirroring the invoice
editor so a non-VAT-registered company never sets a rate it can't charge.

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

* feat(import): allow file-based imports in the sandbox

Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no
external service, so they're now reachable in the sandbox. Only the API-backed
options that need live third-party credentials (PSD2 bank connection, provider
migration) stay disabled. Updates the sandbox notice copy to match.

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

* feat(bookkeeping): add edit draft functionality for journal entries

* feat(database): add default "Vår referens" column to company_settings for invoicing

* fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks

* @
fix(payments): use roundOre for Swish amount formatting

Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to
satisfy the antipattern guard.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:49:33 +02:00
Jakob Wennberg b94ed3bec2 feat(api): cookbooks + webhook audit_log + secret rotation (PR-500 carry-overs) (#501)
* docs(api): ship 4 cookbook recipes (close docs polish backlog)

Promotes the four placeholder cookbook entries to full narrative recipes
matching the Stripe-grade quality bar set by quickstart + webhooks.
Closes the docs follow-up bucket from the PR-500 description's deferred
list.

Recipes:

- ingest-bank-transactions: bank-file upload (CSV / CAMT.053 auto-detect)
  → async poll → list uncategorised → suggest-categories → categorize
  (single + batch) → match-invoice / match-supplier-invoice. Multicurrency
  notes covering Riksbanken FX lookup and the kontantmetoden partial-
  payment guard.

- file-vat-declaration: GET /reports/vat-declaration → rutor 05–62
  walkthrough → GL reconciliation block → 2026-04-01 livsmedel 12% → 6%
  transition explicitly covered (delivery_date supply-date rule) → voucher-
  gap pre-flight → period lock workflow → manual Skatteverket Mina Sidor
  submission with confirmation-reference capture → EU / reverse-charge
  / import handling.

- run-payroll-and-agi: draft → calculate → approve → mark-paid → book →
  generate-agi state machine. Per-step idempotency, strict-mode book
  failure semantics, förmånsbeskattning + bilförmån + bruttolöne­avdrag
  vs nettolöneavdrag ordering. AGI XML download for manual Mina Sidor
  upload (direct API submission requires BankID via the Skatteverket
  extension, not the public REST surface).

- year-end-closing: IB/UB continuity check per BFL 5 kap → voucher-gap
  pre-flight → missing-documents pre-flight → lock (reversible) → year-
  end async operation (resultatdisposition + periodiseringsfond +
  överavskrivningar + bolagsskatt + opening-balance batch) → close
  (irreversible per BFL 5 kap 8 §, typed-phrase confirmation) →
  årsredovisning + INK2/NE generation. Brutet räkenskapsår variant
  documented.

Each cookbook follows the same shape as the existing quickstart and
webhooks recipes — concrete curl commands, response samples, common
pitfalls, next-steps cross-links. Lengths are deliberately uneven: the
year-end recipe is longest because the consequences of getting it
wrong are most severe (BFL violations, irreversible close).

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

* feat(api): V16 audit_log entries for webhook lifecycle + secret rotation endpoint

Two intertwined changes that together close the "real audit attribution
gap in actively-used routes" item from the PR description.

1. POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret

   New endpoint that issues a fresh HMAC signing secret and invalidates
   the previous one immediately. Returns the new secret EXACTLY ONCE in
   the response, mirroring the create-time contract. Required scope:
   webhooks:manage. Idempotency-Key mandatory.

   Rotation is instant — no grace period. Documented workflow: stage the
   new secret on the receiver side (separate config slot, not yet active)
   → POST /rotate-secret → activate the new secret on the receiver →
   POST /webhooks/{id}/test to verify. A "previous_secret" column with
   TTL-based grace window (Stripe-style) is the natural follow-up; the
   instant-rotation shape ships first because it closes the "secret
   leaked, need to rotate now" use case with minimum new surface.

   The route is wired into load-routes.ts and lib/auth/scopes.ts. Spec
   snapshot updated.

2. V16 audit_log entries on every webhook lifecycle mutation

   The audit_log column shape (user_id, company_id, action, table_name,
   record_id, actor_id, old_state, new_state, description) is exactly
   what V16 / Art.32(1)(b) / A.8.24 audit-trail requirements call for.
   Wired entries on:

   - POST /webhooks (create) — action INSERT, new_state captures the
     row WITHOUT the secret (signing material must not land in the
     audit trail; only secret-event metadata).
   - PATCH /webhooks/:id (update) — action UPDATE, before/after pair so
     reviewers can reconstruct exactly what changed.
   - DELETE /webhooks/:id (delete) — action DELETE, old_state snapshot
     so the row's prior state survives the delete.
   - POST /webhooks/:id/rotate-secret — action SECURITY_EVENT, new_state
     carries the event marker only (no secret value).
   - dispatcher.disableWebhook (auto-disable on HTTP 410 / redirect /
     url_unsafe) — action SECURITY_EVENT, before/after capturing the
     disable cause for SIEM correlation.

   actor_id is set to ctx.apiKeyId on caller-driven entries so the
   audit row points back to the specific API key that triggered the
   change (PR-500 round-1 CC6.3 finding: actor attribution via
   created_by_api_key_id alone leaves a gap if a key is deleted —
   keeping the actor_id in audit_log closes that).

   4 new integration tests cover the rotate-secret happy path, 404,
   401 unauthorized, and Idempotency-Key required. The existing
   webhook integration tests continue to pass because the audit_log
   inserts fall through to the default mock response (no-op) without
   disturbing the per-table queues.

39 integration tests pass on the webhook surface (+4 vs round-2).
Total: 3588 unit tests passing.

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

* refactor(api): address PR-501 review round 1 — correctness + Swedish compliance

Round 1 of review fixes. Two real correctness bugs Greptile caught, two
audit-trail gaps, and four Swedish-compliance errors in the cookbook
prose. Compliance Swarm has 17 findings (0 blocking); the 4 architectural
items (secret-at-rest encryption, dedicated rotate scope, rate-limit on
rotation, URL redaction) remain deferred with rationale.

Greptile (3 / 3 — all addressed):

1. rotate-secret silent 0-row UPDATE — fixed by adding
   `.select('id').maybeSingle()` to the UPDATE and returning NOT_FOUND
   when no row was touched. Closes the TOCTOU window between the
   existence check and the secret update; a concurrent DELETE no
   longer hands the caller a freshly-generated secret that no webhook
   in the database matches.

2. DELETE handler audit_log silently skipped when prior snapshot is
   null — fixed by writing the audit row UNCONDITIONALLY with
   `old_state: prior ?? null` and a degraded description when the
   snapshot is unavailable. A successful DELETE now always produces
   exactly one audit row (CC6.3 attribution contract).

3. Typo "bookslut" → "bokslut" in year-end-closing.ts.

Compliance Swarm code-quality items addressed:

4. PATCH new_state now derived from the DB-confirmed returned `data`
   with an explicit field allowlist, not from the request-body-derived
   `update` object (A.8.11 / V16.1.1). Closes the gap where a future
   trigger that rejects a field would leave the audit trail out of
   sync with the actual stored state.

5. All four route-side audit_log inserts (create, update, delete,
   rotate-secret) now capture the insert error and emit a structured
   warning via ctx.log; mirrors the dispatcher pattern (CC7.2).

6. Dispatcher null-user_id path now emits a structured warning instead
   of silently skipping the audit_log entry — SIEM can alert on the
   gap (CC7.2 / V16.1.1 / A.8.15).

Swedish compliance (cookbook content fixes — all real errors):

7. VAT cookbook ruta 06 label corrected: "Övrig försäljning (ej
   skattepliktig)" → "Momspliktig försäljning som inte ingår i ruta 05"
   (Skatteverket's verbatim label). The old label conflated exempt vs
   zero-rated supplies and would cause integrators to omit export /
   EU zero-rated sales from box 06.

8. Livsmedel rate-change framing rewritten: leads with the supply-date
   rule (ML 1 kap 3 §) as the decisive date, not invoice_date. The
   old opening sentence ("invoices created with invoice_date >=
   2026-04-01 book to 2631") was wrong on its face — a copy-paste
   reader would mis-book pre-cutover deliveries invoiced in April at
   the new 6% rate.

9. Reverse-charge EU 2645 note adds the blandad-verksamhet caveat:
   "Net zero impact on cash flow" only holds when full avdragsrätt
   applies; partial avdragsrätt requires proportional restriction
   per HFD 2023 ref. 45.

10. Payroll cookbook age bounds corrected: "under-25 / over-66" →
    "18-22 years old (born 2003-2007) / 67+ from 2026", per Prop.
    2025/26:66. The old bounds would cause integrators to apply the
    reduced rate (20.81%) to 23-24-year-olds who must pay 31.42%,
    producing non-compliant AGI files.

11. Payroll cookbook BAS 2615 corrected to 2731 (Avräkning sociala
    avgifter). 2615 is "Utgående moms vid import" in BAS 2026 — using
    it for the payroll liability would misclassify a payroll payable
    as an import-VAT payable and break moms reconciliation.

12. Year-end cookbook periodiseringsfond cap base corrected: IL 30
    kap 5 § cap is on taxable profit BEFORE the periodiseringsfond
    deduction itself (and after schablonintäkt is added back). Note
    on materiellt samband (BFNAR 2016:10 kap 13) added — the
    reservation is BOOKED on 2110-2139, not declaration-only.

Deferred to follow-ups (architectural / out of scope for round 1):

- Secret-at-rest encryption (CC6.1 / Art.5(1)(f)): PR-1 architectural
  carryover, applies to existing webhooks.secret column too.
- Dedicated `webhooks:rotate` scope (CC6.3 informational): introduces
  friction without closing a real gap when the only caller-driven
  action gated by `webhooks:manage` is the rotation itself.
- Per-route rate-limit on :rotate-secret (Art.32 abuse case): part
  of the wider per-route rate-limit pass already on the deferred list.
- webhook_url redaction in audit_log (Art.5(1)(c)): URLs are admin-
  supplied configuration values with no expected sensitive params;
  truncation would degrade audit value for legitimate review.

23 webhook integration tests pass locally (no regressions).

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

* refactor(api): address PR-501 review round 2 — atomic mutations + audit completeness + cookbook compliance

Round 2 of review fixes. Compliance Swarm flagged refinements to the
round-1 fixes; Swedish-compliance had a fresh batch of cookbook items
(including a self-contradiction in payroll pitfalls I missed last
round). All addressed.

Code changes — atomicity + audit completeness:

1. rotate-secret collapsed to a single UPDATE … RETURNING (V8.2.1).
   The preflight existence-check SELECT was redundant after round 1
   added .select().maybeSingle() on the UPDATE — the same null-row
   signal indicates non-existence, but in one round trip with no
   TOCTOU window. RETURNING `name` so the audit_log description still
   carries a human identifier without a second read.

2. DELETE handler collapsed to atomic .delete().select().maybeSingle()
   (V8.2.1). Eliminates the pre-read TOCTOU window entirely. A 0-row
   delete (already-deleted webhook) still returns 204 — idempotent
   DELETE — and the audit entry captures the attempt with old_state:
   null. Description discriminates the two cases ("deleted: name" vs
   "delete attempted on missing id").

3. Cache-Control: no-store, no-cache, must-revalidate, private on
   the rotate-secret response (Art.25). The HMAC secret is sensitive
   credential material returned exactly once; this header prevents
   any intermediary (CDN, proxy, gateway access log, browser cache)
   from persisting the response body in a store with a different
   retention policy than intended.

4. Dispatcher auto-disable now writes the audit_log entry
   UNCONDITIONALLY (A.8.15 / V16.1.1 / CC7.2). Previously a null
   prior snapshot or a legacy null user_id caused the audit row to
   be silently skipped — only a warn log was emitted. Now writes
   user_id=NULL when unavailable (post-multi-tenant-refactor schema
   allows it; row is invisible under user RLS but queryable under
   service-role review, which is correct for system-initiated
   SECURITY_EVENT records). Description discriminates the snapshot-
   available / snapshot-unavailable cases.

Swedish compliance — cookbook content fixes (all real errors):

5. VAT cookbook rounding rule corrected: SFL 22 kap 1 § mandates
   TRUNCATION of öre (Math.floor for positive amounts), not half-up
   rounding. Last round mislabeled this as "Math.round (half-up)";
   the SRU filing skill is canonical and uses truncation. Using
   Math.round would produce values that differ from Skatteverket's
   expectations and cause GL-reconciliation mismatches at the öre
   level.

6. VAT reconciliation block now includes 2614 (Utgående moms vid
   omvänd skattskyldighet, matches ruta 30). The previous list of
   2611/2621/2631/2641/2645 omitted 2614; a reconciliation that
   skips it would show rutor_match_gl: true even when the 2614
   balance is non-zero and un-reconciled.

7. Livsmedel rate-change adds a one-sentence caveat for continuous/
   subscription supplies — the supply-date framing in round 1 was
   too tight for cases where multiple deliveries roll up into a
   subscription. Confirms against ML 1 kap 3 § rather than
   assuming a single delivery date is decisive.

8. Payroll pitfalls bullet contradicted step 2 — "Employees under 26
   (2024 rule for 2026 birth year ≥ 2001)" rewritten to match step 2:
   "18–22 years old at the start of 2026 (born 2003–2007) AND 67+
   from 2026". An integrator reading only the pitfalls section
   would have applied the reduced rate too broadly, producing
   underpaid arbetsgivaravgifter and a non-compliant AGI.

9. Year-end periodiseringsfond cap now states schablonintäkt explicitly:
   1.94% × outstanding prior-year balance (SLR + 1% for 2026) is
   ADDED to taxable income before the 25% cap is computed. Last
   round mentioned the "BEFORE the periodiseringsfond deduction"
   ordering but elided the schablonintäkt step; omitting it
   produces a cap that's too low when prior-year reserves exist.

10. Year-end SRU format characterization corrected: SRU is plain text
    encoded in ISO 8859-1, NOT XML. iXBRL (XML-based) is the
    Bolagsverket digital annual-report format — a separate artefact
    for a separate authority. Round 1 conflated them.

Deferred (architectural / out of scope, documented in commit):

- Audit-log dead-letter queue / SIEM alert escalation (Art.32 /
  A.8.15): infra setup, not code-PR scope. The warn-on-failure path
  is the in-process surface; durable delivery is a SRE/SIEM concern.
- Secret encryption at rest (CC6.1): PR-1 architectural carryover.
- webhook_url + description redaction in audit_log (Art.5(1)(c)):
  URLs are admin-supplied configuration values; redaction would
  degrade audit reconstructibility without closing a real PII gap.
- PATCH old_state TOCTOU via Postgres function (CC6.3): the read-
  then-write pattern produces an append-only audit row capturing
  the read state; the small race window is non-load-bearing for
  audit purposes and a stored-procedure refactor exceeds the
  cost/value.

23 webhook integration tests pass locally (no regressions). Type-check
clean for all changed files.

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

* refactor(api): address PR-501 review round 3 — real cookbook tax errors + cache-control on create

Round 3 closes two tax-impact errors in the cookbooks plus the
consistency gap on the create response. Compliance Swarm's remaining
findings are recurring architectural carryovers or oscillation against
prior rounds.

Real cookbook errors (would mislead integrators):

1. Schablonintäkt rate corrected. Round 2 hardcoded 1.94% — that's the
   2024 rate (SLR 0.94% + 1%). For 2026 SLR is 2.55%, so the rate is
   3.55%. A wrong rate produces a too-low add-back, a too-high
   periodiseringsfond cap, and an IL 30 kap compliance error for any
   integrator copying the cookbook number. Rewrite to describe the
   formula (SLR + 1%, where SLR is the Riksbank statslåneränta on
   30 Nov of the preceding year) with the 2026 figure as an example,
   and note the engine reads the canonical rate from `tax_rates`.

2. SRU format is a TWO-file pair, not one. Round 2 correctly said
   "plain text encoded in ISO 8859-1 (NOT XML)" but described it as a
   single file. Skatteverket requires both INFO.SRU (metadata header)
   AND BLANKETTER.SRU (declaration body) uploaded together — a
   single-file upload is rejected by their validation. Fix the prose
   to describe the two-file pair explicitly.

Code consistency:

3. POST /webhooks (create) now returns the same
   `Cache-Control: no-store, no-cache, must-revalidate, private` +
   `Pragma: no-cache` headers as the rotate-secret endpoint (A.8.12).
   Both endpoints return the HMAC secret exactly once; both need the
   same intermediary-cache prevention.

Smaller cookbook refinements (round 3 bot follow-ups):

4. VAT reconciliation block now includes 2615 (Utgående moms vid
   import, matches ruta 60) — the previous list covered 2611-2645
   but omitted import VAT. A reconciliation that skips 2615 would
   show rutor_match_gl: true falsely for any importer.

5. Service supply-date fallback statement qualified to "one-off
   service supplies where delivery and invoice coincide" — long-
   running service contracts (subscriptions, maintenance) have
   per-delprestation skattskyldighet and need an explicit
   delivery_date per billing cycle.

6. Payroll elder-reduction boundary clarified: "67 years or older
   AT THE START OF the income year (1 January 2026)" — a 66-year-
   old whose 67th birthday falls in February does NOT qualify in
   2026. Prevents misreading the pithy "67+ from 2026" as a
   birthday-during-year rule.

Bot oscillation (skipping with rationale documented here for posterity):

- Compliance Swarm Art.25 now asks to REMOVE webhook_url from DELETE
  old_state — direct contradiction with CC6.3's round-1 ask for
  complete attribution. webhook_url is admin-supplied configuration,
  not PII; keeping it preserves audit reconstructibility.

- Swedish-compliance flags the unconditional re-delete audit row as
  "polluting" the behandlingshistorik — direct contradiction with
  Compliance Swarm V8.2.1 + CC6.3 round-1 / round-2 asks for
  unconditional writes. The audit_log is operational, not BFL
  räkenskapsinformation (which lives on journal_entries and
  related tables under explicit immutability triggers). Audit
  trail completeness wins over BFL purity for this table.

Architectural carryovers (already documented in earlier commit
bodies as deferred to follow-up PRs):

- Secret encryption at rest (CC6.1, recurring)
- Audit-log dead-letter / SIEM alerting (Art.32 / A.8.15, infra)
- webhook_url userinfo stripping (A.8.11 low — URLs are admin-
  configured, no expected credentials; validating at registration
  would be a registration-time concern, not audit-time)

23 webhook integration tests pass. Type-check clean.

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 21:31:47 +02:00
Jakob Wennberg afb21ea638 feat(api): Phase 6 PR-3 — substrate hardening (SKIP LOCKED + DNS pinning + test debt) (#500)
* feat(api): operations table immutability trigger

BFNAR 2013:2 kap 8 § behandlingshistorik integrity: once an operations
row is in a terminal status (succeeded / failed / cancelled) the audit
record of what happened becomes immutable. Adds the BEFORE UPDATE and
BEFORE DELETE triggers that the webhook_deliveries table already has
(20260515170000 / 20260515190000), mirroring their predicate shape and
error code exactly.

Closes the Phase 4 PR-2 (PR #469) review-round carry-over flagged by
Swedish-compliance: previously a future bug, a privileged operator, or
a compromised service-role caller could rewrite "this year-end close
succeeded" to "failed" by updating an already-terminal row. The
running → succeeded/failed/cancelled transition itself stays legal
because the trigger keys on OLD.status, which is non-terminal at the
moment of the legitimate UPDATE.

pg test covers all transitions (allowed and blocked) plus DELETE on
both terminal and non-terminal rows.

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

* feat(api): atomic SKIP LOCKED claim for webhook dispatch

Replaces the SELECT-then-UPDATE-intersect pattern in the dispatcher
with a single-roundtrip SQL function using FOR UPDATE SKIP LOCKED.
PostgREST can't express SKIP LOCKED through the JS client, so the
previous shape relied on a CAS guard inside an UPDATE WHERE status IN
('pending','failed') to ensure only one of two overlapping cron ticks
claimed any given row.

The CAS pattern was correct (under load — receivers >60s could push a
batch past the next minute's tick) but burned two round trips and
forced the application to negotiate the locking semantics in JS.
The function form moves the contention to the DB, where SKIP LOCKED
makes a row held by a concurrent tick simply invisible to the second
caller. One round trip, no JS-side intersect.

All filter semantics are preserved verbatim inside the function:
status IN ('pending','failed'), next_attempt_at <= now, webhook_id
IS NOT NULL, ORDER BY next_attempt_at ASC, LIMIT batchSize. p_batch_size
is bounded (0, 1000] to forestall a runaway lock-set in case a caller
misconfigures it.

pg test covers basic claim (pending + failed), future-due skip, dangling-
row (webhook_id IS NULL) skip, terminal-status skip, batch-size limits,
out-of-range argument rejection, and the SKIP LOCKED invariant itself
using two concurrent pool clients in BEGIN — the second caller does not
see the row A locked, no double-delivery.

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

* feat(api): pinned-IP HTTPS dispatch (close DNS-rebinding window)

The url-guard.ts file header openly flagged the remaining gap:
"a separate DNS-rebinding window (between dispatch-time validation
and the actual fetch) remains; closing that requires a custom HTTPS
agent that pins the resolved IP — tracked for follow-up." This closes it.

The previous shape was:
  1. validateWebhookUrl()  → DNS resolves to [public IP], returns ok
  2. fetch(webhook_url)    → re-resolves DNS; an attacker who flipped
                             the A record in the interval gets a
                             private-IP socket

The new pinnedHttpsFetch helper validates DNS once, then opens a
node:https.request to that pinned IP — but keeps the original hostname
in the TLS SNI extension (so the receiver's cert validates) and in the
HTTP Host header (so vhost routing still works). The request socket
never re-resolves DNS, foreclosing the rebind race entirely.

Built on node:https.request rather than undici's Agent so the project
doesn't take on a new dep — the stdlib API is also more explicit about
the SNI / Host / pinned-IP split. Test seam injects both validateUrl
and httpsRequest so the unit tests verify the pinning shape without
standing up an HTTPS server.

The dispatcher's attemptDelivery is rewritten as a switch over the four
PinnedFetchResult kinds (ok / unsafe_url / redirect_blocked / timeout
/ transport_error). The previous fetch-based code path that distinguished
redirect rejection by string-matching err.message is gone — the new
result type makes the distinction structural.

8 unit tests cover the SNI/Host/pinned-IP shape, port handling,
redirect_blocked, transport_error, timeout, response-body truncation,
first-IP determinism, and the validation short-circuit (never opens a
socket when the URL fails the SSRF guard).

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

* test(api): pg tests for webhook substrate triggers (PR-1 test debt)

CLAUDE.md ("Testing" + "Migration Rules") mandates a *.pg.test.ts for
any PR touching a trigger / RPC / RLS / DEFERRABLE constraint. Phase 6
PR-1 (#496) shipped three webhook_deliveries triggers without the
accompanying pg test; this closes that debt.

Triggers covered:
  - enforce_webhook_delivery_immutability  (BEFORE UPDATE)
  - block_webhook_delivery_terminal_delete (BEFORE DELETE)
  - assert_webhook_delivery_company_match  (BEFORE INSERT)

13 cases verify the lifecycle the dispatcher depends on remains mutable
(pending → in_flight, in_flight → failed, failed → in_flight, in_flight
→ delivered) while terminal-status rows (delivered / dead) are write-
locked and the cross-tenant INSERT path is refused with the
ERRCODE=check_violation contract documented in the migration.

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

* test(api): integration tests for webhook routes (PR-1 test debt)

CLAUDE.md mandates integration tests under app/api/v1/ for every route.
Phase 6 PR-1 (#496) shipped the eight v1 webhook routes (five under
/companies/{companyId}/webhooks/ + the cross-tenant /webhook-deliveries/
{id}/retry) without them; closes that debt.

19 cases for the /webhooks/ verticals:
  POST     /webhooks                    create + secret-once + payroll-scope gate + SSRF
  GET      /webhooks                    list (no secret) + empty list
  GET      /webhooks/:id                detail (no secret) + 404
  PATCH    /webhooks/:id                update + active=true re-enable + SSRF re-check + empty-body
  DELETE   /webhooks/:id                204 hard delete
  POST     /webhooks/:id/test           enqueue + 404 + disabled-rejection
  GET      /webhooks/:id/deliveries     happy path + ownership 404

7 cases for the retry route:
  POST /webhook-deliveries/:id/retry   dead → fresh pending row, live-status refusal,
                                       cross-tenant 404, disabled-webhook gate,
                                       SSRF re-check, delivery 404, webhook-gone 404

Both files mirror the suppliers/customers integration test pattern:
Proxy-backed Supabase mock with per-table queues, validateApiKey +
validateWebhookUrl stubbed to control auth and DNS deterministically.

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

* refactor(api): address PR-500 review round 1 — pg-real CI fix + 4 review items

1. pg-real CI was red on this PR: the new webhook trigger pg.test.ts and
   claim-due-webhook-deliveries pg.test.ts fixtures tried to INSERT into
   `webhooks.user_id`, which doesn't exist in the migration history. The
   column was never declared in automation_webhooks (20260415000000) nor
   added by webhooks_v2 (20260515170000) — so a fresh schema replay had
   no such column. The webhook create route (`webhooks.create`) was also
   referencing this non-existent column in its INSERT, so the production
   route was latent-broken since PR-1 and never exercised against a fresh
   DB. Drop the `user_id` field from both the route INSERT and the pg
   fixtures. Actor attribution lives on `created_by_api_key_id` (which
   leads back to the owning user via `api_keys.user_id`).

2. Greptile P2 #1 — `recoverStuckInFlight` carried a redundant
   `.not('status','in','(delivered,dead)')` filter alongside
   `.eq('status','in_flight')`, with a comment that incorrectly described
   PostgreSQL's UPDATE re-evaluation semantics. Under READ COMMITTED,
   UPDATE re-evaluates WHERE against each row's CURRENT value when it
   acquires the row lock — a row that raced to terminal status will fail
   `status='in_flight'` on re-evaluation and be skipped, no immutability
   trigger fires. Drop the redundant filter and rewrite the comment.

3. Greptile P2 #2 — added explicit pg test verifying `in_flight` rows are
   skipped by `claim_due_webhook_deliveries`. The status filter is what
   prevents double-delivery and is the entire point of the SKIP LOCKED
   substrate; making that invariant load-bearing in the test suite
   forecloses a future filter expansion silently regressing it.

4. Greptile P2 #3 — pinned-fetch registered both `res.on('end', finalize)`
   and `res.on('close', finalize)`. Node fires BOTH on normal completions,
   so finalize ran twice; the outer `settled` guard squashed the
   double-resolve but the header reconstruction still ran twice. Switch
   to `once` + self-removing pair so finalize runs exactly once on
   whichever event fires first (normal: end; truncation: close).

5. Compliance Swarm V8.2.1 — the retry route only checked
   `webhooks:manage` even when retrying `salary_run.* / agi.*` deliveries.
   Mirror the create-route elevated-scope gate so a key with only
   `webhooks:manage` cannot re-emit payroll payloads carrying
   personnummer / lönesummor / skatteavdrag. New integration test verifies
   the gate returns 403 INSUFFICIENT_SCOPE with `required_scope:
   payroll:read`.

35 tests pass locally (+1 vs pre-fix). Type-check clean.

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

* refactor(api): address PR-500 review round 2 — 2 small precision fixes

1. Compliance Swarm Art.32 / A.8.24 — response_body size cap was enforced
   only at the application layer (pinnedHttpsFetch's maxResponseBytes=4096
   constant). A future refactor that bypassed the truncation, or a non-
   dispatcher write path into webhook_deliveries.response_body, would
   silently land large blobs in a column adjacent to event payloads
   carrying personal data. Add a CHECK constraint at the DB layer with
   a generous ceiling (8 KB — double the application cap so legitimate
   dispatcher writes never hit it; only a regression surfaces as a
   check_violation).

2. Compliance Swarm CC6.6 — pinned-fetch substitutes the validated IP
   for `host` while keeping the original hostname in `servername`. A
   reader could reasonably worry that the IP substitution weakens TLS
   hostname verification. Document explicitly that Node's default
   `checkServerIdentity` matches the cert's SAN/CN against `servername`
   (not `host`), so a forged endpoint at the pinned IP with a valid
   cert for a different hostname would fail the handshake. No code
   change — the default behavior is correct; the comment forecloses
   future "this looks dangerous" review-round noise on the same line.

Items NOT addressed (with rationale documented elsewhere):

- Compliance Swarm V8.2.1 (retry route 404-vs-404 information leak):
  delivery IDs are UUIDs; the "leak" is the ability to probe existence
  of an opaque 128-bit identifier the caller already has, which is not
  meaningfully different from probing for any opaque token. Both
  branches return the same structured 404 envelope.

- Compliance Swarm CC7.2 (restore the .not() defense-in-depth filter):
  direct contradiction of last round's Greptile P2 fix. Greptile's
  PG-semantics analysis is correct — under READ COMMITTED, UPDATE
  re-evaluates WHERE against the row's current value when it acquires
  the lock, so .eq('status','in_flight') already handles the race.
  Adding a redundant .not() restores a misleading comment without
  closing a real gap. This is the documented Compliance Swarm
  oscillation pattern from the project's Phase 4 lessons.

- Compliance Swarm CC6.1 (webhook secret encryption-at-rest):
  architectural choice from PR-1; not in PR-3 (substrate hardening)
  scope. Belongs to a future hardening PR.

- Swedish-compliance review (operations queued/running rows hard-
  deletable): deliberate operability tradeoff — operators need to
  clear stuck/queued entries that crashed mid-flight. Blocking all
  deletes would force a manual DB intervention every time a worker
  crashed before reaching terminal status. The audit trail starts
  at terminal-state mutation, which IS blocked.

- Swedish-compliance review (salary_run.* / agi.* payload anonymisation
  after 7 years): already on the deferred-list as part of the 90-day
  TTL cleanup cron item from the PR description. Belongs to a
  retention-policy follow-up PR.

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 20:38:19 +02:00
Jakob Wennberg 3912c74a7b feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired) (#497)
* feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired)

Ships the developer-facing documentation surface for the v1 REST API.
Mirrors Stripe's structure (landing → cookbooks → concepts → reference
→ errors → changelog) at /docs/api with a sticky-sidebar layout in the
gnubok editorial-monochrome aesthetic. Every page is also served as
plain Markdown via a sibling .md URL so agents and LLM crawlers can
ingest the same content without HTML parsing — the existing /llms.txt
already promised /docs/api references that this PR makes real.

Single source of truth for endpoint metadata is the existing Zod
registry (lib/api/v1/registry.ts). The reference pages auto-generate
from it: adding a new endpoint surfaces in the docs on the next build
with no manual sync. The error reference pulls directly from
lib/errors/structured-errors.ts STRUCTURED_ERRORS.

CONTENT LAYER (lib/docs/):

- content/landing.ts — introduction, auth, base URL, response envelope,
  the four core principles (dry-run, idempotency, strict-mode, inline
  audit), pointers to every other section.
- content/versioning.ts — versioning + deprecation policy (Stripe dated
  format), idempotency, dry-run, strict-mode write semantics, inline
  audit blocks.
- content/webhooks.ts — webhook concept guide. Full Node.js (express +
  crypto) and Python (Flask + hmac) signature-verification samples that
  match lib/webhooks/signing.ts exactly. Lifecycle, event-type
  catalogue, payload shape, request headers, common pitfalls,
  auto-disable behaviour, audit + retention.
- content/errors.ts — generated from STRUCTURED_ERRORS. Groups by
  domain (generic, bookkeeping, periods, invoices, supplier-invoices,
  transactions, reports, imports, documents, salary, company, provider).
  Every code is anchorable so the docs_url field on every error
  envelope finally points somewhere real.
- content/reference.ts — generated from listEndpoints(). Groups by
  resource (companies, customers, invoices, suppliers,
  supplier-invoices, transactions, journal-entries, fiscal-periods,
  accounts, documents, employees, salary-runs, reports, imports,
  compliance, webhooks, operations, voucher-gap-explanations,
  reconciliation). Each endpoint section: summary, description,
  useWhen, doNotUseFor, pitfalls, scope, idempotent/reversible/dry-run
  flags, request + response examples.
- content/changelog.ts — initial entry for API version 2026-05-12
  covering every endpoint shipped in Phases 1-6. Lists what's coming
  in Phase 6 PR-3 (hardening + remaining cookbooks).
- content/cookbook/quickstart.ts — five-minute send-your-first-invoice
  guide. Demonstrates auth, dry-run, idempotency, audit-block patterns
  in one continuous narrative.
- content/cookbook/webhooks.ts — end-to-end webhook setup, sig
  verification, retry handling, idempotency on receiver side, replay
  patterns, auto-disable behaviour. Companion to the concept page.
- content/cookbook/index.ts — recipe registry. 4 placeholder recipes
  (ingest-bank-transactions, file-vat-declaration, run-payroll-and-agi,
  year-end-closing) link to their reference pages with a "coming after
  Phase 6 PR-3 hardening" note. Narrative cookbook quality benefits
  from a focused pass after the substrate stabilises.
- nav.ts — single source of truth for the sidebar nav, used by the
  layout AND the landing-page resource grid.
- markdown.tsx — shared <DocsMarkdown> component using react-markdown
  (already a dep) with Hedvig serif headlines, Geist mono code blocks,
  hairline section borders, paper-white surfaces — same editorial
  aesthetic as the dashboard.

LAYOUT (components/docs/DocsLayout.tsx):

Two-column sticky-sidebar layout. Top header carries the gnubok mark
+ section links (API reference, Cookbooks, Errors, Changelog,
openapi.json). Sidebar groups: Getting started, Cookbooks, Concepts,
API reference, Reference. Active page highlighted with the same
warm-beige bg the dashboard sidebar uses.

ROUTES (app/docs/api/, app/llms-full.txt/):

- /docs/api → landing
- /docs/api/errors → error reference
- /docs/api/webhooks → webhook concept
- /docs/api/versioning → versioning + idempotency + dry-run
- /docs/api/changelog → release notes
- /docs/api/reference → resource overview
- /docs/api/reference/[slug] → per-resource pages (19 resources, all
  generated from the registry; generateStaticParams listed)
- /docs/api/cookbook/[slug] → recipe pages (8 entries, 2 fully written
  + 6 aliases/placeholders)
- /llms-full.txt → everything concatenated for one-shot LLM ingestion

Every page has a sibling .md route (e.g. /docs/api/errors.md) serving
the raw Markdown for agents — same content, no HTML wrapper, same
5-min cache. Honours the existing /llms.txt promise that "every .md
URL under /docs/api is served as plain Markdown".

CI GUARD (lib/api/v1/__tests__/spec-snapshot.test.ts):

Vitest snapshot test that locks down (a) the endpoint count, (b) the
sorted set of method+path keys, (c) the set of distinct scopes
referenced. CI fails if any drift unexpectedly so a Zod-schema change
can't ship a silent API break — when you intentionally add/remove an
endpoint, run with -u to refresh the snapshot, review the diff, and
commit alongside the route change. The snapshot diff itself is a
self-describing changelog entry.

Initial snapshot: 100 endpoints, 17 distinct scopes, full key set
sorted. Fourth assertion in the test guarantees every endpoint
declares the agent-facing metadata (summary, description, useWhen,
doNotUseFor, pitfalls, example) the reference pages depend on — so a
registerEndpoint call that omits any of these fields is caught at CI
time rather than rendering an empty section in the docs.

INFRA TOUCH (lib/api/v1/load-routes.ts):

Added the 5 Phase 6 webhook route imports so the registry includes
them on the docs builders' path. Required for the /docs/api/reference/
webhooks page to render. The webhook routes' registerEndpoint calls
already exist; this just side-effect-imports them where the spec
generator can see them.

Coming in Phase 6 PR-3 (hardening — separate PR):

- 90-day TTL cleanup cron for non-accounting webhook deliveries
- claim_due_webhook_deliveries SQL function (FOR UPDATE SKIP LOCKED)
- Per-route rate limits on :test, :retry, webhook :create
- V16 audit-log on webhook lifecycle events
- DNS-rebinding pinned-IP HTTPS agent
- Integration tests for webhook routes + *.pg.test.ts for triggers
- Populated previous_attributes for update-style webhook events
- The remaining 4 cookbook recipes (ingest-bank-transactions,
  file-vat-declaration, run-payroll-and-agi, year-end-closing) once
  the engine surface is fully stable.

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

* refactor(api): address PR-497 review round 1 — CI fix + 5 small docs items

CI BLOCKER (the reason core-only failed):

1. **Type error on `[slug].md/route.ts` dynamic routes** — Next.js 16's
   route-type inference can't extract the dynamic segment from a
   directory whose name contains a literal suffix like `[slug].md/`. It
   types `params` as `Promise<{}>` and rejects our handler that
   declares `params: Promise<{ slug: string }>`. The framework still
   ROUTES requests correctly (URL `/docs/api/cookbook/quickstart.md`
   reaches the handler) — only the typed `params` is unusable.

   Fix: drop the typed `params` parameter on the two affected handlers
   (cookbook + reference) and parse the slug from `request.url.pathname`
   directly. Inline comment documents the workaround so the next person
   to touch these doesn't try to "fix" it back to the typed pattern.

GREPTILE INLINE (2 items):

2. **Python sample was missing `import json` and `import os`** — the
   webhook signature-verify sample uses both but only imported `hmac`,
   `hashlib`, `time`, and `flask`. Added the two missing imports.

3. **`buildResourcePages()` perf — called twice per request** (P2).
   Each call iterates every registered endpoint, groups by resource,
   sorts, and serialises Markdown for all 19 resource pages. Memoised
   at module level — the registry is populated once at module load and
   immutable for the process lifetime, so a single derivation is safe
   to cache. Halves the cost on the HTML routes' `generateMetadata` +
   page render pair, and the .md route handlers (which Next.js doesn't
   statically pre-render) are now constant-time after the first GET.

SWEDISH-COMPLIANCE PRECISION (3 items):

4. **`webhooks.ts`: behandlingshistorik vs räkenskapsinformation
   distinction.** The previous "Audit + retention" section conflated
   the two — webhook delivery rows are *behandlingshistorik* (system-
   event log) per BFNAR 2013:2 kap 8 §, NOT räkenskapsinformation
   themselves. The 7-year retention from BFL 7 kap 1 § attaches to the
   underlying verifikation/faktura/AGI XML in its own table, not to
   the delivery envelope. Updated the section to draw the distinction
   and clarify gnubok's 7-year retention on accounting-event delivery
   rows is an operational audit-trail policy, not a statutory
   obligation passed through to the integrator.

5. **`changelog.ts`: same distinction in the Phase 6 PR-1 entry** —
   replaced the "räkenskapsinformation" framing with the correct
   behandlingshistorik framing + the operational-policy note.

6. **Quickstart cookbook: ML 17 kap 24 § p.8 note about
   `beskattningsunderlag per skattesats`.** The "What just happened"
   section now explicitly notes that the rendered PDF contains every
   ML 17 kap 24 § field (including taxable amount per VAT rate) and
   that the JSON response's summary fields are convenience aggregates
   for the integration — the binding faktura content is the PDF.
   Forecloses the misreading that `subtotal + vat_total` is sufficient
   compliance.

7. **Changelog: BFL 7 kap caveat on SIE export.** The `/reports/
   sie-export` line now warns that a SIE4 export alone does NOT
   satisfy BFL 7 kap archiving obligations — SIE captures account
   positions and verifikationer but lacks system documentation and
   behandlingshistorik. Treat as a portability format
   (Fortnox/Visma/Bokio migration), not as a complete archive. Closes
   the misreading the swedish-sie-import-export skill flagged.

DEFENSIBLE DEFERS (round 1 final):

- **CM-8 SPDX-License-Identifier headers per file** (Compliance Swarm).
  The repo declares AGPL-3.0-or-later in the root LICENSE file, which
  satisfies licensing for the project as a whole. Per-file SPDX
  headers are a REUSE-conformance feature; we can address as a sweep
  across the entire codebase if/when REUSE conformance becomes a
  requirement. Out of scope for a docs PR.

- **`/llms-full.txt` exposes payroll endpoint metadata publicly**
  (A.8.12). By design — the entire point of the file is one-shot LLM
  ingestion of the public docs corpus. Endpoint METADATA (path, scope,
  description) is non-sensitive; actual payroll DATA is gated behind
  `payroll:read` scope and requires a real API key. Adding an auth
  gate would defeat the agent-discovery purpose.

- **Secret rotation endpoint** (Art.25(2)). Real product gap (delete +
  recreate is the current rotation path), but it's feature work, not
  docs. Tracked for Phase 6 PR-3 alongside the other webhook hardening.

- **DNS rebinding pinned-IP HTTPS agent** (Art.5(1)(f)). Already
  documented in this changelog as a Phase 6 PR-3 item; bot is just
  re-flagging that it's not yet shipped.

- **A.5.34 changelog cites GDPR Art.5(1)(c) for personnummer masking
  without linking privacy policy.** The citation is informational
  context for developers, not a privacy notice to data subjects. Data-
  subject notices live at /privacy. Adding a pointer is reasonable;
  adding it would consume real estate that's better spent on the
  technical detail. Defer.

- **Compliance Swarm A.5.21 third-party attribution in llms-full.txt**.
  False positive — all markdown content in this PR is original
  first-party text. No third-party snippets to attribute.

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

* refactor(api): address PR-497 review round 2 — 6 small precision fixes

All CI green after round 1 (core-only fixed). Compliance Swarm: 7 → 10
findings is the documented oscillation pattern — net-new actionable
items are 6 small fixes; the rest are recurring defers
(plaintext-secret variants, SPDX, planned PR-3 features the changelog
already lists as "coming soon").

FIXED:

1. **Slug allow-list validation in `[slug].md` routes** (V1.2.5 ×2,
   medium). The cookbook + reference .md routes parse the slug from
   the URL pathname (round-1 workaround for Next.js 16's failed
   inference on `[slug].md/` directories) and pass it to a
   dictionary-based lookup. The lookup itself is safe — findRecipe /
   buildResourcePages can't reach SQL or filesystem from a bad
   slug — but the explicit allow-list gate keeps the contract safe
   if the lookup mechanism ever changes (file-load, RPC, etc.).
   Added `Set<string>(COOKBOOK_SLUGS)` + `Set<string>(RESOURCE_SLUGS)`
   guard before any lookup runs.

2. **Changelog: BFL 5 kap 5 § cited on both `/reverse` AND `/correct`**
   (swedish-compliance precision). The previous wording cited BFL 5:5
   only on `/reverse` (storno) and described `/correct` as plain
   "rättelse" — but BFL 5:5 governs rättelse generally, and storno is
   the canonical method of rättelse, so both endpoints satisfy 5:5.
   Updated to: "/{id}/reverse (storno) and /{id}/correct (rättelse) —
   both satisfy BFL 5 kap 5 § (storno is the canonical method of
   rättelse)".

3. **Changelog AGI: explicit that XML is for manual submission**
   (swedish-compliance / swedish-payroll). Previously said
   "/generate-agi produces AGI XML" — could be misread as
   auto-submission to Skatteverket. Now states explicitly that the
   response carries `data.xml` for the integrator to upload via
   Skatteverket Mina Sidor (or via the optional `skatteverket`
   extension), and that the AGI deadline (12th / 17th of the
   following month) is the integrator's responsibility. Aligns with
   the route file's existing doNotUseFor + pitfalls metadata.

4. **Quickstart: F-skatt note qualified**
   (swedish-invoice-compliance). The "What just happened" section
   previously said the PDF "contains the F-skatt note" — only valid
   if the seller actually holds F-skatt. Updated to: "The 'Godkänd
   för F-skatt' note is included automatically when
   company_settings.has_f_skatt is set — confirm this on the company
   settings page before sending invoices in production." Also
   tightened the beskattningsunderlag wording to mention "one line
   per distinct rate on multi-rate invoices" — closes the
   swedish-compliance note about the multi-rate claim in the landing
   needing explicit support in the cookbook.

5. **Cookbook placeholder VAT description: "compute and review" not
   "submit"** (swedish-vat). The placeholder previously said "Compute
   momsdeklaration rutor and submit to Skatteverket" — but no
   Skatteverket-submission endpoint exists in the v1 surface; the
   API only computes the rutor 05–62 values for manual filing.
   Updated description in BOTH cookbook/index.ts AND nav.ts (where
   the same string was duplicated): "Compute momsdeklaration rutor
   05–62 and reconcile against the GL before manual submission to
   Skatteverket." Title also flipped from "File a VAT declaration"
   to "Compute and review a VAT declaration".

6. **Cookbook nav for AGI: aligned to "generate" semantics**. nav.ts
   AGI summary used to say "file AGI" — same misreading risk as #3.
   Now: "Calculate, approve, mark paid, book, generate AGI XML for
   manual Skatteverket upload."

DEFERS (round 2 final — every remaining swarm finding is in one of
these buckets):

- **🟠 Art.32 plaintext webhook secret + 4 sibling framings** (V14,
  V11.1, A.8.24, CC6.1). Established defer per Stripe / GitHub /
  Slack precedent; documented inline in lib/webhooks/signing.ts.
  Bot is re-flagging via the docs surface this round; underlying
  position unchanged.
- **🟡 Art.5(1)(e) 90-day TTL non-accounting cron + V16 audit log +
  V2.4 rate limits**. All explicitly listed in the changelog as
  "Coming soon (Phase 6 PR-3 hardening)". Bot is reading the same
  text we wrote; not blocking.
- **🟠 A.8.11 personnummer masking lacks an automated test**. Real
  product-hardening request, but it's feature work in the employees
  test surface, not docs. Tracked.
- **🟡 CM-8 SPDX-License-Identifier headers per file**. Established
  defer from round 1 — root LICENSE covers AGPL-3.0-or-later for
  the project as a whole; per-file SPDX is a REUSE-conformance sweep
  that's its own effort.
- **🟡 SR-3 SBOM dependencies**. False positive — next/server, react,
  next/link, next/navigation are existing project deps, not new in
  this PR.
- **swedish-compliance "SIE disclaimer could note immutability
  requirement"** — the current disclaimer accurately calls out
  system documentation + behandlingshistorik as missing; adding
  immutability would over-stuff a one-line caveat. Defer with the
  understanding that the SIE skill itself documents the
  immutability requirement for any consumer that follows the
  reference.

If round 3 plateaus (Compliance Swarm count stable, no net-new
inline items), 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-497 review round 3 — 4 small precision fixes

Compliance Swarm: 10 → 3 (down 70%) — net-new actionable items are
the 4 below; remaining 3 swarm findings are either trivial defense-
in-depth (URL decode, fixed here) or out-of-repo decisions
(personnummer disclosure DPO confirmation).

FIXED:

1. **URL-decode slug before allow-list check** in both .md route
   handlers (V1.2.5 ×2 low). The closed allow-list is pure ASCII so a
   percent-encoded value can't decode to a legitimate slug, but the
   explicit decode-then-check pattern keeps the contract correct under
   any future encoding-quirk runtime. try/catch around
   decodeURIComponent so a malformed % sequence (which throws) returns
   a clean 404 rather than a 500.

2. **Quickstart: explicit `delivery_date` requirement** (swedish-
   invoice-compliance / ML 17:24 field 7). The previous wording
   listed "supply date" as a covered field but didn't note that the
   API does NOT default delivery_date to invoice_date — integrators
   shipping invoices for goods delivered on a different date than the
   invoice date must pass delivery_date explicitly or the rendered
   PDF is non-compliant. Added explicit pass-it-yourself note.

3. **Quickstart: F-skatt strengthened from "verify" to "legal
   requirement"** (swedish-invoice-compliance / Peppol BIS 3.0
   SE-R-005). The previous "confirm on settings page" wording risked
   integrators treating the F-skatt note as optional UX. It's a legal
   requirement on every faktura issued by a company that holds
   F-skatt registration — and a FATAL Peppol BIS 3.0 validation
   failure (SE-R-005) for B2G invoices when missing. Reframed as a
   compliance assertion: the PDF includes it automatically when the
   setting is true; verifying the setting is correct before
   production is the integrator's responsibility.

4. **Changelog: SIE post-import VAT code reconfiguration warning**
   (swedish-sie-import-export). The /imports/sie line previously
   noted the file format support but didn't warn that SIE files do
   NOT carry VAT codes or tax-rate-to-account mappings. After
   migrating from Fortnox / Visma / BL / SpeedLedger / Bokio,
   integrators must manually reconfigure VAT codes before the first
   momsdeklaration — skipping this is the most common source of
   incorrect VAT submissions in migrated bookkeeping.

DEFERS (round 3 final — these are the architectural floor):

- **🟠 A.5.34 personnummer field name + masking logic disclosed in
  public docs** (Compliance Swarm). Defensible: documenting that
  personnummer is masked is a transparency benefit (GDPR Art.13/14
  intent), not a privacy disclosure risk. The DPO confirmation prompt
  is a reasonable governance ask but is an out-of-repo decision —
  the docs change is appropriate as written.
- **AGI penalty amounts (625 / 1,250 SEK)**. Operational guidance
  for integrators building deadline-tracking; not strictly API-doc
  material. The deadline (12th / 17th) is documented; integrators
  who automate compliance can read SFL for penalties.
- **VAT period thresholds in the cookbook placeholder**. Belongs in
  the actual cookbook content when written, not in the placeholder
  description.
- **`invoice.credited` event-naming verification**. False positive —
  the emitter uses `credit_note.created` (which IS in the docs);
  there is no `invoice.credited` event in the codebase. Naming is
  consistent.
- **Webhook retention sentence reordering** (swedish-compliance
  stylistic). Current wording leads with what delivery rows ARE
  (behandlingshistorik), then clarifies what they are NOT
  (räkenskapsinformation) — clean teaching arc, the qualifier is
  prominent. Reordering doesn't change clarity.

Round 3 stop signal hit (per Phase 4 lessons): swarm count plateauing
at the architectural floor with all remaining findings either
defers, false positives, or out-of-repo decisions. Greptile has
posted no inline comments since round 1's two items (both fixed).
This should be merge-ready.

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

* refactor(api): address PR-497 review round 4 — 5 small precision fixes

Compliance Swarm: 3 → 5 (slight uptick from oscillation, but 0
critical, 1 actionable; remaining 4 are recurring or philosophical).
swedish-compliance: 7 advisories — 3 actionable precision items
incorporated below; the others are forward-looking notes for cookbook
content that ships in Phase 6 follow-ups.

FIXED:

1. **Spec-snapshot test enforces ep.scope is explicitly defined**
   (CC6.3, real future-bug prevention). Previously the test asserted
   every endpoint declared the agent-facing metadata fields the docs
   depend on, but `scope` could be `undefined` — a registerEndpoint
   call that silently dropped the field would make the wrapper treat
   the route as unauthenticated. Added an assertion that
   `ep.scope !== undefined` (the literal sentinel `null` is allowed
   for genuinely public endpoints like /api/v1/health). The 4 spec
   tests still pass — confirming no current endpoint has undefined
   scope and the gate works prospectively.

2. **F-skatt: integrator responsibility for `has_f_skatt` accuracy**
   (swedish-invoice-compliance). The previous "verify on settings
   page" framing didn't connect the flag to the live Skatteverket
   registration. Now: "The integrator is responsible for keeping
   has_f_skatt in sync with the company's live Skatteverket
   registration status. Update via PATCH /api/v1/companies/{id}/
   settings or the settings page — a flag that's false while the
   company is actually F-skatt-registered produces non-compliant
   invoices, not merely a missing optional note."

3. **AGI deadline qualified by turnover** (swedish-payroll). The
   previous wording listed "12th / 17th of the following month" with
   no condition. Now: "12th of the following month for large
   employers, 17th for companies with annual turnover ≤ 40 MSEK."
   Aligns with the swedish-payroll skill's AGI filing deadline
   section.

4. **SIE import warning includes behandlingshistorik gap**
   (swedish-sie-import-export + swedish-accounting-compliance). The
   previous warning covered the VAT-code reconfiguration requirement
   but didn't note that SIE files also do NOT transfer
   behandlingshistorik (the source system's processing log per
   BFNAR 2013:2 kap 8 §) or systemdokumentation. Added: "The
   behandlingshistorik gap must either be preserved separately
   (export from the source system + archive alongside the SIE file)
   or accepted with documented justification — gnubok starts a fresh
   behandlingshistorik from the import date forward."

5. **Webhook 7-year retention: voluntary policy vs statutory
   obligation** (swedish-accounting-compliance). The previous wording
   said gnubok keeps delivery rows "for 7 years as an operational
   audit-trail policy" — the 7-year figure could be misread as
   statutory. Tightened in BOTH webhooks.ts and changelog.ts:
   the 7-year statutory retention under BFL 7 kap 1 § applies ONLY
   to the underlying verifikation/faktura/AGI XML; gnubok's 7-year
   policy on delivery rows is voluntary and chose the duration to
   align conveniently with the statutory horizon on the underlying
   records.

DEFERS (round 4 final, all in the architectural-floor bucket):

- **🟠 A.5.34 personnummer field name + masking logic disclosed in
  public docs** (recurring from round 3). Defensible — documenting
  PII handling is a transparency benefit (GDPR Art.13/14 intent),
  not a privacy disclosure risk. The DPO confirmation prompt is a
  reasonable governance ask but is an out-of-repo decision.
- **🟡 A.8.23 DNS-rebinding "coming soon" item**. The bot is reading
  the changelog's own deferral list. Already tracked for Phase 6
  PR-3 hardening.
- **🟠 CC6.6 SSRF protection details exposed in /llms-full.txt**.
  Stripe / GitHub / Slack publish their full webhook security
  posture publicly (signature format, rejected IP ranges, retry
  policy) — documenting protections IS the trust pattern. Obscurity
  is not security; the SSRF protection is enforced in code, not in
  the docs.
- **🟡 CC6.7 public CDN caching**. withPublicSecurityHeaders()
  already applies the appropriate headers (CSP, X-Content-Type-
  Options, X-Frame-Options). The 5-min cache is appropriate for
  static developer documentation; the alternative (no caching) is
  cost without security benefit since the content is intended to be
  public.
- swedish-compliance "VAT 2026-04-01 livsmedel rate change" —
  forward-looking; ships when the actual VAT cookbook is written.
- swedish-compliance "year-end IB/UB continuity" — forward-looking;
  ships when the year-end cookbook is written.
- 2 verify-only notes (rättelse implementation, future salary-
  journal/avgifter-basis masking sweep) — not actionable in this PR.

Round 4 stop signal: every remaining swarm finding is in the
deferred or recurring bucket; the actionable item (CC6.3) is
shipped. swedish-compliance is now in advisory mode (no errors,
just stylistic suggestions and future-cookbook notes). Per Phase 4
lessons, this is the merge-ready signal.

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

* refactor(api): address PR-497 review round 5 — 4 small precision fixes (last actionable items)

Compliance Swarm: 5 → 2 (down to architectural floor — 1 high + 1
medium). swedish-compliance: 7 advisories, 4 actionable precision
items addressed below; the others are forward-looking notes for
content that ships in Phase 6 follow-ups.

Trajectory: 7 → 10 → 3 → 5 → 2. Plateaued.

FIXED:

1. **Webhook secret storage guidance: secrets manager, not env file**
   (A.8.5 high). Added explicit instruction to the cookbook that the
   returned secret is signing material and must live in a secrets
   manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault,
   Doppler, 1Password Connect, ...) — not a plaintext .env file or
   config commit. Treated with the same care as a database password.

2. **AGI deadline correction: 17th = January and August only**
   (swedish-payroll). Round 4's wording said "12th (large employers) /
   17th (≤40 MSEK)" — but per the swedish-payroll skill the 17th
   applies in January and August specifically, not generally to all
   months for sub-40 MSEK companies. Other months are the 12th
   regardless of employer size. Fixed to: "the 12th of the following
   month for every reporting period EXCEPT January and August, where
   companies with annual turnover ≤ 40 MSEK get the 17th." This
   would've caused integrators automating sub-40 MSEK deadline
   tracking to misfile by 5 days from February through July and
   September through December.

3. **F-skatt SE-R-005 broader scope** (swedish-invoice-compliance /
   swedish-e-invoicing). The previous wording framed SE-R-005 as
   primarily a Peppol B2G validation rule. Reframed: the F-skatt
   note is a legal requirement on every faktura issued by a Swedish
   momsregistrerad seller that holds F-skatt registration — applies
   to PDF/paper AND Peppol/e-invoice formats. The buyer uses it to
   determine A-skatt withholding obligation (omitting it can shift
   tax liability onto the buyer); B2G is just where the validation
   is automated as a FATAL Peppol BIS 3.0 check.

4. **SIE behandlingshistorik gap: full räkenskapsår scope**
   (swedish-accounting-compliance). Round 4's wording said the
   integrator "must either preserve [behandlingshistorik] separately
   or accept the gap with documented justification" and that gnubok
   "starts a fresh behandlingshistorik from the import date forward."
   The "documented justification" framing implied the gap was
   acceptable as a default. Per BFNAR 2013:2 kap 8 §, the obligation
   attaches to the entire räkenskapsår, not from the import date.
   Reframed as: "must be preserved separately... best practice for a
   mid-year migration: export the source system's behandlingshistorik
   for the full fiscal year and archive it alongside the SIE file."

DEFERS (round 5 final — these are the architectural-floor items
that will recur indefinitely):

- **🟡 A.8.20 DNS-rebinding gap** (Compliance Swarm). Already
  documented in the changelog as a Phase 6 PR-3 deferral item; the
  bot is reading the same text we wrote.
- **swedish-compliance: VAT 2026-04-01 livsmedel rate change**.
  Forward-looking — for the actual VAT cookbook recipe content,
  which ships post-Phase-6.
- **swedish-compliance: year-end IB/UB continuity**. Forward-looking
  — same.
- **swedish-compliance: SIE warning placement note**. Forward-looking
  — for the imports reference page when authored.
- **swedish-compliance: BFNAR 2013:2 citation correct, webhook
  retention correct**. No-op confirmations.
- **swedish-compliance: delivery_date pre-payment scenario**. Real
  but extremely narrow edge case (faktura utfärdad före leverans).
  Defer with the understanding that anyone using the API for
  pre-payment invoicing will read the full invoice reference, not
  rely solely on the quickstart.

This is the merge-ready signal per Phase 4 lessons-learned: every
remaining swarm finding is in the deferred or recurring bucket;
swedish-compliance is in pure-advisory mode (forward-looking notes
for cookbook content that ships later); CI is fully green; Greptile
posted nothing past round 1's two items (both fixed). Ship it.

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 15:57:28 +02:00
Jakob Wennberg 32ad6da28c feat(api): v1 invoice + customer reads (Phase 2 PR-A) (#451)
* feat(api): v1 invoice + customer read endpoints (Phase 2 PR-A)

First slice of the Phase 2 invoices vertical. Read-only endpoints landing
in this PR; writes + webhooks land in PR-B and PR-C. After all three a
developer can ship an end-to-end invoicing integration.

New endpoints (all wrapped, scoped, cursor-paginated):
- GET /api/v1/companies/:companyId/invoices       — list, filters: status, customer_id, document_type, currency. Cursor on (invoice_date DESC, id DESC). Customer name embedded inline; ?expand=customer for full record, ?expand=items for line items.
- GET /api/v1/companies/:companyId/invoices/:id   — detail with embedded customer. ?expand=items,payments.
- GET /api/v1/companies/:companyId/customers      — list, filters: customer_type, search (name/org_number prefix), include_archived. Cursor on (created_at ASC, id ASC).
- GET /api/v1/companies/:companyId/customers/:id  — detail. ?expand=invoices embeds open invoices in a single round-trip.

Shared infra:
- lib/api/v1/expand.ts — parseExpand() validates ?expand=a,b,c against a per-endpoint allowlist; unknown keys yield VALIDATION_ERROR with the full invalid list and the allowlist (agent-friendly).
- All four routes register with the Zod schema registry so they show up in /api/v1/openapi.json with x-action-risk and use-when / do-not-use-for metadata.
- Compound keyset filter on both list endpoints (per Greptile review on PR #450) — no skipped or duplicated rows on page boundaries.

Tests:
- lib/api/v1/__tests__/expand.test.ts (8 tests)
- app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts (10 tests)
- app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts (8 tests)

Full repo suite green (3127/3127), build clean, lint clean on v1 paths.

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

* fix(api): address PR #451 review (Greptile + compliance swarm)

- Greptile P1 (customers search) + OWASP V1.2.5: customer search term now
  escapes both PostgREST .or() delimiters (,()) AND SQL LIKE wildcards
  (% _ \). '100%' searches for the literal string instead of any
  customer containing '100'.
- OWASP V8.2.1 + V16.1: detail endpoints now UUID-validate the :id path
  param before touching the database, and no longer echo the raw id in
  the NOT_FOUND response details. Adds a structured warn log on 404 with
  the queried (id, companyId) for audit purposes.
- V4.5 + Art.25(1) + A.8.3 / A.8.11 + CC6.3 + PI1.3 (~12 findings): every
  select('*') replaced with explicit column lists per the documented Zod
  schemas. Includes joined sub-queries — customer:customers(...),
  items:invoice_items(...), payments:invoice_payments(...). Future
  schema migrations adding sensitive columns must now update these
  projections before the field becomes visible on the public API.
- A.8.5: hardcoded 'Bearer gnubok_sk_x' in test fixtures replaced with
  'Bearer test-fixture-not-a-real-key' to avoid false-positive secret
  scanner alerts. Fixture UUIDs upgraded to valid v4 format (Zod 4's
  .uuid() enforces version+variant digits).
- Art.5(1)(f): customer-invoices expansion soft-degrade now logs only
  the error code + message rather than the full Supabase error object.

Pushing back on:
- Art.5(1)(b) org_number in customer list — Bolagsverket-public data
  (same triage as PR #450; required by integration use case)
- Art.25(2) customer_name always-joined — denormalising via trigger is
  a real schema migration for a marginal data-flow gain
- A.8.15 _partial flag on soft-degrade — ?expand is documented as a hint

50/50 v1 tests; 3131/3131 full suite; build clean.

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

* fix(api): second-pass review on PR #451 — partial_expansions + fake fixtures

Address the residual compliance-swarm findings after the first fix round:

- CC6.1 (medium): the customer-detail handler now sets
  meta.partial_expansions=['invoices'] when the ?expand=invoices subquery
  fails, signalling the degraded response to the caller without escalating
  to error-level logs (alert fatigue). The primary resource still returns
  with an empty invoices array. New ResponseOptions.partialExpansions
  threaded through buildMeta(). 1 new test for the failure path, plus
  a happy-path assertion that the flag is absent.
- A.8.33 (low): SAMPLE_CUSTOMER fixture's org_number and vat_number
  replaced with 'TEST-0000-0001' / 'SETEST00000001' — cannot be confused
  with real Bolagsverket entries or pass external VIES validation.

Pushing back on:
- CC6.3 (medium) — separate scope for ?expand=items on invoices: every
  accounting API I know (Stripe, QuickBooks, Fortnox) treats line items
  as part of the invoice resource. Splitting would violate principle of
  least surprise for integrators; the plan deliberately treats
  invoices:read as covering the full invoice including items.

3132/3132 vitest pass; build clean.

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

* fix(api): third-pass review on PR #451 — PII minimisation refinements

Third compliance-swarm sweep (6 → 0 highs, 4 medium, 2 low). Addressed:

- Art.5(1)(c) personnummer leakage: customer LIST response now masks
  org_number AND vat_number for customer_type IN ('individual',
  'eu_individual') — for sole traders (enskild firma) org_number IS the
  personnummer. Business customers' Bolagsverket-public org_numbers stay
  visible. Detail endpoint (deliberate single-record fetch) unchanged.
- Art.5(1)(c) over-broad invoice-list expansion: ?expand=customer on
  the invoice LIST endpoint now uses a new CUSTOMER_LIST_CONTEXT_COLUMNS
  projection (id, name, customer_type, email, country, archived_at) —
  full address/phone/notes/vat_number stay on the customer DETAIL
  endpoint. Drops PII transmitted in bulk-list contexts by ~60%.
- A.8.15 permission-error differentiation: customer-detail soft-degrade
  for ?expand=invoices now bumps Postgres error class 42 (insufficient
  privilege, RLS denial) to error-level log so Sentry alerts on
  misconfigurations. Transient errors stay at warn.
- PI1.1 ISO-4217 currency: invoice list ?currency now requires
  /^[A-Z]{3}$/ instead of accepting any 3-8 char string. Two new tests.

Pushing back on:
- Art.5(1)(f) UUID logging on 404 — UUIDs have 122 bits of entropy; you
  cannot enumerate the space, so the "log scraping = enumeration" framing
  doesn't hold. Operational audit value > theoretical risk.
- Art.25(1) notes-by-default in customer DETAIL — kept inline. Detail
  is a deliberate single-record fetch; the dashboard shows notes inline;
  agents calling /customers/{id} reasonably expect them. Notes are
  already excluded from the LIST endpoint AND from the invoice-list
  ?expand=customer projection (above).

3135/3135 vitest pass; build clean.

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-12 21:49:22 +02:00
Jakob Wennberg db592d922d feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints (#450)
* feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints

Lay the substrate for the public REST API at /api/v1/*: Bearer-auth wrapper
that reuses the existing api_keys + idempotency machinery, an extended scope
catalogue (companies, events, webhooks, operations, documents, compliance),
v1 response envelopes (data + meta with request_id, api_version, audit block,
cursor pagination), an error envelope with recovery_hint / docs_url /
valid_alternatives derived from the existing structured-error registry, and
a Zod schema registry that generates the OpenAPI 3.1 spec with x-action-risk
/ x-idempotent / x-reversible / x-dry-run-supported extensions.

Ships discovery routes (/llms.txt, /.well-known/skills/index.json) and three
smoke endpoints (GET /api/v1/health, /api/v1/companies, /api/v1/openapi.json)
so the wrapper is exercised end-to-end. Includes the api_keys.mode (test|live)
migration and 41 unit tests covering auth, scope, company-membership,
idempotency replay, dry-run, pagination, response shape, and scope resolution.

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

* fix(api): harden v1 foundation — cursor validation, security headers, forensic logs

Address compliance-swarm findings on PR #450:

- OWASP V2.3: decodeDefaultCursor now validates the cursor's ts as ISO 8601
  and id as UUID. A crafted cursor previously could inject untyped strings
  into a query's .gt(field, value); PostgREST would have rejected them, but
  validating here keeps the failure mode predictable (stale cursor → reset)
  rather than 400-ing.
- OWASP V3.4: public discovery routes (llms.txt, .well-known/skills,
  openapi.json) now stamp X-Content-Type-Options: nosniff, Referrer-Policy,
  X-Frame-Options: DENY. New lib/api/v1/security-headers.ts helper.
- OWASP V16: security event logs (missing token, validation failure,
  insufficient scope, company-membership deny) now include source IP
  (x-forwarded-for / x-real-ip) and User-Agent for forensic correlation.
- OWASP V8.2.1 / ISO A.8.3: GET /api/v1/companies emits a warn log when the
  PostgREST archived_at filter unexpectedly returns a row with a null
  company join, surfacing silent data-integrity regressions instead of
  hiding them behind the existing pickCompany() === null filter.

Pushing back on (not changed):
- GDPR Art.32 cursor HMAC signing — cursors only paginate within a user's
  own user_id scope; cross-tenant probe surface doesn't exist yet.
- GDPR Art.25 org_number in list — Bolagsverket public-record data, removing
  forces N+1 fetches to make the response useful.
- SOC 2 CC6.3 service-role bypasses RLS — defense-in-depth IS the design;
  the wrapper's company_members membership check is the technical control.
- ISO A.8.12 public OpenAPI spec — intentional, mirrors Stripe/Twilio.

5 new pagination tests cover the cursor validators. 46/46 v1 tests pass.

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

* fix(auth): detect Supabase duplicate-signup obfuscation on register

Supabase obfuscates duplicate signups to prevent user enumeration: when an
email already belongs to a confirmed account, signUp returns data.user with
identities: [] and no error, and sends no email. Without detecting this case
we showed the "check your email" screen to the user, who then waited for a
mail that never arrived.

Detect the empty-identities response and surface it via duplicateEmail state
so the UI can branch on it.

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

* fix(api): second-pass hardening — CSP, IP truncation, cursor scope comment

Address the second compliance-swarm sweep on PR #450:

- OWASP V3.2: PUBLIC_SECURITY_HEADERS now includes Content-Security-Policy
  default-src 'none'; frame-ancestors 'none'. Free win for JSON/text-only
  public routes (no script, style, image, or form contexts).
- GDPR Art.5(1)(f): truncate IPs before logging — IPv4 to /24, IPv6 to /48.
  Preserves diagnostic value (ASN, abuse-pattern correlation, city-level
  geolocation) while eliminating point-of-presence identification. Standard
  pattern used by Google Analytics anonymize_ip. Exported truncateIp() so
  other surfaces can adopt it.
- OWASP V8.2.1: explicit comment in GET /api/v1/companies documenting that
  the cursor's joined_at is applied AFTER user_id filter, so a tampered
  cursor can only reorder rows the caller already owns. Cursors deliberately
  unsigned; trade-off documented.

Pushing back on second-pass findings (not changed):
- ISO A.8.12 / SOC 2 CC6.3 health/llms.txt/skills exposing service name +
  API version + MCP URL — these are intentional disclosures for a public
  3rd-party developer API; hiding them is theatre.
- GDPR Art.32 logging granted scopes on INSUFFICIENT_SCOPE — diagnostic
  value during incident response outweighs the theoretical privilege-profile
  leak; an attacker who already breached the log store has bigger problems.
- OWASP V2.2 route-level Zod for cursor — decodeDefaultCursor already
  validates strictly; route-level Zod is stylistic.
- GDPR Art.25(2) org_number/entity_type in list — Bolagsverket-public data;
  entity_type materially affects which API calls make sense.
- ISO A.8.15 x-forwarded-for trusted-proxy CIDR — overkill behind Vercel's
  edge which rewrites the leftmost value.

50/50 v1 tests pass (4 new for truncateIp). Build green.

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

* fix(api): third-pass hardening — Host header injection, anon client, HSTS

Address the third compliance-swarm sweep on PR #450:

- SOC 2 CC6.1 (3× high): llms.txt, openapi.json, and .well-known/skills
  built URLs from the inbound Host header. A spoofed Host could poison
  agent discovery with attacker-controlled endpoints. New
  lib/api/v1/base-url.ts centralises canonical base-URL derivation via
  NEXT_PUBLIC_APP_URL (already a required env var per CLAUDE.md).
- ISO A.8.2 / A.8.5 (2× high): the wrapper's public-scope code path now
  uses an anon-key Supabase client (RLS-respecting) instead of the
  service-role client. A future accidental DB call from a public handler
  is constrained to anon-accessible rows. Least-privilege at the
  infrastructure layer.
- OWASP V3.2 (medium): PUBLIC_SECURITY_HEADERS now includes
  Strict-Transport-Security: max-age=31536000; includeSubDomains.
- GDPR Art.5(1)(f) (medium): truncateIp now logs a warn when a non-empty
  x-forwarded-for / x-real-ip payload fails to parse, surfacing spoofed
  or unexpected proxy values to security monitoring instead of silently
  dropping them. The raw value is never logged.
- CC2.3 (low): llms.txt now links the SECURITY.md disclosure policy with
  the security@arcim.io reporting address so agents have a clear
  responsible-disclosure path.

Pushing back on third-pass findings (not changed):
- Cursor HMAC signing — user_id filter is the authorisation boundary;
  cursor scope is bounded to within-user rows. Documented in code.
- org_number in companies list — Bolagsverket public data; the swarm's
  "could be enskild firma personnummer" framing isn't accurate (enskild
  firma org_number IS the personnummer, but it's already in the public
  Bolagsverket business register).
- Health endpoint information disclosure — intentional for a public
  developer API; matches Stripe/Twilio convention.
- llms.txt / skills index MCP URL disclosure — that's the file's purpose.
- Cache-Control public on discovery routes — content is by definition
  public; getCanonicalBaseUrl() removes the previous spoof concern.
- Duplicate-email screen — user's own input; out of scope for this PR.

50/50 v1 tests pass; @supabase/supabase-js#createClient mocked so the
public-path tests don't need real env vars.

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

* fix(test): widen validateApiKey result assertions to include mode field

The core-only CI job failed on two pre-existing api-keys.test.ts assertions
that used strict toEqual matching against the old (userId, companyId, scopes)
shape. The wrapper migration in this PR widened that shape with mode,
apiKeyId, and apiKeyName.

Update both existing assertions to match the current shape and add a third
test that exercises the mode='test' path. 3027/3027 vitest tests now pass
locally.

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

* fix(api): fourth-pass hardening — env guards, IP range check, headers on wrapped routes

Address the fourth compliance-swarm sweep on PR #450:

- ISO A.5.17 / SOC 2 CC6.1 (high): createAnonClient now fails closed with
  an explicit Error if NEXT_PUBLIC_SUPABASE_URL or _ANON_KEY are missing,
  surfacing misconfiguration on the first request instead of throwing
  deeper in the handler with no context.
- GDPR Art.5(1)(f): truncateIp now rejects IPv4 with out-of-range octets
  (>255). '999.999.999.999' now returns undefined instead of a pseudo-IP
  that would pollute abuse-pattern analysis. Edge octets (0, 255) still
  accepted. 2 new tests.
- OWASP V3.2 / V3.3: the wrapper's stampHeaders step now applies the full
  security header set to every wrapped v1 response (CSP, HSTS, X-Frame,
  Referrer-Policy, X-Content-Type-Options) PLUS X-Robots-Tag: noai,
  noimageai so authenticated payloads are excluded from AI training sets.
  Public discovery routes (llms.txt, skills index, openapi.json)
  deliberately omit X-Robots-Tag — being AI-discoverable is the whole
  point of those surfaces.
- New WRAPPED_RESPONSE_HEADERS export separates the two contexts.

Pushing back on:
- SOC 2 CC6.1 medium "API key prefix in public docs aids brute force" —
  inverted logic. Every public API publishes its key prefix specifically
  so secret scanners (GitHub Advanced Security, GitLeaks) can detect
  leaks. Stripe (sk_live_), GitHub (ghp_), OpenAI (sk-) all do this.
- SOC 2 CC6.3 medium "formal risk register for unsigned cursors" — org
  -level documentation, outside this PR. Code-comment already documents
  the trade-off.
- SOC 2 CC2.3 low "llms.txt hardcodes security@arcim.io" — same address
  as SECURITY.md; no drift risk.

Flagged separately (not changed): the register-page duplicate-email
detection in this branch defeats Supabase's user-enumeration obfuscation
(GDPR Art.5(1)(c) × 2, ISO A.8.11). Substantive product decision: UX (no
infinite-wait for non-existent accounts) vs security (no enumeration).
GitHub and Stripe Atlas pick UX; some pick security. Owner's call.

3029/3029 vitest tests pass.

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

* fix(api): address Greptile review on PR #450

- P1 (companies/route.ts): keyset pagination was missing its tiebreaker.
  The cursor encoded (joined_at, id) but the filter only applied
  .gt('joined_at', ts) — same-joined_at rows on a page boundary could be
  skipped or duplicated. Also the encoded id was companies.id while the
  sort was on company_members, mismatched. Fixed: select + sort + encode
  on company_members.id, apply compound
  joined_at.gt.{ts} OR (joined_at.eq.{ts} AND id.gt.{cursor_id}) via .or().
  Side benefit — eliminates the broken-cursor-on-null-join case (#2)
  because company_members.id is always present, no null guard needed.
- P2 (registry.ts): ZodUnion branch had a dead ternary
  (['x','y','z','w'].length > 0 ? undefined : 'object') that always
  yielded undefined. Removed; emit { oneOf: [...] } without top-level
  type (correct JSON Schema for a union).
- P2 (with-api-v1.ts): public-endpoint path was short-circuiting before
  Bearer-token validation, contradicting the JSDoc and PR description.
  Now opportunistically validates a supplied token for rate-limit
  attribution + key tracking; missing/invalid token silently falls back
  to anon (the route is public by definition, so we don't 401). Two
  new tests cover both branches.

3031/3031 vitest tests pass; build green.

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-12 20:47:13 +02:00