Files
accounted/extensions/general/mcp-server/server.ts
T
f53725b20a Agent v1 bundle: TIC v2 onboarding, in-app assistant gating, sidebar nav, MCP fixes (#584)
* fix(sie-import): accept tab as field separator (Bollbok exports)

The SIE 4 spec allows either space or tab between fields, but
splitSIELine() only treated space (0x20) as a separator. Bollbok
exports tab-separated lines for every record except #RAR, which
silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS
records — imports appeared empty even though the file was well-formed.

Also adds a parser-side diagnostic that emits a warning when raw #IB
or #VER lines are present in the input but parsing produced none. The
previous silent failure is how this bug stayed hidden; the warning
gives the import preview something visible to surface next time.

Verified against two real reproducer files (Sean / Erik Hellqvist):
  erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS.
  erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers.
Both now parse with zero warnings/errors.

Tests:
  + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants).
  + 4 silent-failure diagnostic-warning tests.
  All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers.

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

* fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning

Two non-blocking P2 findings from Greptile review on PR #513:

1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports
   (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'.
   Latent defect — accountType is unused downstream today, but my tab-
   separator fix made the quoted-value path reachable. Now routes through
   parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T")
   land as 'T'.

2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning
   fired alongside per-record 'error'-severity issues for malformed #IB /
   #VER records, producing a misleading hint when the parser had already
   pinpointed the structural problem. Now suppressed when an error-severity
   issue with the same tag already exists.

Test coverage:
  + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes.
  + VER aggregate-warning test now uses #VER lines without { } blocks
    (silent loss, no per-record error) — the canonical case the diagnostic
    is designed for.
  + New suppression test: bare #VER produces per-record errors AND the
    aggregate warning is absent.

75/75 sie-parser tests pass; 156/156 in lib/import.

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

* wip: agent chat + composer + memory + document extraction

In-progress work on this branch beyond the SIE-import fixes:
- Specialized accountant agent (composer + intents + chat loop)
- Persistent agent_conversations/messages, agent_profiles, agent_memory
- /chat surface + /onboarding/agent + /settings/agent-memory
- document-extraction extension with status hooks
- MCP server staging refactor + new skills (atoms, bank reconciliation,
  customer onboarding, kreditfaktura)
- pending_operations rejection feedback (category + reason) + realtime
- TIC company profile cached snapshot on companies
- 17 migrations (all additive — see prior conversation analysis)

Parked while branch waits for review/merge. Migrations are already
applied to prod.

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

* refactor(tic): migrate company-data client from api-core v1 to Lens v2

Swaps the seven TIC company-data endpoints we call from the api-core
paths (`/datasets/companies/{companyId}/...`, `/search/companies`) to
the Lens equivalents (`/companies/{id}/...`, `/search-public/companies`).
Hard cutover; proxy pattern preserved.

Schema shifts handled inside the extension so consumers (TicWorkspace,
Step2CompanyDetails) don't need changes:

- `/companies/{id}/bank-accounts` now returns Bankgirot only — map to
  the existing `{ type, accountNumber, bic }` shape, drop terminated.
- `/companies/{id}/industries` returns a discriminated array — filter
  to `companyIndustryCodeType === 'sni2007'` to preserve v1 behavior.
- `/companies/{id}/phone-numbers` renamed the field to
  `phoneNumberFormatted` (fall back to `e164PhoneNumber`).
- `/companies/{id}/documents` replaces `/financial-report-summaries`;
  filter `type === 'annualReport'` and read nested
  `financialReportMetadata` to rebuild the legacy summary shape.
- `isCeased` is now a top-level boolean; `activityStatus` is an enum.
  Translate enum -> 'ceased' for the workspace's existing check.

BankID identity flow (id.tic.io) is untouched — separate TIC product.

Note: deploy gated on the TIC proxy being flipped to lens-api.tic.io
with an `x-api-key` Lens key.

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

* feat(tic): expose v2 onboarding & workspace data

Adds six new Lens (v2) fetchers on top of the migration that already
landed in this branch, surfacing the data through /lookup and /profile.

New fetchers in lib/tic-client.ts:
- getFiscalYears          /companies/{id}/fiscal-years
- getAccountingPeriods    /companies/{id}/accounting-periods
- getPayrolls             /companies/{id}/payrolls
- getSignatory            /companies/{id}/signatory
- getRepresentatives      /companies/{id}/representatives
- getCompanyStatus        /companies/{id}/status

/lookup gains a fiscalYear field (current fiscal-year configuration)
so onboarding Step 2 can skip manual MM-DD entry. CompanyLookupResult
extended with optional fiscalYear; consumers without it keep working.

/profile gains five new sections on TICCompanyProfile:
- fiscalYear + fiscalYearHistory   current + deduped period list
- signatory                        firmateckning descriptions
- board + representatives          board-composition summary + active
                                   officers (positionEnd in future)
- payrolls                         payroll2 array newest-first, with
                                   deviation vs annual-report
- statuses                         current+historical status entries
                                   with red/yellow/green/neutral color

TicWorkspace renders the new data as four cards (Status, Fiscal year +
Signatory, Board + Representatives, Payroll history) plus a Badge
mapping for the traffic-light status color.

Tests: 52 -> 60 passing. Added unit tests for the new fetchers' v2
paths, fiscal-year auto-fill in /lookup, and full v2 profile coverage.

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

* feat(onboarding,agent): lean on TIC v2 to skip Steps 1 & 3 and sharpen Opus

Three small wins that unlock more of the v2 cutover. No new endpoints — the
data was already in the snapshot, just not flowing where it should.

Step 1 (entity_type) — deep-link path only:
- /lookup now returns `legalEntityType` and `registrationDate` (added to
  CompanyLookupResult).
- /onboarding/page.tsx does a server-side /lookup prefetch when
  ?org_number= is present (BankID picker path), maps "AB"/"EF" to the
  EntityType enum, and seeds Step 1's radio. Falls through silently for
  unsupported codes (HB, KB, …) and on TIC errors.
- WelcomeOnboarding hydrates ticLookup state from the server prefetch so
  Step 2's debounced client fetch and Step 3's first-year inference both
  have data on first render — no flash.

Step 3 (is_first_fiscal_year) — every path:
- deriveFirstYearDefaults() parses ticLookup.registrationDate and returns
  { isFirstFiscalYear, firstYearStart } when registered <12 months ago.
  Step 3's initialData picks it up; the user only confirms the end date.
- Settings value wins when present so existing users with a saved choice
  don't get overridden.

Composer prompt:
- redactTic allowlist was the bottleneck — it stripped beneficialOwners,
  signatory, board, representatives, payrolls, statuses, fiscalYear
  before Opus ever saw the JSON. Existing filterRedundantQuestions
  ownership logic was effectively dead because the data path was severed.
  Expanded allowlist to include those v2 sections; kept bankAccounts/
  email/phone/fiscalYearHistory/financialReports out (token cost > signal).
- SYSTEM_PROMPT now documents each v2 section and the rules Opus should
  apply: payroll signal switches from "registration.payroll" to "actual
  payrolls[] filings" (kills the false-positive swedish-payroll selection
  for newly registered employers); beneficialOwners[] becomes the
  authoritative ownership source (single owner → FMB modifier; multiple →
  multi-owner); statuses[] isCeased/red triggers an uncertainty_note.

Tests: 4112 unchanged. Build: green. No schema or migration changes.

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

* fix(agent): onboarding polish + composer signal fixes from first-run feedback

UX:
- AgentOnboarding: drop the 10s "Hoppa över — fortsätt med standardval"
  escape hatch. The fallback path runs automatically on timeout; the
  manual skip just teased users into a degraded build.
- ReviewCard step 2 title: "Stämma av detaljerna" → "Stäm av detaljerna"
  (imperative form matches the rest of the steps).
- Drop em-dashes from user-visible Swedish strings in AgentOnboarding +
  ReviewCard (fallback labels, subtitles, placeholder, error message,
  final CTA). Em-dashes survive in code comments only.
- "Fråga min revisor" → "Fråga min assistent" everywhere it surfaced:
  AgentTrigger, AgentSparkleButton, ReviewCard preview, ReviewCard
  fallback comment, general.help intent buttonLabel + prompt text.
- AgentTrigger / AgentSparkleButton / EmptyState.AgentHelpLink /
  TransactionInboxCard ask-button all gated on identity.isVerified.
  Pre-onboarding users no longer see the floating FAB or per-page
  Sparkle buttons. AgentSheetProvider.identity gained an isVerified
  field; (dashboard)/layout.tsx selects agent_profiles.verified_at and
  passes it through.

TIC verksamhetsbeskrivning:
- tic/index.ts /profile: /companies/{id}/purposes returns every
  historical verksamhetsföremål filing. Picking [0] was returning the
  oldest "äga och förvalta" holding-company boilerplate for companies
  whose later filings narrowed the purpose ("tillhandahålla
  företagskrediter och finansiella teknologilösningar"). Sort the
  array by lastUpdatedAtUtc desc and take the most recent non-empty
  purpose.

Composer banking signal:
- loadBankingSummary now reads journal_entry_id alongside
  description/amount/date and returns per-counterparty `direction`
  ('in' | 'out' | 'mixed') and `has_unbooked` (any row not yet booked).
  Aggregate `unbooked_count` accompanies the rollup.
- buildUserPrompt emits each counterparty as
  `Name: 12 345 kr (ut, OBOKFÖRD)` so Opus can tell income from cost
  on sight and tell which counterparties are still open questions.
- SYSTEM_PROMPT now explicitly forbids verification questions about
  counterparties whose direction is unambiguous AND status is 'bokförd'.
  Should kill the regressions from the first agent build:
  * "Konsult, J 98 565 kr — intäkt eller kostnad?" when the amount is
    clearly negative.
  * "ALMI AB 493 000 kr — lån eller bidrag?" when the transaction is
    already categorized.

Tests: 4112 unchanged. Build: green.

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

* fix(agent,ui): representation needs deltagare+syfte, drop duplicate doc icon

Representation booking:
- transaction-categorization prompt now requires the agent to capture
  participants (name + company) AND purpose before staging a
  representation categorization. SKV's representationsregler + ML 8 kap
  require the verifikation to document who attended and what the
  meeting was about; without that the avdrag is denied and the post
  should be booked as non-deductible / personalkostnad.
- The agent confirms back in plain text (audit trail in the chat),
  writes the deltagare + syfte to gnubok_remember_fact (long-term),
  THEN stages. Saknas deltagare/syfte: explicitly tell the user the
  avdrag won't go through and offer the non-deductible alternative.
- Known gap (followup, not this commit): the staged op's journal entry
  description doesn't yet carry the deltagare text. Until we add a
  `notes` field to gnubok_categorize_transaction, the audit trail
  lives in chat + agent_memory only.

TransactionInboxCard duplicate attachment indicator:
- Drop the FileCheck2 "open document" button from the trailing slot.
  TransactionAttachmentIndicator (Paperclip) next to the description
  already opens the underlag on click. Two icons doing the same thing
  was noise. Cleaned up the unused state (isOpeningDoc, hasAttachment,
  handleOpenAttachment) and dropped now-unused imports (FileCheck2,
  useToast).

Tests: 4112. Build: green.

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

* feat(agent,nav): notes on verifikation + redesigned sidebar

Audit-trail notes for representation:
- gnubok_categorize_transaction gains an optional `notes` string.
  Threaded through stagePendingOperation → commitCategorizeTransaction →
  createTransactionJournalEntry, which now appends notes to the entry's
  description (capped at 500 chars). The verifikation an external auditor
  reads now carries deltagare + syfte directly — not just chat history /
  agent_memory.
- transaction-categorization prompt updated: representation flow now
  REQUIRES the agent to pass deltagare+syfte via the notes parameter.
  Without it the booking is non-deductible / personalkostnad per SKV.

DashboardNav redesign:
- Top section: flat, no header — Hem (/chat), Underlag (was
  Dokumentinkorg), Transaktioner, Granskning. Always visible; the inline
  badge on /pending shows the count when there are pending ops.
- Mid section: four collapsible dropdowns (Försäljning, Inköp,
  Redovisning, Personal). Each auto-expands when the active route lives
  inside it. KPI moved from main to Redovisning. Extension nav items
  (TIC workspace, etc.) fold into Redovisning.
- Bottom-left: new account popover (DropdownMenu, opens upward) holding
  CompanySwitcher, Inställningar, Hjälp, Support, Logga ut. Replaces
  the old top company-switcher card + the bottom Support/Logout block.
- Mobile drawer mirrors the new structure: top items as flat list,
  same four dropdown groups, separate "Tillägg" section when
  extensions exist, "Mitt konto" section at the bottom.
- i18n: invoice_inbox label renamed "Dokumentinkorg" → "Underlag"
  ("Documents" in en). New keys: mitt_konto, group_extensions.

Tests: 4112. Build: green.

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

* fix(nav): unhide Leverantörer under Inköp

The /suppliers entry existed in navItems but was marked hidden — leftover
from when the supplier list lived elsewhere in the IA. Removing the
hidden flag puts Leverantörer in the Inköp dropdown alongside
Leverantörsfakturor.

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

* fix(nav): CompanySwitcher back to top-left, user account moves bottom-left

The previous pass collapsed both concepts into the bottom popover. They
mean different things: the company is the org context everything below
operates against (top-of-sidebar, scannable); the user is the
account-holder (bottom-of-sidebar, where settings/logout live).

- (dashboard)/layout.tsx: fetch profiles.full_name alongside the
  existing identity queries; pass userName + userEmail into
  DashboardNav.
- DashboardNav: restore CompanySwitcher at the top of the sidebar
  (pre-redesign placement). Bottom-left popover trigger now shows the
  signed-in user's name + single-letter initial (accountInitial helper
  falls back to email's first char, then "?"). Popover header carries
  full name + email; items unchanged (Inställningar, Hjälp, Support,
  Logga ut). CompanySwitcher removed from inside the popover — nested
  dropdowns were awkward and the top placement is where it belongs.

Tests: 4112. Build: green.

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

* fix(pending): trim the agent context strip

The row-level AgentContextStrip on /pending was rendering the model
name (eu.anthropic.claude-sonnet-4-6) and the full atoms array
(horizontal/swedish-vat, vertical/konsult-it, …) inline, which made
each row 60–80 chars of mostly-the-same metadata. Reviewers never
scan that text; they scan amounts and decide approve/reject.

Now the strip shows only the conversation deep-link
(Konversation #<short id>) — the one piece that's actually useful for
diving into context. Model + atoms remain available in agent_metadata
for debugging surfaces; they're just not in the list view.

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

* fix(agent): shared ground rules + paragraph breaks after tool calls

Two regressions surfaced in real usage. Both are systemic.

Shared agent ground rules:
- /chat surface (general.help) was happily inventing four-digit BAS
  account numbers ("Debet 6212 - Molntjänster…", "Kredit 2614 - Ingående
  moms…") and proposing booking decisions on invoices it had never
  seen, with no follow-up questions about currency/scope/etc.
- transaction-categorization had those rules baked into its prompt;
  general-help / bokslut-step / invoice-draft / supplier-invoice-review
  / verifikation-draft / vat-review never inherited them.
- Extracted lib/agent/intents/shared-rules.ts with five cross-cutting
  rules: underlag first (check inbox + ask user to upload to
  Dokumentinkorgen when missing), ask follow-ups when ambiguous, never
  write four-digit BAS account numbers in chat (category names only),
  cite atoms / load skills (don't guess), check counterparty history
  before proposing.
- Injected renderAgentGroundRules() into all six intents above.
  transaction-categorization left alone — it has more detailed inline
  rules tied to its specific underlag-flow.

Paragraph break after tool calls:
- text_delta from the model often resumes after a tool call without a
  leading newline ("kategoriseras." → gnubok_query_journal runs → "Inget
  historik hittades…" appended directly). Markdown rendered the
  concatenation as one paragraph.
- AgentChat text_delta handler now inserts \n\n when (a) the buffer
  ends with text content, (b) the incoming delta starts with text
  content, (c) at least one tool call has run, and (d) the buffer
  doesn't already end with a blank line.

Tests: 4112. Build: green.

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

* fix(nav): default-open dropdown groups; closing is per-user

Dropdowns started collapsed which meant first-time users had to open
each group to discover what's inside. Inverted the state: default open,
user can collapse, active route still forces a group open.

- manualExpanded → manualCollapsed (semantics flip)
- toggleGroup unchanged externally; flips the bit
- isGroupExpanded returns !manualCollapsed[g] || hasActiveChild

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

* feat(agent): rate-safe v1→v2 TIC upgrade, counterparty defaults, profile settings

Three pre-ship quality wins.

Rate-limit-safe TIC v2 upgrade:
- The /profile endpoint fans out to ~13 Lens calls; the account has a
  ~3000/mo ceiling. Force-refreshing every pre-v2 (v1) snapshot across
  the customer base would blow the budget.
- ensureTicSnapshot gains an `upgradeV1` flag. A cached snapshot still
  inside the 7-day window is re-fetched only when (a) the caller passes
  upgradeV1 AND (b) the snapshot is v1-shaped (missing the v2-only
  `statuses` key). Gated to the two agent-onboarding call sites — a
  deliberate, once-per-company action and the only consumer of the v2
  sections. Workspace + signup keep the natural 7-day staleness, so the
  v1→v2 migration is lazy and bounded to companies actually building an
  agent.

Known-counterparty defaults (shared-rules):
- Agent now proposes a sensible default for well-known counterparties
  instead of asking the same question monthly: Almi → lån, Tillväxtverket/
  Vinnova/EU-stöd → bidrag, Skatteverket → skatt/avgift or återbäring,
  Bolagsverket → avgift, Försäkringskassan → ersättning, EF private
  withdrawal → eget uttag. Stated as an assumption the user can correct,
  not a hard rule — underlag/history still wins.

Företagsprofil settings page:
- New /settings/agent-profile (Företagsprofil / "Company profile"):
  view + edit the agent's company profile after onboarding — assistant
  name + avatar, the profile summary the agent reasons from, and a
  read-only chip view of loaded specialities (atoms). Backed by the
  existing GET/PATCH /api/agent/profile.
- New GET /api/agent/atom-titles?ids= resolves atom slugs → human titles
  for the chips (registry is globally-readable reference data).
- Added to SettingsSidebar; i18n keys agent_profile (sv "Företagsprofil"
  / en "Company profile").

Note: /chat already redirects unverified users to / (chat layout guard),
and / renders WelcomeGate → /onboarding/agent. No redirect work needed.
AgentSetupBanner.tsx is orphaned dead code (WelcomeGate superseded it).

Tests: 4112. Build: green. Both new routes compile.

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

* feat(nav,agent): Hem=Översikt + separate Assistent button; memory dedup

Nav restructure:
- "Hem" now points to / (Översikt dashboard) again, not /chat. The agent
  chat gets its own top-level nav entry "Assistent" (Sparkles icon) → /chat.
  Mobile bottom nav mirrors this (Hem / Assistent / Transaktioner).
- / restored to render DashboardContent (the Översikt) for built-agent
  users instead of redirecting to /chat. Users who haven't built their
  assistant yet still get WelcomeGate (the build-agent checklist); once
  verified, / shows the dashboard. Chat is reachable anytime via its nav
  entry. Restored main's dashboard data-fetch; added an agent_profiles
  verified_at probe to drive the WelcomeGate branch.
- i18n: nav.assistant ("Assistent" / "Assistant").

agent_memory dedup (gnubok_remember_fact):
- The agent re-remembers the same fact constantly (e.g. "Vercel = omvänd
  skattskyldighet" on every Vercel categorization), which would bloat
  agent_memory with paraphrases over months.
- Before insert, compare the incoming fact against the 300 most-recent
  active memories by word-set Jaccard similarity (lowercased, punctuation-
  stripped, stopwords dropped). A near-duplicate (≥0.82) is treated as
  already-known: bump its relevance toward the new score + refresh
  updated_at instead of writing a new row. Embedding-free, zero added
  latency beyond one bounded SELECT.

Tests: 4112. Build: green.

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

* fix(agent,nav): företagsprofil=Bolagsuppgifter, avatar nav icon, dedupe greeting

Företagsprofil settings page (the right content this time):
- Replaced the agent atoms/summary panel with CompanyProfileView — a
  read-only "Bolagsuppgifter" view of the cached TIC company snapshot
  (name, org-nr, form, address, F-skatt/Moms/Arbetsgivare, SNI, bank,
  verksamhet, employees, latest financials, status traffic-lights,
  fiscal year, firmateckning, företrädare). Server component reads the
  companies.tic_snapshot column directly — no extension import, stays
  inside the core-build boundary.
- Route renamed /settings/agent-profile → /settings/company-profile.
  Removed the old AgentProfilePanel + the now-unused /api/agent/atom-titles
  endpoint.

"Assistent" nav icon = the agent's chosen avatar:
- DashboardNav reads agent identity from AgentSheetProvider and renders
  the onboarding-chosen avatar for the /chat ("Assistent") entry across
  desktop sidebar, mobile drawer, and mobile bottom nav. Falls back to
  the Sparkles glyph pre-onboarding (no avatar yet).

Nav cleanup:
- Dropped the beta badge from Underlag.
- Filtered the TIC workspace (/e/general/tic, "Företagsprofil") out of
  the nav — the same Bolagsuppgifter now lives under Inställningar →
  Företagsprofil, so it shouldn't appear in two places.

Doubled intake greeting fix:
- /chat/intake fires an invoke with no conversation_id, then swaps the
  URL to /chat/[id] the instant the `conversation` event lands — which
  can beat the greeting being persisted. /chat/[id] then hydrated with 0
  messages and, because the auto-fire guard keyed on (id && messages>0),
  fired a SECOND invoke on the same conversation → two greetings.
  Guard now keys on conversation-id presence alone: a set id means
  resume, never bootstrap. Closes the race.

Tests: 4112. Build: green.

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

* fix(agent): paragraph-break-after-tool split words mid-stream

The earlier "insert \n\n when text resumes after a tool call" heuristic
re-evaluated on EVERY text_delta (any delta not starting/ending with
whitespace, once a tool had run). Streaming deltas arrive in sub-word
chunks, so it injected breaks between fragments of the same word:
"minnes\n\nno\n\nterna", "kund\n\nrep\n\nresentation".

Replace the per-delta heuristic with a consume-once ref:
- tool_use sets breakBeforeNextTextRef = true
- the next text_delta consumes it: prepends \n\n exactly once (only when
  the buffer has content, doesn't already end in whitespace, and the
  delta doesn't start with whitespace), then clears the flag

So the break fires once per tool→text resume, never mid-word.

Build: green.

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

* fix(agent): much shorter replies, representation headcount + VAT cap, dot separator

Brevity (system-prompt Svarsformat — affects every reply):
- Hard "korthet är regel nummer ett": aim for 2-4 sentences, lead with
  the answer/action, no warm-up ("Här är vad som gäller…"), don't derive
  VAT in prose, don't restate what the approval card shows, one question
  at a time. The agent was writing textbook-length essays.

Representation rule now in shared-rules (so verifikation-draft, vat-review,
etc. all get it — previously only transaction-categorization had it, which
is why the verifikation flow guessed 25% VAT and skipped the cap):
- Require ANTAL deltagare (headcount), not just one name — the moms
  deduction is per person (underlag cap 300 kr/person ex moms).
- Use the receipt's ACTUAL VAT rate (usually 12% on food), never assume
  25%.
- Meal representation isn't income-tax deductible (post-2017); whole cost
  booked as non-deductible representation.

Verifikation description separator:
- createTransactionJournalEntry appended notes with an em-dash
  ("Utlägg Eatnam — Deltagare:…"), violating house style. Switched to a
  middle dot " · ". journal_entries has no separate notes column — the
  description IS the BFL verifikationstext / audit field, so deltagare +
  syfte correctly live there.

Tests: 4112. Build: green.

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

* fix(settings): tidy Bolagsuppgifter — no status colours, clean firmateckning

From first-look feedback on the Företagsprofil page:

- Status: dropped the coloured traffic-light badges (red/yellow/green).
  Per the design system semantic colour is data-only, never chrome, so
  status now renders as plain label + date. Also filtered to dated
  entries only — Bolagsverket emits flags like "Har aldrig varit verksam"
  with no date that read as noise next to the real status. Ceased status
  gets muted destructive text (the one chrome colour the system keeps).

- Firmateckning: the source text carries ">" list markers and crams
  several rules onto one line, and repeats "Firman tecknas av styrelsen"
  across rows. cleanSignatory() strips the markers, normalises whitespace,
  splits run-on "Firman tecknas …" clauses onto separate lines, and the
  render dedupes — so each rule reads as its own sentence.

Build: green.

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

* fix(mcp): inbox items expose all terminal links + processed flag

The Eatnam receipt was booked against its bank transaction (so the inbox
row had matched_transaction_id + created_journal_entry_id set), yet the
agent reported it as loose/unmatched and a duplicate risk. Root cause:
gnubok_list_inbox_items only selected and returned matched_supplier_id +
created_supplier_invoice_id — the supplier-invoice path. The
transaction-match and direct-journal-entry paths were invisible, so any
receipt cleared via /transactions looked unprocessed.

- list_inbox_items now selects + returns matched_transaction_id and
  created_journal_entry_id alongside the supplier fields, plus a derived
  `processed` boolean (true when ANY of the three terminal links is set).
- New unprocessed_only=true input filters to items with no terminal link
  — the "what still needs handling" view that prevents the agent from
  flagging already-booked docs as duplicates. (Fetches a wider window
  then filters client-side so limit applies post-filter.)
- Description updated to document the processed semantics, within the
  280-char tool-description budget.

The DB linkage itself already worked: /transactions attach-document sets
matched_transaction_id, and commitCategorizeTransaction stamps
created_journal_entry_id. This was purely a read/surface gap.

Tests: 4112 (+ MCP description guard). Build: green.

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

* fix(mcp): repair stage-but-never-commit tools + consolidate tool surface

- post_annual_depreciation AND reverse_entry were never in the pending_operations operation_type CHECK, so both staged then died with check_violation at INSERT. Add the CHECK migration, a commitPostAnnualDepreciation executor (reusing commitAnnualPostings), risk tier, and the PendingOperationType union member.
- Salary tools de-risked: calculate_salary_run calls runSalaryCalculation() directly (no self-fetch/forged cookie); create_salary_run uses a transactional create-run helper with compensating delete; generate_agi actually generates + persists the declaration.
- import_sie parses + validates at stage time with a content-rich preview (company, fiscal year, voucher/account counts, balance) instead of a blind byte count.
- batch-match-invoices passed user.id where companyId was expected (silently matched zero).
- VAT report+widget merged behind render_ui; gnubok_search_tools ranks by relevance; gnubok_feedback readOnlyHint corrected; tools/list instruction text fixed; income decision-tree + GL/query_journal cross-refs added.

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

* feat(agent): load skill atom bodies from the DB so they survive the build

Skill bodies were read from disk at runtime (.claude/skills/**/SKILL.md); on Vercel the dynamic readFile path isn't traced into the lambda and on Docker .claude/ is excluded, so atoms loaded EMPTY in production — a despecialized agent. Inline the bodies into agent_atom_registry instead:
- Migration adds body + mcp_exposed columns; a build-time generator (scripts/generate-skill-bodies.ts) emits a deterministic dollar-quoted seed migration with a content-hash manifest + --check CI guard.
- Read sites (mcp-server atoms.ts, chat system-prompt.ts, composer prewarm) read body from the DB, with a dev-only disk fallback. mcp_exposed curates which atoms the MCP exposes (swarm-* never become atoms).
- The seed script + generator share scripts/lib/atom-discovery.ts; estimated_tokens now reflects SKILL.md only.

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

* feat(agent): safe the in-app assistant — gating, FAB de-confliction, rate limit, friendly errors

- Hide all agent entry points until verified_at: the Assistent nav tab (sidebar + mobile) and the agent-memory settings tab now match the floating FAB's gate.
- FAB de-confliction: /kpi -> kpi.explain and /bookkeeping/year-end -> bokslut.step so the floating button opens the SAME assistant as the page button (no two-agents-on-one-page).
- Generous per-user rate limit (30/min, 1000/day) on /api/agent/invoke, /onboarding/stream, /composer via a new agent_rate_counters table + check_and_increment_agent_quota RPC; fails open. Bounds runaway Bedrock spend without touching normal users.
- Friendly errors: Bedrock 429/timeout/5xx normalized to Swedish (friendlyModelError) in run-turn + the invoke route; the chat client surfaces the server's friendly message instead of a raw HTTP status.
- /chat/new validates ?intent= against the registry so bad deep-links fall back to general.help instead of rendering a broken-looking error.

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

* fix(agent): keep /chat read-only — redirect categorization + swap the "categorize" suggestion for a VAT-report question

general.help (the /chat assistant) is read-only, but it still gave per-transaction bokföringsförslag in prose and asked "godkänner du dessa?" — an analysis the user can't act on (no write tool, no per-tx underlag). Strengthen the prompt to redirect categorization/bokföring to the per-transaction flow (open the transaction -> "Fråga om denna transaktion", where the agent sees the underlag and stages a real ApprovalCard); a short overview is still allowed. Add a guard test locking in no-write-tools + the redirect language. Swap the /chat empty-state "Hjälp mig kategorisera" chip (which lured users into exactly this dead-end) for a VAT-report question the read-only assistant can actually answer.

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

* refactor(pending): declutter the review queue rows + header

Fold the conversation deep-link onto the actor label (drop the separate
"Konversation #xxxx" strip and its icon), hide the quick-pick when there's
only one operation type (it duplicated "Markera alla"), and drop the "(0)"
from the disabled bulk-approve button.

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

* feat(vat): enhance VAT handling by integrating document validation and improving error messaging

* feat(settings): add assistant knowledge surface + consolidate settings tabs

Expose the agent's skill atoms (agent_atom_registry) in a read-only surface
beside the existing memory view, and tighten the settings tab bar from 14 to
10 tabs.

- New GET /api/agent/skills + AgentSkillsPanel: lists active, mcp_exposed
  atoms grouped by tier (Kärnkompetens / bransch / bolagssituation), flags
  which are active for the company from agent_profiles, and lazy-loads each
  SKILL.md body on expand.
- New /settings/assistant tab with a Minne/Kompetens toggle (?view=skills);
  /settings/agent-memory and /settings/agent-skills redirect into it.
- Merge Företagsprofil (TIC snapshot) into the Företag tab via
  CompanyProfileSection; /settings/company-profile redirects.
- Merge Skatteverket-anslutningen into the Skatt tab — OAuth returnTo and the
  callback toast now target /settings/tax; /settings/skatteverket redirects.
- Drop the Säkerhetsbackup tab (already under Importera/Exportera).

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

* fix(inbox): keep booked underlag out of the unmatched queue + widen match window

- categorize: after booking an inbox underlag onto a verifikat, backfill the
  inbox row's matched_transaction_id + created_journal_entry_id so it stops
  showing as unmatched (mirrors the /attach-document paperclip path).
- TransactionMatchPicker: bias the candidate window forward (60d before →
  180d after the invoice date) so late payments aren't dropped before scoring,
  and widen the ranking date tolerance to 120d so the true match floats to the
  top instead of collapsing to "Svag match". Fix "okatigoriserade" typo.

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

* wip: bundle in-progress branch work + agent onboarding chat optimizations

Captures the uncommitted work-in-progress on this branch so it lives on the
remote. Heterogeneous changeset — bundled as one commit since the work was
already entangled across files.

Headline change in this commit (from this session):
- Remove the double interview in agent onboarding. Phase B's verification-
  question form stepper is gone — the Phase C chat (onboarding.intake) now
  owns the entire interview and reads the composer's verification_questions
  server-side as its question bank.
- ReviewCard collapses from 3 steps to 2 (meet → review-and-confirm) with
  value-first ordering: profile + "vad jag kan hjälpa dig med" + facts +
  optional seed note. CTA reads "Möt {namn}" to signal the chat follows.
- ChatIntakeStarter handoff subcopy updated to match reality (assistant
  greets first; user can leave anytime).
- Stamp agent_profiles.intake_completed_at server-side in
  app/api/agent/invoke/route.ts on the first user-typed reply in any
  onboarding.intake conversation (idempotent IS NULL guard, best-effort).
  Closes the previously dead-write column and unlocks the opportunistic-
  follow-up hook the migration anticipated.

Plus in-progress branch work being carried forward (not introduced here):
agent runtime + intent prompts, composer + atom-discovery scripts, MCP
server skills surface, onboarding flow components, dashboard/inbox tweaks,
two new agent_atom_registry migrations, additional agent-chat tests.

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

* refactor(agent): drop inline "Fråga assistenten" affordances — rely on the FAB

The bottom-right "Fråga {namn}" FAB (AgentTrigger) is already route-aware
and picks the right intent per page, so duplicating it as inline page-
header buttons and empty-state links is noise. Removed:

- EmptyState `agentHelp` link ("Eller fråga {namn} hur du kommer igång")
  + the AgentHelpLink component + agent_default_name/agent_ask_link i18n
  keys + the agentHelp props on EmptyInvoices/EmptyCustomers/EmptyTransactions.
- AgentSparkleButton on /bookkeeping (verifikation.draft) and /kpi
  (kpi.explain) page headers.

The FAB stays — when verified, it appears on those routes and routes to
the right intent automatically.

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

* fix(agent): gate the last two ungated "Fråga assistenten" affordances

Both surfaces previously called useAgentSheet directly without checking
identity.isVerified, so they appeared pre-onboarding (everywhere else the
FAB / sparkle buttons / /chat / Assistent nav are all gated on verified_at).

- Settings page header: remove the "Fråga {namn}" pill entirely. The FAB
  covers /settings routes route-aware (settings.help) — no need for a
  duplicate inline trigger.
- Invoice inbox transaction picker: hide the "Fråga assistenten" button
  when the agent isn't built. Done at the parent (InvoiceInboxWorkspace)
  by passing onAskAssistant only when identity.isVerified is true; the
  child renders the button only when the callback is present.

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

* feat(tic,onboarding,agent): single-call TIC lookup + director-aware narrative voice

- TIC: collapse the company lookup from 6 endpoint calls to 1
  (search-public already exposes sniCodes, bank accounts, emails, phones,
  and registration flags). Derive fiscal-year MM-DD from
  mostRecentFinancialSummary; newly-registered companies fall through to
  the client's first-year defaults.
- Onboarding: BankID picker no longer auto-provisions companies. Every
  pick routes through the wizard with orgnr (and entity_type via the
  CompanyRoles match) prefilled; F-skatt/VAT/address get confirmed in
  steps 2-4 instead of being auto-fetched. createCompanyFromOnboarding
  reuses CompanyLookupResult and adds a defensive top-level catch so
  server-action errors surface to the UI instead of being redacted.
- Agent composer: loadUserDirectorship() checks BankID CompanyRoles for
  a director-like position (ceo/boardMember/chairman/externalSignatory,
  active) before the narrative uses second-person ownership voice
  ("Du driver…"); unknown users get neutral third-person voice so we
  never put ownership words in the user's mouth.

Tests cover loadUserDirectorship, narrative voice, tic-fetch path,
onboarding page, and updated TIC client + lookup/profile suites.

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

* fix(tic): extend agent-onboarding TIC budget to 10s + backfill stranded org_numbers

The 5s TIC fetch timeout aborted client-side before the upstream Lens
fan-out (~13 calls) could complete, but the in-flight upstream calls
still counted against quota — actions.ts already documents ~530 wasted
calls from this in May. Same bug still applied to the agent-onboarding
stream path. Adds an optional `timeoutMs` to `ensureTicSnapshot` so
deliberate wait-screen callers (agent onboarding stream) can run with
10s while background/dev callers stay on the conservative 5s default.

Page-level server fetch (page.tsx) intentionally stays at 5s to avoid
blocking TTFB without a visible progress affordance.

Backfill migration mirrors `company_settings.org_number` to
`companies.org_number` for the 105 cases where it's safe (after dedup
+ conflict filtering). 56 of those are on active companies — unblocks
duplicate guards, SIE/SRU exports, and TIC fallback chain. Zero TIC
API calls — pure data move. Idempotent.

Also sweeps a pre-existing SSRF guard on the stream route's origin
derivation that was sitting unstaged in the working tree — it lives in
the same diff hunks as the TIC budget change and couldn't be split cleanly.

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

* wip: bundle in-progress branch work

Sweep up uncommitted agent/MCP/RLS work-in-progress so the branch is fully
backed up to origin. Not reviewed in detail — committed as-is to preserve
working state alongside the TIC fixes in the previous commit.

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

* fix(agent): tag the "Bygg din bokföringsassistent" CTA as Beta

Adds a Beta badge next to the assistant-setup heading on the dashboard
banner, dashboard inline card, and onboarding checklist row. Also drops
the stale "Gratis i 30 dagar" subline from the dashboard card.

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

* fix(build,migrations): PendingOperationType salary ops + resolve migration version collisions

PR #584 went red on three things:

1. core-only build / Vercel: `lib/pending-operations/commit.ts:2666` switched on
   'create_salary_run' and 'generate_agi' but `PendingOperationType` was missing
   both literals. Add them to the union.

2. Supabase preview: migration version 20260526120000 collided with main's
   newly-merged 20260526120000_fix_replace_sie_import_hard_delete.sql.
   Bump the branch's pair to 20260526120050 / 20260526120051 — still ahead of
   20260526120100_restvardeavskrivning so ordering is preserved.

3. 20260527170000 was used twice on this branch
   (_agent_rls_with_check + _journal_entry_no_doc_required). Bump the second
   to 20260527170100 so the pair stays orderable and Supabase doesn't choke
   on the duplicate schema_migrations PK.

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

* fix(ci): reword comment so core-only guard stops flagging it

The "Check no core imports from extensions" step greps for the literal
\`from '@/extensions/\` across lib/, app/api/, components/. A comment in
lib/agent/composer/tic-fetch.ts quoted the exact pattern verbatim to
explain *why* the file does a self-fetch instead of importing the TIC
extension directly — which the grep matched even though no actual
import exists.

Rewrite the line to keep the same meaning without the literal pattern.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-05-28 11:27:01 +02:00

8507 lines
344 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from 'next/server'
import {
extractBearerToken,
validateApiKey,
createServiceClientNoCookies,
hasScope,
TOOL_SCOPE_MAP,
} from '@/lib/auth/api-keys'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { eventBus } from '@/lib/events/bus'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { getBranding } from '@/lib/branding/service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
calculateGrossMargin,
calculateCashPosition,
calculateExpenseRatio,
calculateAvgPaymentDays,
} from '@/lib/reports/kpi'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { uiWidgets, findUiWidget, WIDGET_MIME_TYPE } from './widgets'
import { dataResources, findResource, parseResourceQuery } from './resources'
import { prompts, findPrompt } from './prompts'
import { findSkill, loadAllSkills, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
import type { SkillTier } from './skills'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { z } from 'zod'
import {
checkIdempotencyKey,
storeIdempotencyResponse,
hashRequest,
IdempotencyKeyReuseError,
} from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
import { closePeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service'
import { generateSIEExport } from '@/lib/reports/sie-export'
import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { getSuggestedCategories } from '@/lib/transactions/category-suggestions'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service'
import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
import { commitPendingOperation } from '@/lib/pending-operations/commit'
import { appendProcessingHistory } from '@/lib/processing-history/append'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler — no duplicate call needed here.
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation } from '@/types'
// ── Actor context ────────────────────────────────────────────
interface ActorContext {
type: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
id?: string
label?: string
/**
* Stable agent-session identifier from the `Mcp-Session-Id` JSON-RPC header
* when present, otherwise null. Used to correlate `mcp.tool_called`,
* `mcp.workflow_started`, `mcp.next_hint_followed`, etc. events across a
* single agent conversation. Not used for auth.
*/
sessionId?: string | null
}
// ── JSON-RPC types ───────────────────────────────────────────
interface JsonRpcRequest {
jsonrpc: '2.0'
id?: string | number
method: string
params?: Record<string, unknown>
}
interface JsonRpcResponse {
jsonrpc: '2.0'
id: string | number | null
result?: unknown
error?: { code: number; message: string; data?: unknown }
}
// ── MCP Tool definition ──────────────────────────────────────
interface McpToolAnnotations {
readOnlyHint?: boolean
destructiveHint?: boolean
idempotentHint?: boolean
openWorldHint?: boolean
}
interface McpTool {
name: string
description: string
inputSchema: Record<string, unknown>
outputSchema?: Record<string, unknown>
annotations: McpToolAnnotations
_meta?: { ui: { resourceUri: string } }
// Result-level UI hint: when set, a call passing render_ui=true gets a
// _meta.ui.resourceUri on the RESULT, so the host renders the widget only when
// asked. (Contrast _meta above, on the definition, which renders on every call.)
uiResourceUri?: string
execute: (
args: Record<string, unknown>,
companyId: string,
userId: string,
supabase: SupabaseClient,
actor?: ActorContext
) => Promise<unknown>
}
// ── Shared constants ─────────────────────────────────────────
const log = createLogger('mcp-server')
// gnubok_feedback rate limit: 1 per 60s per actor. In-memory single-process;
// no Redis dependency. See the gnubok_feedback tool definition below.
const FEEDBACK_RATE_LIMIT_MS = 60_000
const feedbackRateLimit = new Map<string, number>()
const VALID_CATEGORIES = [
'income_services', 'income_products', 'income_other',
'expense_equipment', 'expense_software', 'expense_travel', 'expense_office',
'expense_marketing', 'expense_professional_services', 'expense_education',
'expense_representation', 'expense_consumables', 'expense_vehicle',
'expense_telecom', 'expense_bank_fees', 'expense_card_fees',
'expense_currency_exchange', 'expense_other', 'private',
] as const
const VALID_VAT_TREATMENTS = [
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
] as const
// ── Pending operations staging ───────────────────────────────
/**
* Param-keys we'll scan for an affärshändelse date when the caller doesn't
* pass `dateForPeriodCheck` explicitly. Ordered: most-specific first. The
* first ISO yyyy-MM-dd hit wins. Adding a new field is safe — unknown values
* just fall through to undefined.
*/
const AUTO_PERIOD_DATE_KEYS = [
'entry_date',
'payment_date',
'invoice_date',
'date',
'period_end',
'period_start',
'voucher_date',
'paid_date',
'transfer_date',
] as const
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
function autoExtractDateForPeriodCheck(params: Record<string, unknown>): string | undefined {
for (const key of AUTO_PERIOD_DATE_KEYS) {
const value = params[key]
if (typeof value === 'string' && ISO_DATE_RE.test(value)) return value
}
return undefined
}
interface StageOptions {
/**
* When true, validate inputs and return the would-be preview without
* inserting into pending_operations or executing any side-effects. Used
* by agents to preflight an operation before committing to it.
*/
dryRun?: boolean
/**
* Per-operation idempotency key. When supplied, repeat calls with the same
* key + same payload return the original response and never re-execute.
* Different payload + same key returns IDEMPOTENCY_KEY_REUSE.
*/
idempotencyKey?: string
/**
* ISO yyyy-MM-dd date used to look up period_status before staging. When
* provided, the response includes a `period_status` envelope so agents and
* widgets can detect locked/closed periods without a round-trip. Failure to
* resolve (DB blip, missing settings row) leaves the response unchanged —
* the DB triggers remain the authoritative gate.
*/
dateForPeriodCheck?: string
}
function buildApprovalGuidance(operationId: string, riskLevel: 'low' | 'medium' | 'high'): string {
if (riskLevel === 'high') {
return `This is an irreversible posting under BFL 5 kap 5§ — surface the irreversibility implications to the user and obtain an explicit acknowledgment before committing. Once the user has acknowledged, call gnubok_approve_pending_operation with operation_id="${operationId}" and confirmed=true.`
}
return `When the user authorises, call gnubok_approve_pending_operation with operation_id="${operationId}".`
}
async function stagePendingOperation(
supabase: SupabaseClient,
companyId: string,
userId: string,
operationType: string,
title: string,
params: Record<string, unknown>,
previewData: Record<string, unknown>,
actor: ActorContext = { type: 'user' },
next?: NextActionHint,
options: StageOptions = {}
): Promise<{
staged: boolean
dry_run?: boolean
idempotency_replay?: boolean
operation_id?: string
risk_level: 'low' | 'medium' | 'high'
actor: ActorContext
message: string
approve?: { tool: string; args: Record<string, unknown> }
preview: Record<string, unknown>
period_status?: PeriodStatusForDate
next?: NextActionHint
}> {
const riskLevel = getRiskLevel(operationType)
const branding = getBranding().appName.toLowerCase()
// Resolve period_status once. The caller can pass `dateForPeriodCheck`
// explicitly; otherwise we scan params for a known affärshändelse-date
// field so every date-bearing operation surfaces a period_status envelope
// without each tool having to opt in. Failure is non-fatal — DB triggers
// are the authoritative gate; a missing envelope just degrades preview UX.
const dateForPeriodCheck = options.dateForPeriodCheck ?? autoExtractDateForPeriodCheck(params)
let periodStatus: PeriodStatusForDate | undefined
if (dateForPeriodCheck) {
try {
periodStatus = await resolvePeriodStatusForDate(supabase, companyId, dateForPeriodCheck)
} catch (err) {
log.warn('resolvePeriodStatusForDate failed', {
operationType,
companyId,
dateForPeriodCheck,
error: err instanceof Error ? err.message : String(err),
})
periodStatus = undefined
}
}
// ── Dry-run path: skip both the cache and the insert. Return the preview
// so the agent sees exactly what would happen without committing.
if (options.dryRun) {
return {
staged: false,
dry_run: true,
risk_level: riskLevel,
actor,
message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`,
preview: previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
...(next ? { next } : {}),
}
}
// ── Idempotency check: same key + same payload + same company → return
// cached response. companyId is folded into the canonical hash so the
// same key UUID submitted under a different company is treated as a
// fresh request, not a replay.
const requestHash = options.idempotencyKey
? hashRequest({ operationType, params, companyId })
: null
if (options.idempotencyKey && requestHash) {
const cached = await checkIdempotencyKey(supabase, userId, companyId, options.idempotencyKey, requestHash)
if (cached) {
const cachedBody = cached.body as Record<string, unknown>
const cachedOpId = typeof cachedBody.operation_id === 'string' ? cachedBody.operation_id : undefined
return {
...cachedBody,
idempotency_replay: true,
risk_level: riskLevel,
actor,
message: cachedOpId
? `Replayed cached response for idempotency_key "${options.idempotencyKey}" — already staged as pending_operation ${cachedOpId}. No new side-effects. ${buildApprovalGuidance(cachedOpId, riskLevel)}`
: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`,
...(cachedOpId
? { approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: cachedOpId } } }
: {}),
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
} as Awaited<ReturnType<typeof stagePendingOperation>>
}
}
const { data, error } = await supabase
.from('pending_operations')
.insert({
company_id: companyId,
user_id: userId,
operation_type: operationType,
title,
params,
preview_data: previewData,
actor_type: actor.type,
actor_id: actor.id ?? null,
actor_label: actor.label ?? null,
risk_level: riskLevel,
})
.select('*')
.single()
if (error) throw new Error(`Failed to stage operation: ${error.message}`)
// approve.args carries only the operation_id. For high-risk operations the
// LLM must supply confirmed=true itself after surfacing the BFL 5 kap 5§
// irreversibility implications to the user — pre-filling it server-side
// would collapse the explicit-acknowledgment gate (mirrors the web UI's
// warning dialog). The server-side check in gnubok_approve_pending_operation
// remains authoritative.
const response = {
staged: true,
operation_id: data.id,
risk_level: riskLevel,
actor,
message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`,
approve: {
tool: 'gnubok_approve_pending_operation',
args: { operation_id: data.id } as Record<string, unknown>,
},
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}),
...(next ? { next } : {}),
} as const
if (options.idempotencyKey && requestHash) {
await storeIdempotencyResponse(
supabase, userId, companyId, options.idempotencyKey, requestHash,
'success', { staged: true, operation_id: data.id, preview: previewData }
)
}
return response
}
// ── Journal entry reference resolution ────────────────────────
/**
* Resolve a journal entry reference to a journal_entries.id UUID.
*
* Accepts either a raw UUID (returned as-is) or a voucher reference like
* "A-113" / "A113" / "A/113" (resolved by voucher_series + voucher_number
* scoped to the company).
*
* Voucher refs are the preferred input shape for LLM-driven callers: short,
* semantically meaningful, and resistant to UUID hallucination — a failure
* mode where the agent reproduces the first 8 hex chars correctly but
* fabricates the remaining 24, so a downstream lookup rejects the ID even
* though the entry exists.
*/
async function resolveJournalEntryRef(
supabase: SupabaseClient,
companyId: string,
ref: string
): Promise<string> {
const trimmed = ref.trim()
// UUIDs pass through. If the UUID was hallucinated, the caller's own
// lookup surfaces the "not found" diagnostic with the supplied value.
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)) {
return trimmed
}
// Voucher ref: letters (series) + optional separator + digits (number).
const match = trimmed.match(/^([A-Za-z]+)\s*[-:/ ]?\s*(\d+)$/)
if (!match) {
throw new Error(
`Could not parse entry reference "${ref}". Expected a UUID or a voucher ref like "A-113".`
)
}
const series = match[1].toUpperCase()
const number = parseInt(match[2], 10)
const { data, error } = await supabase
.from('journal_entries')
.select('id, entry_date, description')
.eq('company_id', companyId)
.eq('voucher_series', series)
.eq('voucher_number', number)
.order('entry_date', { ascending: false })
if (error) {
throw new Error(`Database error resolving voucher "${series}-${number}": ${error.message}`)
}
const matches = (data ?? []) as Array<{ id: string; entry_date: string; description: string }>
if (matches.length === 0) {
throw new Error(
`No journal entry found for voucher "${series}-${number}" in this company. ` +
`Verify the series and number, or supply the full UUID.`
)
}
// Voucher numbers reset per fiscal period. The same (series, number) pair
// can therefore appear in multiple years — refuse to guess.
if (matches.length > 1) {
const summary = matches
.map((m) => `${m.entry_date} "${m.description}" (id=${m.id})`)
.join('; ')
throw new Error(
`Voucher "${series}-${number}" matches multiple entries across fiscal periods: ${summary}. ` +
`Supply the specific UUID instead.`
)
}
return matches[0].id
}
// ── Shared categorization logic ──────────────────────────────
async function categorizeTransactionCore(
txId: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
userId: string,
companyId: string,
supabase: SupabaseClient,
confirm: boolean = false
): Promise<{
preview?: boolean
success?: boolean
journal_entry_created?: boolean
journal_entry_id?: string | null
journal_entry_error?: string | null
category: string
debit_account: string
credit_account: string
amount: number
currency: string
vat_lines?: Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>
message?: string
transaction?: Transaction
underlag?: {
document_id: string
total: number | null
vat_amount: number | null
currency: string | null
} | null
}> {
// Validate category
if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) {
throw new Error(
`Invalid category "${category}". Valid categories: ${VALID_CATEGORIES.join(', ')}`
)
}
if (vatTreatment && !VALID_VAT_TREATMENTS.includes(vatTreatment as typeof VALID_VAT_TREATMENTS[number])) {
throw new Error(
`Invalid vat_treatment "${vatTreatment}". Valid: ${VALID_VAT_TREATMENTS.join(', ')}`
)
}
const isBusiness = category !== 'private'
// Fetch the transaction
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', txId)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
throw new Error('Transaction not found. Check the transaction_id is correct.')
}
// Underlag guard: if the transaction has an attached document with
// AI-extracted invoice data, use it to validate the proposed VAT treatment
// BEFORE we build the booking. The historical failure mode here was the
// agent stamping reverse_charge on any foreign-vendor charge and producing
// fictive 2645/2614 VAT lines (25% of the SEK amount) on an invoice where
// the seller had already debited real VAT. Block that explicitly.
let underlagSummary: {
document_id: string
total: number | null
vat_amount: number | null
currency: string | null
} | null = null
if (transaction.document_id) {
const { data: doc } = await supabase
.from('document_attachments')
.select('id, extracted_data')
.eq('id', transaction.document_id)
.eq('company_id', companyId)
.maybeSingle()
const ex = (doc?.extracted_data ?? null) as
| { totals?: { total?: number; vatAmount?: number }; invoice?: { currency?: string } }
| null
if (doc) {
underlagSummary = {
document_id: doc.id as string,
total: ex?.totals?.total ?? null,
vat_amount: ex?.totals?.vatAmount ?? null,
currency: ex?.invoice?.currency ?? null,
}
const sellerChargedVat = (ex?.totals?.vatAmount ?? 0) > 0
if (vatTreatment === 'reverse_charge' && sellerChargedVat) {
throw new Error(
`Reverse charge avvisas: underlaget (document_id=${doc.id}) visar att säljaren redan har debiterat moms ` +
`(${ex?.totals?.vatAmount} ${ex?.invoice?.currency ?? ''}). Omvänd skattskyldighet gäller bara fakturor utan säljarens moms. ` +
`Bokför som vanlig kostnad (utan vat_treatment, eller standard_25 om svensk faktura) — den utländska momsen ingår i kostnaden.`,
)
}
}
}
if (transaction.journal_entry_id) {
return {
success: true,
journal_entry_created: false,
journal_entry_id: transaction.journal_entry_id,
journal_entry_error: 'Transaction already has a journal entry — use gnubok_list_uncategorized_transactions to find unbooked ones.',
category,
debit_account: '',
credit_account: '',
amount: Math.abs(transaction.amount),
currency: transaction.currency,
transaction: transaction as Transaction,
}
}
// Get entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Build mapping
const mappingResult = buildMappingResultFromCategory(
category,
transaction as Transaction,
isBusiness,
entityType,
vatTreatment
)
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
`No account mapping for category "${category}" with entity type "${entityType}". ` +
'Try a different category or check your chart of accounts.'
)
}
// Preview mode: return what would happen without executing
if (!confirm) {
return {
preview: true,
category,
debit_account: mappingResult.debit_account,
credit_account: mappingResult.credit_account,
amount: Math.abs(transaction.amount),
currency: transaction.currency,
vat_lines: mappingResult.vat_lines.map(v => ({
account_number: v.account_number,
debit_amount: v.debit_amount,
credit_amount: v.credit_amount,
description: v.description,
})),
message: 'Preview only — no changes made. Call again with confirm: true to create the journal entry.',
underlag: underlagSummary,
}
}
// Ensure fiscal period exists
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
const txDate = new Date(transaction.date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
await supabase
.from('fiscal_periods')
.upsert(
{ user_id: userId, name: periodName, period_start: periodStart, period_end: periodEnd },
{ onConflict: 'user_id,period_start,period_end' }
)
// Create journal entry
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase,
companyId,
userId,
transaction as Transaction,
mappingResult
)
if (journalEntry) {
journalEntryId = journalEntry.id
}
} catch (err) {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// Update transaction
await supabase
.from('transactions')
.update({
is_business: isBusiness,
category,
journal_entry_id: journalEntryId,
})
.eq('id', txId)
// Emit event so extensions (mapping rules, etc.) can react
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId,
companyId,
},
})
// Upsert counterparty template for future auto-matching
try {
await upsertCounterpartyTemplate(
supabase, userId, transaction as Transaction, mappingResult, 'user_approved'
)
} catch {
// Non-critical
}
return {
success: true,
journal_entry_created: !!journalEntryId,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
category,
debit_account: mappingResult.debit_account,
credit_account: mappingResult.credit_account,
amount: Math.abs(transaction.amount),
currency: transaction.currency,
transaction: transaction as Transaction,
}
}
// ── Output schema helpers ────────────────────────────────────
const PAGINATION_PROPS = {
count: { type: 'number', description: 'Number of items in this page' },
total_count: { type: 'number', description: 'Total matching across all pages' },
has_more: { type: 'boolean' },
next_offset: { type: 'number', description: 'Offset for the next page (omitted on last page)' },
} as const
const NEXT_ACTION_HINT_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
description: { type: 'string' },
tool: { type: 'string' },
args: { type: 'object', additionalProperties: true },
resource: { type: 'string' },
},
required: ['description'],
} as const
const STAGED_OPERATION_SCHEMA = {
type: 'object',
properties: {
staged: { type: 'boolean' },
operation_id: { type: 'string', description: 'UUID of the staged operation, present once persisted' },
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] },
actor: { type: 'object' },
dry_run: { type: 'boolean' },
idempotency_replay: { type: 'boolean' },
message: { type: 'string' },
approve: { type: 'object' },
preview: { type: 'object' },
period_status: {
type: 'object',
description: 'Fiscal period covering the affärshändelse date. Use to detect locked/closed periods without a round-trip.',
properties: {
period_id: { type: ['string', 'null'] },
status: { type: 'string', enum: ['open', 'locked', 'closed'] },
lock_date: { type: ['string', 'null'] },
},
},
next: NEXT_ACTION_HINT_SCHEMA,
},
required: ['staged', 'risk_level', 'actor', 'message', 'preview'],
} as const
function paginatedSchema(itemsKey: string, itemSchema: Record<string, unknown> = { type: 'object' }) {
return {
type: 'object',
properties: {
[itemsKey]: { type: 'array', items: itemSchema },
...PAGINATION_PROPS,
},
required: [itemsKey, 'count', 'total_count', 'has_more'],
} as const
}
const VAT_REPORT_OUTPUT_SCHEMA = {
type: 'object',
properties: {
period: {
type: 'object',
additionalProperties: false,
properties: {
type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'] },
year: { type: 'number' },
period: { type: 'number' },
start: { type: 'string', description: 'Period start date (YYYY-MM-DD)' },
end: { type: 'string', description: 'Period end date (YYYY-MM-DD)' },
},
required: ['type', 'year', 'period', 'start', 'end'],
},
period_label: { type: 'string', description: 'Human-readable period label (e.g. "Q1 2026")' },
rutor: {
type: 'object',
description: 'SKV 4700 momsdeklaration boxes — absolute values, signs implied by box semantics',
properties: {
ruta05: { type: 'number', description: 'Total domestic taxable sales (all rates)' },
ruta10: { type: 'number', description: 'Output VAT 25 % (account 2611)' },
ruta11: { type: 'number', description: 'Output VAT 12 % (account 2621)' },
ruta12: { type: 'number', description: 'Output VAT 6 % (account 2631)' },
ruta30: { type: 'number', description: 'Reverse-charge output VAT 25 % (account 2614)' },
ruta31: { type: 'number', description: 'Reverse-charge output VAT 12 % (account 2624)' },
ruta32: { type: 'number', description: 'Reverse-charge output VAT 6 % (account 2634)' },
ruta35: { type: 'number', description: 'EU intra-community goods supplies, momsfri (account 3108)' },
ruta39: { type: 'number', description: 'EU services sold (account 3308)' },
ruta40: { type: 'number', description: 'Export outside EU (account 3305)' },
ruta48: { type: 'number', description: 'Total input VAT (2641 + 2645 + 2647)' },
ruta49: {
type: 'number',
description: 'VAT to pay (positive) or refund (negative) = (10+11+12+30+31+32+60+61+62) − 48',
},
ruta60: { type: 'number', description: 'Import VAT 25 % (account 2615) — non-EU import declared via momsdeklaration' },
ruta61: { type: 'number', description: 'Import VAT 12 % (account 2625)' },
ruta62: { type: 'number', description: 'Import VAT 6 % (account 2635)' },
},
required: ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49', 'ruta60', 'ruta61', 'ruta62'],
},
summary: { type: 'string', description: 'One-line Swedish summary string (att betala / att få tillbaka / noll)' },
warnings: {
type: 'array',
items: { type: 'string' },
description: 'Pre-filing warnings (e.g. one-sided reverse charge). Empty when none.',
},
},
required: ['period', 'period_label', 'rutor', 'summary', 'warnings'],
} as const
// ── VAT report computation (shared by gnubok_get_vat_report + gnubok_vat_review_widget) ──
//
// Maps posted journal entry lines to SKV 4700 rutor. ruta49 covers domestic
// output VAT (10/11/12) AND reverse-charge output VAT (30/31/32) per
// ML 2023:200 — both must be displayed and netted against ruta48 (input VAT).
//
// Account → ruta map:
// 3001-3008, 3041-3048, 3051-3058, 3071-3078 → ruta05 (all domestic taxable sales — common BAS revenue accounts)
// 2611 → ruta10 (output VAT 25%)
// 2621 → ruta11 (output VAT 12%)
// 2631 → ruta12 (output VAT 6%)
// 2614 → ruta30 (reverse-charge output VAT 25%)
// 2624 → ruta31 (reverse-charge output VAT 12%)
// 2634 → ruta32 (reverse-charge output VAT 6%)
// 3308 → ruta39 (EU services sold)
// 3305 → ruta40 (export outside EU)
// 2641/2645/2647 → ruta48 (all input VAT)
//
// Posted+reversed status filter: a "reversed" original entry is still part of
// its period's books — Skatteverket files VAT period-by-period under
// faktureringsmetoden (sale's VAT in invoice-date period; kreditfaktura's
// reduction in storno-date period). The original entry stays in its period;
// the storno (status 'posted', dated when the credit was issued) lands in
// its own period. The two periods file independently; across a year they
// arithmetically cancel. *Excluding* 'reversed' would under-report Period N
// (the original sale's VAT silently disappears) and over-credit Period N+M
// (a reversal with no original) — incorrect per ML 2023:200.
/** Common BAS taxable-revenue accounts that contribute to ruta 05.
*
* Conservative expansion beyond 3001/3002/3003. Excludes 3004 (momsfri,
* exempt) and 3108/3305/3308 (handled by ruta35/40/39). 3106 covers the
* rare case of taxable EU goods (momspliktig EU-leverans, e.g. when the
* buyer's VAT number is invalid).
*
* Companies using non-standard charts must either book to one of these
* or extend the list — gnubok's BAS chart only ships 3001/3002/3003/3004
* by default, but 30xx alternates are common in custom charts. */
const RUTA_05_ACCOUNTS = [
// Domestic sales by VAT rate (canonical BAS)
'3001', '3002', '3003', '3005', '3006', '3007', '3008',
// Taxable EU goods (momspliktig — buyer's VAT number invalid or buyer is private)
'3106',
// Domestic services (alternative numbering some companies use)
'3041', '3042', '3043', '3044', '3045', '3046', '3047', '3048',
// Domestic goods (alternative numbering)
'3051', '3052', '3053', '3054', '3055', '3056', '3057', '3058',
// Other domestic taxable
'3071', '3072', '3073', '3074', '3075', '3076', '3077', '3078',
] as const
export interface VatReportResult {
period: { type: string; year: number; period: number; start: string; end: string }
period_label: string
rutor: {
ruta05: number; ruta10: number; ruta11: number; ruta12: number
ruta30: number; ruta31: number; ruta32: number
ruta35: number; ruta39: number; ruta40: number
ruta48: number; ruta49: number
// Import VAT (post-2015 momsdeklaration path, accounts 2615/2625/2635).
// Buyer/importer self-assesses output VAT here and deducts the matching
// input via ruta 48 — same mechanic as ruta 30/31/32.
ruta60: number; ruta61: number; ruta62: number
}
summary: string
warnings: string[]
}
export async function computeVatReport(
args: Record<string, unknown>,
companyId: string,
supabase: SupabaseClient
): Promise<VatReportResult> {
const periodType = args.period_type as string
const year = Number(args.year)
const period = Number(args.period)
if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
throw new Error('period_type must be: monthly, quarterly, yearly')
}
if (!year || year < 2000 || year > 2100) throw new Error('year must be between 2000 and 2100')
if (periodType === 'monthly' && (period < 1 || period > 12)) throw new Error('period must be 1–12 for monthly')
if (periodType === 'quarterly' && (period < 1 || period > 4)) throw new Error('period must be 1–4 for quarterly')
let startDate: string
let endDate: string
if (periodType === 'monthly') {
startDate = `${year}-${String(period).padStart(2, '0')}-01`
const lastDay = new Date(year, period, 0).getDate()
endDate = `${year}-${String(period).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
} else if (periodType === 'quarterly') {
const startMonth = (period - 1) * 3 + 1
const endMonth = period * 3
startDate = `${year}-${String(startMonth).padStart(2, '0')}-01`
const lastDay = new Date(year, endMonth, 0).getDate()
endDate = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
} else {
startDate = `${year}-01-01`
endDate = `${year}-12-31`
}
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', startDate)
.lte('journal_entries.entry_date', endDate)
if (error) throw new Error(`Database error: ${error.message}`)
const accountTotals = new Map<string, { debit: number; credit: number }>()
for (const line of lines ?? []) {
const acc = line.account_number
const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
accountTotals.set(acc, existing)
}
function creditBalance(acc: string): number {
const t = accountTotals.get(acc)
return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0
}
function debitBalance(acc: string): number {
const t = accountTotals.get(acc)
return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0
}
const ruta05 = RUTA_05_ACCOUNTS.reduce((sum, acc) => sum + creditBalance(acc), 0)
const ruta10 = creditBalance('2611')
const ruta11 = creditBalance('2621')
const ruta12 = creditBalance('2631')
const ruta30 = creditBalance('2614')
const ruta31 = creditBalance('2624')
const ruta32 = creditBalance('2634')
const ruta35 = creditBalance('3108') // EU intra-community goods supplies (momsfri leverans till EU)
const ruta39 = creditBalance('3308')
const ruta40 = creditBalance('3305')
// Import VAT (since 2015 declared via momsdeklaration, not Tullverket): the
// importer books output VAT to 2615/2625/2635 (ruta 60/61/62) and the
// matching deductible input to 2645 (rolls into ruta 48 below).
const ruta60 = creditBalance('2615')
const ruta61 = creditBalance('2625')
const ruta62 = creditBalance('2635')
const calculatedInput2645 = debitBalance('2645')
const calculatedInput2647 = debitBalance('2647')
const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647
const ruta49 = Math.round(
(ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 + ruta60 + ruta61 + ruta62 - ruta48) * 100
) / 100
const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December']
let periodLabel: string
if (periodType === 'monthly') periodLabel = `${monthNames[period - 1]} ${year}`
else if (periodType === 'quarterly') periodLabel = `Q${period} ${year}`
else periodLabel = `${year}`
// Pre-filing warnings — surface common compliance footguns.
//
// The matching input for reverse-charge output (2614/2624/2634) lands on
// 2645 (EU acquisitions) or 2647 (domestic reverse charge per ML 16:13 —
// byggtjänster, electronics > 100k SEK, etc.). Either is a valid mirror;
// the warning must fire only when *both* are zero.
const warnings: string[] = []
const totalReverseChargeOutput = ruta30 + ruta31 + ruta32
const totalReverseChargeInput = calculatedInput2645 + calculatedInput2647
if (totalReverseChargeOutput > 0 && totalReverseChargeInput === 0) {
warnings.push(
'Omvänd betalningsskyldighet: utgående moms har bokförts (rutor 30/31/32) men ingen ' +
'beräknad ingående moms (varken 2645 EU eller 2647 inhemsk). Kontrollera att den ' +
'motsvarande ingående bokningen finns — båda sidor krävs enligt ML 2023:200.'
)
}
return {
period: { type: periodType, year, period, start: startDate, end: endDate },
period_label: periodLabel,
rutor: {
ruta05: Math.abs(ruta05),
ruta10: Math.abs(ruta10),
ruta11: Math.abs(ruta11),
ruta12: Math.abs(ruta12),
ruta30: Math.abs(ruta30),
ruta31: Math.abs(ruta31),
ruta32: Math.abs(ruta32),
ruta35: Math.abs(ruta35),
ruta39: Math.abs(ruta39),
ruta40: Math.abs(ruta40),
ruta48: Math.abs(ruta48),
ruta49,
ruta60: Math.abs(ruta60),
ruta61: Math.abs(ruta61),
ruta62: Math.abs(ruta62),
},
summary: ruta49 > 0
? `Moms att betala: ${Math.abs(ruta49).toFixed(2)} kr`
: ruta49 < 0
? `Moms att få tillbaka: ${Math.abs(ruta49).toFixed(2)} kr`
: 'Noll i moms',
warnings,
}
}
// ── VAT close check (composes VAT report + blocker scans + sanity ratios) ──
//
// Intent-shaped tool: answers "can I close VAT for this period?" in one call.
// Replaces the 5–7 chained tool calls (vat_report + uncategorized + supplier
// invoices + reconciliation + voucher gaps + prior-period compare) the agent
// would otherwise need to assemble the same answer.
interface VatCloseBlocker {
kind:
| 'uncategorized_transactions'
| 'unapproved_supplier_invoices'
| 'bank_unreconciled'
| 'missing_high_value_receipts'
| 'reverse_charge_input_missing'
severity: 'high' | 'medium' | 'low'
count: number
message: string
hint: string
}
interface VatCloseSanityAnomaly {
kind: 'output_vat_ratio_drift' | 'input_vat_ratio_drift' | 'revenue_drop' | 'revenue_spike'
rate?: '25' | '12' | '6'
current: number
previous: number
delta_pct: number
message: string
}
interface VatCloseCheckResult {
period: VatReportResult['period']
period_label: string
rutor: VatReportResult['rutor']
payment: {
net_due: number
direction: 'pay' | 'refund' | 'zero'
deadline: string | null
deadline_label: string | null
moms_period: 'monthly' | 'quarterly' | 'yearly' | null
}
blockers: VatCloseBlocker[]
sanity: {
anomalies: VatCloseSanityAnomaly[]
ratios: {
output_vat_ratio_25: number // ruta10 / domestic 25% revenue
output_vat_ratio_12: number
output_vat_ratio_6: number
previous_period_compared: boolean
}
}
ready_to_close: boolean
summary: string
}
/** Compute the Skatteverket momsdeklaration deadline for a period.
* - monthly: due on the 12th of (period-end-month + 1)
* - quarterly: 26th of the month after quarter-end (Q4 → 26 Jan next year)
* - yearly: 26 Feb of next year
*/
export function computeMomsDeadline(
periodType: 'monthly' | 'quarterly' | 'yearly',
year: number,
period: number
): { date: string; label: string } | null {
if (periodType === 'monthly') {
// period 1-12; deadline = 12th of next month
const deadlineMonth = period === 12 ? 1 : period + 1
const deadlineYear = period === 12 ? year + 1 : year
return {
date: `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-12`,
label: `12 ${monthName(deadlineMonth)} ${deadlineYear}`,
}
}
if (periodType === 'quarterly') {
// Q1→26 apr, Q2→26 jul, Q3→26 okt, Q4→26 jan next year
const monthByQuarter: Record<number, { m: number; yOffset: number }> = {
1: { m: 4, yOffset: 0 },
2: { m: 7, yOffset: 0 },
3: { m: 10, yOffset: 0 },
4: { m: 1, yOffset: 1 },
}
const cfg = monthByQuarter[period]
if (!cfg) return null
return {
date: `${year + cfg.yOffset}-${String(cfg.m).padStart(2, '0')}-26`,
label: `26 ${monthName(cfg.m)} ${year + cfg.yOffset}`,
}
}
if (periodType === 'yearly') {
return {
date: `${year + 1}-02-26`,
label: `26 februari ${year + 1}`,
}
}
return null
}
function monthName(m: number): string {
return ['januari', 'februari', 'mars', 'april', 'maj', 'juni',
'juli', 'augusti', 'september', 'oktober', 'november', 'december'][m - 1] ?? ''
}
// ── agent_memory dedup helpers ───────────────────────────────
// Cheap, embedding-free near-duplicate detection for gnubok_remember_fact.
// Lowercase, strip punctuation, drop very short / stop-ish words, and
// compare two memories by Jaccard similarity over their word sets. Good
// enough to catch the agent re-remembering the same fact in slightly
// different words; not a substitute for semantic embeddings, but zero-cost.
const MEMORY_DEDUP_STOPWORDS = new Set([
'och', 'att', 'det', 'som', 'en', 'ett', 'är', 'för', 'med', 'på', 'av',
'till', 'den', 'de', 'i', 'om', 'har', 'var', 'kan', 'ska', 'samt',
'the', 'a', 'an', 'is', 'are', 'for', 'with', 'of', 'to', 'and', 'in',
])
function tokenizeForDedup(text: string): Set<string> {
const tokens = text
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.split(/\s+/)
.filter((t) => t.length >= 3 && !MEMORY_DEDUP_STOPWORDS.has(t))
return new Set(tokens)
}
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let intersection = 0
for (const t of a) if (b.has(t)) intersection++
const union = a.size + b.size - intersection
return union === 0 ? 0 : intersection / union
}
export async function computeVatCloseCheck(
args: Record<string, unknown>,
companyId: string,
supabase: SupabaseClient
): Promise<VatCloseCheckResult> {
// 1) VAT report (validates inputs + gives us figures + period dates)
const vatReport = await computeVatReport(args, companyId, supabase)
const { start, end, type: periodType, year, period } = vatReport.period
// 2) Company settings — moms_period drives deadline labelling
const { data: settings } = await supabase
.from('company_settings')
.select('moms_period')
.eq('company_id', companyId)
.single()
const momsPeriod = (settings?.moms_period as 'monthly' | 'quarterly' | 'yearly' | null) ?? null
// 3) Deadline — based on the *requested* period type, not company setting,
// so the model gets the right deadline even when querying ad-hoc periods.
const deadline = computeMomsDeadline(
periodType as 'monthly' | 'quarterly' | 'yearly',
Number(year),
Number(period)
)
// 4) Blocker scans — run in parallel
const [uncategorizedRes, unapprovedRes, reconRes, missingReceiptsRes] = await Promise.all([
supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.gte('date', start).lte('date', end)
.is('journal_entry_id', null),
supabase
.from('supplier_invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'registered')
.gte('invoice_date', start).lte('invoice_date', end),
getReconciliationStatus(supabase, companyId, start, end),
// Missing receipts: posted journal entries in period whose gross amount
// (sum of debits, equal to sum of credits in a balanced entry) is ≥
// 4 000 SEK and that have no document_attachments. Scoped to entries
// originating from bank transactions / supplier invoices / receipts
// (where a receipt is legally expected) — skips invoice-payment
// entries, year-end entries, etc.
//
// The 4 000 SEK threshold from ML 17 kap 26–28 § (förenklad faktura) is
// expressed inclusive of moms, so we deliberately compare against the
// gross. Sum-of-debits equals the gross for ordinary purchase entries
// (expense + ingående moms + AP/bank). For EU acquisitions and domestic
// reverse-charge buyer entries the calculated VAT lines inflate the
// sum, which can pull a sub-threshold purchase above 4 000 — that's a
// false positive in favour of asking the user for the receipt, which
// is the safe direction.
(async () => {
const { data: candidates } = await supabase
.from('journal_entries')
.select(
'id, source_type, document_attachments(id), journal_entry_lines(debit_amount)'
)
.eq('company_id', companyId)
.in('source_type', ['bank_transaction', 'supplier_invoice', 'receipt'])
.in('status', ['posted'])
.gte('entry_date', start).lte('entry_date', end)
const missing = (candidates ?? []).filter((e) => {
const lines = (e.journal_entry_lines ?? []) as { debit_amount: number | string }[]
const gross = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
const docs = e.document_attachments as unknown[] | null
return gross >= 4000 && (!docs || docs.length === 0)
})
return missing.length
})(),
])
const blockers: VatCloseBlocker[] = []
const uncategorizedCount = uncategorizedRes.count ?? 0
if (uncategorizedCount > 0) {
blockers.push({
kind: 'uncategorized_transactions',
severity: 'high',
count: uncategorizedCount,
message: `${uncategorizedCount} okategoriserade banktransaktioner i perioden`,
hint: 'Kategorisera via gnubok_categorize_transaction eller kör gnubok_auto_match_period.',
})
}
const unapprovedCount = unapprovedRes.count ?? 0
if (unapprovedCount > 0) {
blockers.push({
kind: 'unapproved_supplier_invoices',
severity: 'high',
count: unapprovedCount,
message: `${unapprovedCount} oattesterade leverantörsfakturor i perioden`,
hint: 'Attestera via gnubok_approve_supplier_invoice — ingående moms (ruta 48) påverkas.',
})
}
if (!reconRes.is_reconciled) {
blockers.push({
kind: 'bank_unreconciled',
severity: Math.abs(reconRes.difference) > 100 ? 'high' : 'medium',
count: reconRes.unmatched_transaction_count + reconRes.unmatched_gl_line_count,
message: `Bankavstämning visar differens ${reconRes.difference.toFixed(2)} kr (${reconRes.unmatched_transaction_count} omatchade banktransaktioner, ${reconRes.unmatched_gl_line_count} omatchade huvudbokslinjer på 1930)`,
hint: 'Granska via gnubok_get_reconciliation_status och matcha — moms beräknas från huvudboken så differenser döljer fel.',
})
}
const missingReceipts = missingReceiptsRes
if (missingReceipts > 0) {
blockers.push({
kind: 'missing_high_value_receipts',
severity: 'medium',
count: missingReceipts,
message: `${missingReceipts} bokföringsposter över 4 000 kr saknar bifogat verifikat`,
hint: 'BFL 5 kap 6§: varje affärshändelse måste ha verifikat. Använd gnubok_list_unmatched_documents för att para ihop.',
})
}
// Reverse-charge / import sanity: rutor 30/31/32 are the buyer's calculated
// utgående moms on reverse-charge purchases (domestic byggtjänster &
// electronics → 2614 → ruta 30; EU acquisitions of goods → 2624 → ruta 31;
// EU services → 2634 → ruta 32). Rutor 60/61/62 are the importer's
// calculated utgående moms on non-EU imports declared via momsdeklaration
// (since 2015 — 2615/2625/2635). All five carry a corresponding ingående
// moms entry that lands in ruta 48 (2645 utlandet RC, 2647 domestic RC).
// If any of these output rutor are > 0 but ruta 48 is 0, the buyer/importer
// booked the output side but forgot the deductible input — ML 2023:200.
const acquisitionAndImportBase =
vatReport.rutor.ruta30 +
vatReport.rutor.ruta31 +
vatReport.rutor.ruta32 +
vatReport.rutor.ruta60 +
vatReport.rutor.ruta61 +
vatReport.rutor.ruta62
if (acquisitionAndImportBase > 0 && vatReport.rutor.ruta48 === 0) {
blockers.push({
kind: 'reverse_charge_input_missing',
severity: 'high',
count: 1,
message:
'Omvänd skattskyldighet eller import: utgående moms bokförd (ruta 30/31/32 eller 60/61/62) men ingen ingående moms (ruta 48)',
hint: 'ML 2023:200: både beräknad utgående moms och avdragsgill ingående moms ska bokföras (2645 utlandet, 2647 inhemskt).',
})
}
// 5) Sanity ratios — current period output VAT to revenue per rate, vs prior period
const ratios = {
output_vat_ratio_25: vatReport.rutor.ruta05 > 0
? Math.round((vatReport.rutor.ruta10 / vatReport.rutor.ruta05) * 10000) / 100
: 0,
output_vat_ratio_12: 0, // no per-rate revenue split available from VAT report
output_vat_ratio_6: 0,
previous_period_compared: false,
}
const anomalies: VatCloseSanityAnomaly[] = []
// Compare to previous same-length period
const prevArgs = previousPeriodArgs(periodType as 'monthly' | 'quarterly' | 'yearly', Number(year), Number(period))
if (prevArgs) {
try {
const prev = await computeVatReport(prevArgs, companyId, supabase)
ratios.previous_period_compared = true
// Output VAT ratio 25% drift
if (vatReport.rutor.ruta05 > 0 && prev.rutor.ruta05 > 0) {
const cur = vatReport.rutor.ruta10 / vatReport.rutor.ruta05
const prv = prev.rutor.ruta10 / prev.rutor.ruta05
if (prv > 0) {
const deltaPct = Math.round(((cur - prv) / prv) * 10000) / 100
if (Math.abs(deltaPct) > 20) {
anomalies.push({
kind: 'output_vat_ratio_drift',
rate: '25',
current: Math.round(cur * 10000) / 100,
previous: Math.round(prv * 10000) / 100,
delta_pct: deltaPct,
message: `Utgående moms 25% / försäljning ändrades ${deltaPct > 0 ? '+' : ''}${deltaPct}% jämfört med föregående period — kontrollera momssatser`,
})
}
}
}
// Revenue spike/drop
if (prev.rutor.ruta05 > 0) {
const revDelta = Math.round(((vatReport.rutor.ruta05 - prev.rutor.ruta05) / prev.rutor.ruta05) * 10000) / 100
if (revDelta < -50) {
anomalies.push({
kind: 'revenue_drop',
current: vatReport.rutor.ruta05,
previous: prev.rutor.ruta05,
delta_pct: revDelta,
message: `Försäljning föll ${revDelta}% — bekräfta att alla fakturor är bokförda`,
})
} else if (revDelta > 200) {
anomalies.push({
kind: 'revenue_spike',
current: vatReport.rutor.ruta05,
previous: prev.rutor.ruta05,
delta_pct: revDelta,
message: `Försäljning steg ${revDelta}% — kontrollera att inget bokats två gånger`,
})
}
}
} catch {
// Previous period unavailable — skip comparison silently
}
}
const highBlockers = blockers.filter((b) => b.severity === 'high').length
const readyToClose = highBlockers === 0
const netDue = vatReport.rutor.ruta49
const direction: 'pay' | 'refund' | 'zero' = netDue > 0 ? 'pay' : netDue < 0 ? 'refund' : 'zero'
let summary: string
if (readyToClose && anomalies.length === 0) {
summary = `Klart för stängning. ${direction === 'pay' ? `Moms att betala: ${netDue.toFixed(2)} kr` : direction === 'refund' ? `Moms att få tillbaka: ${Math.abs(netDue).toFixed(2)} kr` : 'Noll i moms'}.${deadline ? ` Inlämning senast ${deadline.label}.` : ''}`
} else if (readyToClose) {
summary = `Klart för stängning men ${anomalies.length} avvikelse(r) att granska.`
} else {
summary = `Inte klart: ${highBlockers} kritiska blockerare.`
}
return {
period: vatReport.period,
period_label: vatReport.period_label,
rutor: vatReport.rutor,
payment: {
net_due: netDue,
direction,
deadline: deadline?.date ?? null,
deadline_label: deadline?.label ?? null,
moms_period: momsPeriod,
},
blockers,
sanity: { anomalies, ratios },
ready_to_close: readyToClose,
summary,
}
}
function previousPeriodArgs(
periodType: 'monthly' | 'quarterly' | 'yearly',
year: number,
period: number
): { period_type: string; year: number; period: number } | null {
if (periodType === 'monthly') {
if (period === 1) return { period_type: 'monthly', year: year - 1, period: 12 }
return { period_type: 'monthly', year, period: period - 1 }
}
if (periodType === 'quarterly') {
if (period === 1) return { period_type: 'quarterly', year: year - 1, period: 4 }
return { period_type: 'quarterly', year, period: period - 1 }
}
if (periodType === 'yearly') {
return { period_type: 'yearly', year: year - 1, period: 1 }
}
return null
}
// ── Tools ────────────────────────────────────────────────────
export const tools: McpTool[] = [
{
name: 'gnubok_search_tools',
description: 'Search gnubok MCP tools by keyword and return their schemas at a chosen detail level. Call this first when looking for a capability — avoids loading every tool schema upfront.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
query: { type: 'string', description: 'Keywords matched against tool name + description (e.g. "vat", "invoice", "categorize"). Empty string returns all tools.' },
detail: { type: 'string', enum: ['name', 'summary', 'full'], description: 'Detail level. name: just names. summary: name + description + scope (default). full: complete schema including inputSchema and outputSchema.' },
scope: { type: 'string', description: 'Optional filter: only tools requiring this API key scope (e.g. "invoices:write").' },
limit: { type: 'number', description: 'Max results, 1–50 (default 20).' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
tools: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
total_matched: { type: 'number' },
detail: { type: 'string' },
},
required: ['tools', 'count', 'total_matched', 'detail'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, _companyId, _userId, _supabase, _actor) {
const query = ((args.query as string) || '').toLowerCase().trim()
const detail = ((args.detail as string) || 'summary') as 'name' | 'summary' | 'full'
const scopeFilter = args.scope as string | undefined
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
// Filter results to tools the caller is actually authorized to invoke.
//
// The dispatcher injects __keyScopes when it routes to gnubok_search_tools.
// If the marker is missing (refactor regression, direct execute() invocation
// outside the dispatcher, etc.), FAIL CLOSED — return only unscoped tools
// rather than leaking the full inventory. The marker presence is also part
// of the contract: an explicitly-empty array means "no scopes granted",
// which still hides scoped tools.
const rawKeyScopes = (args as Record<string, unknown>).__keyScopes
const callerScopes: string[] = Array.isArray(rawKeyScopes)
? (rawKeyScopes as string[])
: []
const scopesInjected = Array.isArray(rawKeyScopes)
let candidates = tools.filter((t) => {
const required = TOOL_SCOPE_MAP[t.name]
if (required) {
// Scoped tool: visible only if scopes were injected AND the caller has it.
if (!scopesInjected) return false
if (!callerScopes.includes(required)) return false
}
if (scopeFilter && required !== scopeFilter) return false
return true
})
if (query) {
// Match: every whitespace-separated term must appear in name or description
// (for a single-word query this is identical to a literal substring match).
// Rank by relevance so the most on-point tool comes first instead of
// whichever happens to be defined earliest: exact-ish name match > full
// query as a name substring > per-term name hits > description hits. Ties
// fall back to definition order (stable).
const terms = query.split(/\s+/).filter(Boolean)
const ranked = candidates
.map((t, idx) => {
const name = t.name.toLowerCase()
const desc = t.description.toLowerCase()
const hay = `${name} ${desc}`
if (!terms.every((term) => hay.includes(term))) return null
let score = 0
if (name === query || name === `gnubok_${query}` || name.endsWith(`_${query}`)) score += 100
if (name.includes(query)) score += 40
for (const term of terms) {
if (name.includes(term)) score += 10
if (desc.includes(term)) score += 1
}
return { t, score, idx }
})
.filter((x): x is { t: McpTool; score: number; idx: number } => x !== null)
.sort((a, b) => b.score - a.score || a.idx - b.idx)
candidates = ranked.map((x) => x.t)
}
const totalMatched = candidates.length
const sliced = candidates.slice(0, limit)
const projected = sliced.map((t) => {
const requiredScope = TOOL_SCOPE_MAP[t.name] ?? null
if (detail === 'name') return { name: t.name, scope: requiredScope }
if (detail === 'full') {
return {
name: t.name,
description: t.description,
scope: requiredScope,
inputSchema: t.inputSchema,
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
}
}
// summary (default)
return { name: t.name, description: t.description, scope: requiredScope }
})
return {
tools: projected,
count: projected.length,
total_matched: totalMatched,
detail,
}
},
},
{
name: 'gnubok_list_skills',
description: 'List available domain-knowledge skills filtered to this company (entity type, VAT, payroll). Workflow guides + loaded specialty atoms. Pass include_all=true to see hidden skills. Call gnubok_load_skill(slug) for any body.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
tag: { type: 'string', description: 'Optional filter by tag (e.g. "vat", "monthly", "yearly", "payroll", or the tier name "workflow"/"horizontal"/"vertical"/"modifier").' },
tier: {
type: 'string',
enum: ['workflow', 'horizontal', 'vertical', 'modifier'],
description: 'Optional filter by tier. workflow = static guides, horizontal = regulatory atoms (Swedish VAT/payroll/…), vertical = industry atoms (konsult-IT, e-handel…), modifier = cross-cutting atoms (holding-AB…).',
},
include_all: {
type: 'boolean',
description: 'When true, ignore the company-context filter (entity_type, employees, vat_registered) and return all skills. Default false.',
},
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
skills: {
type: 'array',
items: {
type: 'object',
properties: {
slug: { type: 'string' },
name: { type: 'string' },
summary: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
tier: { type: 'string', enum: ['workflow', 'horizontal', 'vertical', 'modifier'] },
},
required: ['slug', 'name', 'summary', 'tier'],
},
},
count: { type: 'number' },
hidden_count: { type: 'number', description: 'Skills hidden by company-context filter. Re-call with include_all=true to see them.' },
company_context: {
type: 'object',
additionalProperties: false,
description: 'Snapshot of the filter inputs used to compute the list — useful when debugging "why isn\'t skill X showing up?".',
properties: {
entity_type: { type: ['string', 'null'] },
has_employees: { type: 'boolean' },
vat_registered: { type: 'boolean' },
},
},
},
required: ['skills', 'count', 'hidden_count', 'company_context'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const tag = (args.tag as string | undefined)?.toLowerCase().trim()
const tier = (args.tier as SkillTier | undefined)
const includeAll = args.include_all === true
// Resolve company context — read once per call. Failures degrade
// gracefully: an unresolved field means "don't filter on it" so a
// misconfigured company still gets the full skill list.
const [settings, employeeCount] = await Promise.all([
supabase
.from('company_settings')
.select('entity_type, vat_registered')
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('employees')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_active', true),
])
const entityType = (settings.data?.entity_type as string | undefined) ?? null
const vatRegistered = Boolean(settings.data?.vat_registered)
const hasEmployees = (employeeCount.count ?? 0) > 0
const all = await loadAllSkills(supabase)
// First pass: tier + tag filter (unchanged).
const tagFiltered = all.filter((s) => {
if (tier && s.tier !== tier) return false
if (tag && !s.tags.some((t) => t.toLowerCase() === tag)) return false
return true
})
// Second pass: applicability filter — skipped when include_all=true so
// agents can always escape to the full list. Skills without an
// applicability declaration are always shown (universal).
const applicable = includeAll
? tagFiltered
: tagFiltered.filter((s) => {
if (!s.applicability) return true
const a = s.applicability
if (a.entity_type && a.entity_type !== 'both' && entityType && entityType !== a.entity_type) return false
if (a.requires?.includes('employees') && !hasEmployees) return false
if (a.requires?.includes('vat_registered') && !vatRegistered) return false
return true
})
return {
skills: applicable.map((s) => ({
slug: s.slug,
name: s.name,
summary: s.summary,
tags: s.tags,
tier: s.tier,
})),
count: applicable.length,
hidden_count: tagFiltered.length - applicable.length,
company_context: {
entity_type: entityType,
has_employees: hasEmployees,
vat_registered: vatRegistered,
},
}
},
},
{
name: 'gnubok_load_skill',
description: 'Load a skill body by slug. Workflow slugs are flat (e.g. "month-end-close"); atom slugs match registry ids (e.g. "vertical/konsult-it", "modifier/holding-ab"). Call gnubok_list_skills to find slugs.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
slug: { type: 'string', description: 'Skill slug — workflow slug ("month-end-close", "quarterly-vat-review", "year-end-close", "invoicing-rules", "payroll-monthly") or atom id ("vertical/konsult-it", "modifier/holding-ab", "horizontal/swedish-vat", …).' },
},
required: ['slug'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
slug: { type: 'string' },
name: { type: 'string' },
summary: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
tier: { type: 'string', enum: ['workflow', 'horizontal', 'vertical', 'modifier'] },
body: { type: 'string', description: 'Full skill content as Markdown' },
},
required: ['slug', 'name', 'body', 'tier'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const slug = (args.slug as string | undefined)?.trim()
if (!slug) throw new Error('slug is required')
const skill = await findSkill(slug, supabase)
if (!skill) {
const all = await loadAllSkills(supabase)
const available = all.map((s) => s.slug).join(', ')
throw new Error(`Skill not found: "${slug}". Available skills: ${available}`)
}
// Workflow-tier skills are the closed-form processes (month-end-close,
// year-end-close, payroll-monthly). Loading one is a strong signal the
// agent is starting that workflow — emit so we can track completion
// rates. Atom skills are reference material and don't trigger this.
if (skill.tier === 'workflow' && actor) {
emitWorkflowStarted({ slug: skill.slug, actor, userId, companyId })
}
return {
slug: skill.slug,
name: skill.name,
summary: skill.summary,
tags: skill.tags,
tier: skill.tier,
body: skill.body,
}
},
},
{
name: 'gnubok_remember_fact',
description: 'Capture a durable fact, preference, pattern, or correction about the company. Use mid-conversation when the user says something the agent should remember next time. Writes immediately — does not stage. Use sparingly for foundational signal, not for one-off context.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
content: {
type: 'string',
description: 'The full fact text in the user\'s language. Self-contained — readable without prior context. Example: "Företaget hyr lagerplats av AB Foo, hyresfaktura kommer 25:e varje månad."',
},
kind: {
type: 'string',
enum: ['fact', 'preference', 'pattern', 'correction'],
description: 'fact = verifiable statement, preference = user-stated choice, pattern = observed regularity, correction = agent learned from a user fix. Default fact.',
},
source_ref: {
type: 'string',
description: 'Optional pointer to where this fact came from (e.g. "conversation:<uuid>:turn-3").',
},
relevance_score: {
type: 'number',
description: 'How important this memory is for future prompts. 0.0–1.0. Default 0.8 for agent-captured facts.',
},
},
required: ['content'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
kind: { type: 'string' },
content: { type: 'string' },
created_at: { type: 'string' },
},
required: ['id', 'kind', 'content', 'created_at'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const content = (args.content as string | undefined)?.trim()
if (!content || content.length < 2) throw new Error('content is required (min 2 chars)')
const kind = (args.kind as string | undefined) ?? 'fact'
if (!['fact', 'preference', 'pattern', 'correction'].includes(kind)) {
throw new Error(`invalid kind: ${kind}`)
}
const rawScore = args.relevance_score
const score =
typeof rawScore === 'number' && rawScore >= 0 && rawScore <= 1 ? rawScore : 0.8
// Dedup guard: the agent re-remembers the same fact constantly (e.g.
// "Vercel = omvänd skattskyldighet" on every Vercel categorization).
// Before inserting, compare against existing active memories by
// word-set Jaccard similarity. A near-duplicate (≥0.82) is treated as
// already-known: we touch its updated_at + nudge relevance instead of
// writing a new row, so agent_memory doesn't fill with paraphrases.
// Bounded to the 300 most-recent active rows — dedup-on-write keeps
// the working set small enough that this stays cheap.
const { data: existing } = await supabase
.from('agent_memory')
.select('id, kind, content, created_at, relevance_score')
.eq('company_id', companyId)
.eq('is_active', true)
.order('created_at', { ascending: false })
.limit(300)
const incomingTokens = tokenizeForDedup(content)
const dupe = (existing ?? []).find(
(m: { content: string }) =>
jaccardSimilarity(incomingTokens, tokenizeForDedup(m.content)) >= 0.82,
) as { id: string; kind: string; content: string; created_at: string; relevance_score: number } | undefined
if (dupe) {
// Already known. Bump relevance toward the new score (max) and
// refresh updated_at so recency-ordered recall still surfaces it.
await supabase
.from('agent_memory')
.update({
relevance_score: Math.max(dupe.relevance_score ?? 0, score),
updated_at: new Date().toISOString(),
})
.eq('id', dupe.id)
return {
id: dupe.id,
kind: dupe.kind,
content: dupe.content,
created_at: dupe.created_at,
}
}
const { data, error } = await supabase
.from('agent_memory')
.insert({
company_id: companyId,
kind,
content,
source: 'agent_learned',
source_ref: (args.source_ref as string | undefined) ?? null,
relevance_score: score,
is_active: true,
created_by_user_id: userId,
})
.select('id, kind, content, created_at')
.single()
if (error) throw new Error(`Failed to remember fact: ${error.message}`)
return data
},
},
{
name: 'gnubok_forget_fact',
description: 'Deactivate a memory entry by id. Use when the user explicitly asks to forget something or supersedes it. The row is kept for audit (is_active=false) but no longer surfaces in prompts.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', description: 'The memory entry id from a prior gnubok_remember_fact call.' },
reason: { type: 'string', description: 'Optional short note about why this is being forgotten (for audit).' },
},
required: ['id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
is_active: { type: 'boolean' },
},
required: ['id', 'is_active'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const id = (args.id as string | undefined)?.trim()
if (!id) throw new Error('id is required')
const { data, error } = await supabase
.from('agent_memory')
.update({ is_active: false })
.eq('id', id)
.eq('company_id', companyId)
.select('id, is_active')
.single()
if (error) throw new Error(`Failed to forget fact: ${error.message}`)
return data
},
},
{
name: 'gnubok_feedback',
description: 'Report agent-side feedback: missing tool, wrong description, skill gap, or a positive signal. Goes to event_log for product-team triage. Rate-limited 1/min/key.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
context: {
type: 'string',
description: 'What you were trying to do and what blocked you — or what worked well. Free text, max 2000 chars.',
},
sentiment: {
type: 'string',
enum: ['positive', 'negative', 'neutral'],
description: 'Direction of the feedback. Default: negative.',
},
suggestion: {
type: 'string',
description: 'Optional concrete suggestion (e.g. "add a tool for X", "rename Y arg").',
},
tool_name: {
type: 'string',
description: 'Optional specific tool the feedback concerns.',
},
skill_slug: {
type: 'string',
description: 'Optional specific skill the feedback concerns.',
},
},
required: ['context'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
recorded: { type: 'boolean' },
message: { type: 'string' },
},
required: ['recorded', 'message'],
},
annotations: {
// Not read-only: this writes a telemetry event to the bus and mutates the
// in-process rate-limit map. readOnlyHint is about side effects, not whether
// business state changes — so it must be false even though no ledger is touched.
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, _supabase, actor) {
const context = (args.context as string | undefined)?.trim()
if (!context) throw new Error('context is required')
if (context.length > 2000) throw new Error('context is too long (max 2000 chars)')
const sentiment = ((args.sentiment as string | undefined) ?? 'negative') as 'positive' | 'negative' | 'neutral'
const suggestion = (args.suggestion as string | undefined)?.trim() || null
const toolName = (args.tool_name as string | undefined)?.trim() || null
const skillSlug = (args.skill_slug as string | undefined)?.trim() || null
// Rate-limit per API key (or per user when no key id). 1 per 60 s.
// In-memory + single-process — leaky bucket would be cleaner but the
// signal here is product-team triage, not security; over-counting is
// fine, occasional under-counting is fine.
const rateKey = actor?.id ?? userId
const now = Date.now()
const last = feedbackRateLimit.get(rateKey)
if (last && now - last < FEEDBACK_RATE_LIMIT_MS) {
const waitSec = Math.ceil((FEEDBACK_RATE_LIMIT_MS - (now - last)) / 1000)
throw new Error(`gnubok_feedback is rate-limited. Try again in ${waitSec}s.`)
}
feedbackRateLimit.set(rateKey, now)
void eventBus
.emit({
type: 'agent.feedback',
payload: {
context,
sentiment,
suggestion,
toolName,
skillSlug,
sessionId: actor?.sessionId ?? null,
actorType: actor?.type ?? 'api_key',
actorId: actor?.id ?? null,
actorLabel: actor?.label ?? null,
userId,
companyId,
},
})
.catch((err) => console.error('[mcp] agent.feedback emit failed:', err))
return {
recorded: true,
message: 'Thanks — feedback queued for product-team review. We aggregate signal weekly.',
}
},
},
{
name: 'gnubok_get_agent_briefing',
description: 'Bootstrap this company\'s specialized accountant context in one call: profile_summary, the atoms loaded for the company (metadata only — call gnubok_load_skill for bodies), and the top-30 active memories. Call once at session start.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
profile_summary: {
type: ['string', 'null'],
description: 'Composer-generated one-paragraph summary of the company. Null if no agent profile exists yet (composer has not run).',
},
atoms: {
type: 'array',
description: 'Atoms (horizontal/vertical/modifier skills) loaded for this company. Metadata only — call gnubok_load_skill(id) for the body.',
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', description: 'Atom id (e.g. "horizontal/swedish-vat", "vertical/konsult-it", "modifier/holding-ab"). Use as gnubok_load_skill slug.' },
tier: { type: 'string', enum: ['horizontal', 'vertical', 'modifier'] },
title: { type: 'string' },
description: { type: 'string' },
},
required: ['id', 'tier', 'title', 'description'],
},
},
memory: {
type: 'array',
description: 'Top-30 active memories (facts, preferences, patterns, corrections) ranked by relevance and recency.',
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
kind: { type: 'string', enum: ['fact', 'preference', 'pattern', 'correction'] },
content: { type: 'string' },
relevance_score: { type: ['number', 'null'] },
},
required: ['id', 'kind', 'content'],
},
},
},
required: ['profile_summary', 'atoms', 'memory'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, _userId, supabase) {
const [profileRes, memoryRes] = await Promise.all([
supabase
.from('agent_profiles')
.select('profile_summary, horizontal_atoms, vertical_atoms, modifier_atoms')
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('agent_memory')
.select('id, kind, content, relevance_score')
.eq('company_id', companyId)
.eq('is_active', true)
.order('relevance_score', { ascending: false, nullsFirst: false })
.order('last_accessed_at', { ascending: false, nullsFirst: false })
.limit(30),
])
if (profileRes.error) throw new Error(`Failed to load agent profile: ${profileRes.error.message}`)
if (memoryRes.error) throw new Error(`Failed to load agent memory: ${memoryRes.error.message}`)
const profile = profileRes.data as
| {
profile_summary: string | null
horizontal_atoms: string[] | null
vertical_atoms: string[] | null
modifier_atoms: string[] | null
}
| null
const memoryRows = (memoryRes.data ?? []) as Array<{
id: string
kind: string
content: string
relevance_score: number | null
}>
const atomIds = [
...(profile?.horizontal_atoms ?? []),
...(profile?.vertical_atoms ?? []),
...(profile?.modifier_atoms ?? []),
]
let atoms: Array<{ id: string; tier: string; title: string; description: string }> = []
if (atomIds.length > 0) {
const { data: atomRows, error: atomErr } = await supabase
.from('agent_atom_registry')
.select('id, tier, title, description')
.in('id', atomIds)
.eq('is_active', true)
if (atomErr) throw new Error(`Failed to load atom metadata: ${atomErr.message}`)
atoms = ((atomRows ?? []) as Array<{
id: string
tier: string
title: string | null
description: string
}>).map((r) => ({
id: r.id,
tier: r.tier,
title: r.title ?? r.id,
description: r.description,
}))
}
return {
profile_summary: profile?.profile_summary ?? null,
atoms,
memory: memoryRows.map((m) => ({
id: m.id,
kind: m.kind,
content: m.content,
relevance_score: m.relevance_score,
})),
}
},
},
{
name: 'gnubok_create_transactions',
description: 'Stage one or more transactions for the user to approve. Each item creates a separate pending operation; commit each via gnubok_approve_pending_operation when the user authorises. Useful for ingesting rows from external sources (Airtable, CSVs, etc.). Max 10 per call.',
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
staged_count: { type: 'number', description: 'Number of items successfully staged.' },
operations: {
type: 'array',
items: STAGED_OPERATION_SCHEMA,
description: 'One staged-operation result per input item, in the same order.',
},
},
required: ['staged_count', 'operations'],
},
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transactions: {
type: 'array',
minItems: 1,
maxItems: 10,
description: 'Up to 10 transactions to stage. Each becomes its own pending operation.',
items: {
type: 'object',
properties: {
date: { type: 'string', description: 'Transaction date (YYYY-MM-DD).' },
amount: { type: 'number', description: 'Positive = income, negative = expense.' },
description: { type: 'string', description: 'Free-text description shown in /transactions.' },
currency: { type: 'string', description: 'ISO 4217 code. Default SEK.' },
bank_connection_id: { type: 'string', description: 'Optional UUID of a bank_connections row to associate with.' },
external_id: { type: 'string', description: 'Optional external reference (e.g., Airtable record ID). Shown in the preview; the DB enforces uniqueness per user, so the second commit of the same external_id will fail at approval.' },
},
required: ['date', 'amount', 'description'],
},
},
},
required: ['transactions'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const items = args.transactions as Array<Record<string, unknown>> | undefined
if (!Array.isArray(items) || items.length === 0) {
throw new Error('transactions must be a non-empty array.')
}
if (items.length > 10) {
throw new Error('transactions exceeds the per-call limit of 10. Split into multiple calls.')
}
const operations = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
const date = item.date as string
const amount = Number(item.amount)
const description = ((item.description as string) ?? '').trim()
const currency = ((item.currency as string) || 'SEK').toUpperCase()
const bankConnectionId = (item.bank_connection_id as string) || null
const externalId = (item.external_id as string) || null
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error(`transactions[${i}].date must be in YYYY-MM-DD format.`)
}
if (!Number.isFinite(amount)) {
throw new Error(`transactions[${i}].amount must be a finite number.`)
}
if (!description) {
throw new Error(`transactions[${i}].description is required.`)
}
const params = {
date,
amount,
description,
currency,
bank_connection_id: bankConnectionId,
external_id: externalId,
}
const sign = amount >= 0 ? '+' : ''
const titleSuffix = externalId ? ` [${externalId}]` : ''
const title = `Ny transaktion: ${description} ${sign}${amount} ${currency}${titleSuffix}`
const staged = await stagePendingOperation(
supabase, companyId, userId, 'create_transaction',
title,
params,
params, // params ARE the preview
actor,
{
description: 'Once approved, the transaction lands in /transactions as uncategorized. Use gnubok_categorize_transaction to book it.',
tool: 'gnubok_categorize_transaction',
},
{ dateForPeriodCheck: date },
)
operations.push(staged)
}
return {
staged_count: operations.length,
operations,
}
},
},
{
name: 'gnubok_list_uncategorized_transactions',
description: 'List bank transactions with no journal entry yet, newest first. Paginated.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
limit: { type: 'number', description: 'Max results to return, 1–100 (default 20)' },
offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' },
},
},
outputSchema: paginatedSchema('transactions', {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
date: { type: 'string' },
description: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
merchant_name: { type: 'string' },
reference: { type: 'string' },
is_business: { type: 'boolean' },
category: { type: 'string' },
},
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100)
const offset = Math.max(0, Number(args.offset) || 0)
// Get total count
const { count: totalCount, error: countError } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('journal_entry_id', null)
if (countError) throw new Error(`Database error: ${countError.message}`)
const { data, error } = await supabase
.from('transactions')
.select(
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.order('date', { ascending: false })
.range(offset, offset + limit - 1)
if (error) throw new Error(`Database error: ${error.message}`)
const total = totalCount ?? 0
const hasMore = total > offset + (data?.length ?? 0)
return {
transactions: data,
count: data?.length ?? 0,
total_count: total,
has_more: hasMore,
...(hasMore ? { next_offset: offset + (data?.length ?? 0) } : {}),
}
},
},
{
name: 'gnubok_list_transactions_without_documents',
description: 'List BANK TRANSACTIONS booked without an attached underlag. For imported/manual verifikat (no bank tx row) call gnubok_list_verifikat_without_documents — this tool only covers bank-driven entries.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
limit: { type: 'number', description: 'Max results to return, 1–100 (default 20)' },
offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' },
since: { type: 'string', description: 'Optional ISO date (YYYY-MM-DD). Only return transactions on or after this date.' },
},
},
outputSchema: paginatedSchema('transactions', {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string' },
date: { type: 'string' },
description: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
merchant_name: { type: 'string' },
reference: { type: 'string' },
is_business: { type: 'boolean' },
category: { type: 'string' },
journal_entry_id: { type: 'string' },
},
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100)
const offset = Math.max(0, Number(args.offset) || 0)
const since = typeof args.since === 'string' ? args.since : null
let countQuery = supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.is('document_id', null)
if (since) countQuery = countQuery.gte('date', since)
const { count: totalCount, error: countError } = await countQuery
if (countError) throw new Error(`Database error: ${countError.message}`)
let dataQuery = supabase
.from('transactions')
.select(
'id, date, description, amount, currency, merchant_name, reference, is_business, category, journal_entry_id'
)
.eq('company_id', companyId)
.not('journal_entry_id', 'is', null)
.is('document_id', null)
if (since) dataQuery = dataQuery.gte('date', since)
const { data, error } = await dataQuery
.order('date', { ascending: false })
.range(offset, offset + limit - 1)
if (error) throw new Error(`Database error: ${error.message}`)
const total = totalCount ?? 0
const hasMore = total > offset + (data?.length ?? 0)
return {
transactions: data,
count: data?.length ?? 0,
total_count: total,
has_more: hasMore,
...(hasMore ? { next_offset: offset + (data?.length ?? 0) } : {}),
}
},
},
{
name: 'gnubok_list_verifikat_without_documents',
description: 'List POSTED journal entries (verifikat) that have no document_attachments row. Covers SIE-imported, manual and salary vouchers that the transactions-based tool misses. Newest first, paginated.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
limit: { type: 'number', description: 'Max results to return, 1–100 (default 20)' },
offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' },
since: { type: 'string', description: 'Optional ISO date (YYYY-MM-DD). Only return entries on or after this date.' },
min_amount: { type: 'number', description: 'Optional minimum gross amount (sum of debits) to filter low-value entries. Default 0.' },
},
},
outputSchema: paginatedSchema('verifikat', {
type: 'object',
additionalProperties: false,
properties: {
journal_entry_id: { type: 'string' },
voucher_series: { type: 'string' },
voucher_number: { type: 'number' },
entry_date: { type: 'string' },
description: { type: 'string' },
source_type: { type: 'string' },
gross_amount: { type: 'number' },
},
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100)
const offset = Math.max(0, Number(args.offset) || 0)
const since = typeof args.since === 'string' ? args.since : null
const minAmount = typeof args.min_amount === 'number' && Number.isFinite(args.min_amount)
? args.min_amount
: 0
// PostgREST left-join filter: journal_entries left-joined to
// document_attachments and filtered to rows where the join produced
// no document. The filter syntax `document_attachments.id=is.null`
// applies the predicate post-join (Supabase: foreign-table is-null).
let query = supabase
.from('journal_entries')
.select(
'id, voucher_series, voucher_number, entry_date, description, source_type, document_attachments!left(id), journal_entry_lines(debit_amount)',
{ count: 'exact' },
)
.eq('company_id', companyId)
.eq('status', 'posted')
.is('document_attachments.id', null)
if (since) query = query.gte('entry_date', since)
const { data, error, count } = await query
.order('entry_date', { ascending: false })
.order('voucher_number', { ascending: false })
.range(offset, offset + limit - 1)
if (error) throw new Error(`Database error: ${error.message}`)
const rows = ((data ?? []) as Array<{
id: string
voucher_series: string
voucher_number: number
entry_date: string
description: string
source_type: string
journal_entry_lines: { debit_amount: number | string }[] | null
}>).map((e) => {
const lines = e.journal_entry_lines ?? []
const gross = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
return {
journal_entry_id: e.id,
voucher_series: e.voucher_series,
voucher_number: e.voucher_number,
entry_date: e.entry_date,
description: e.description,
source_type: e.source_type,
gross_amount: Math.round(gross * 100) / 100,
}
})
const filtered = minAmount > 0 ? rows.filter((r) => r.gross_amount >= minAmount) : rows
const total = count ?? 0
const hasMore = total > offset + filtered.length
return {
verifikat: filtered,
count: filtered.length,
total_count: total,
has_more: hasMore,
...(hasMore ? { next_offset: offset + filtered.length } : {}),
}
},
},
{
name: 'gnubok_categorize_transaction',
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation when the user authorises. If the row has an attached underlag, the tool reads its extracted_data and rejects vat_treatment="reverse_charge" when the seller already charged VAT.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transaction_id: { type: 'string', description: 'UUID of the transaction to categorize' },
category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] },
vat_treatment: { type: 'string', description: 'VAT treatment override. Defaults to standard_25 for business expenses. Set reverse_charge ONLY when the underlag confirms the seller did NOT charge VAT (omvänd skattskyldighet). An invoice with foreign VAT already debited is NOT reverse charge.', enum: [...VALID_VAT_TREATMENTS] },
notes: { type: 'string', description: 'Audit-trail context appended to the verifikation description. For category=representation use this to record deltagare + syfte ("Anna Andersson (Acme AB), kundmöte om Y"). For project work, include the project ref. Keep under 200 chars; pure metadata, not a re-description of the transaction.' },
},
required: ['transaction_id', 'category'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
// Compute the preview (accounts, amounts, VAT lines)
const result = await categorizeTransactionCore(
args.transaction_id as string,
args.category as TransactionCategory,
args.vat_treatment as VatTreatment | undefined,
userId,
companyId,
supabase,
false // preview mode — execution happens at approval time via gnubok_approve_pending_operation
)
// If already has a journal entry, pass through as-is
if (result.success && result.journal_entry_created === false) {
const { transaction: _tx, ...publicResult } = result
return publicResult
}
// Fetch transaction description (and date for period_status) for the title
const { data: tx } = await supabase
.from('transactions')
.select('description, merchant_name, amount, currency, date')
.eq('id', args.transaction_id as string)
.eq('company_id', companyId)
.single()
const txDesc = tx
? `${tx.merchant_name || tx.description || 'Transaktion'} ${tx.amount} ${tx.currency}`
: String(args.transaction_id)
// Stage for user approval
return stagePendingOperation(supabase, companyId, userId, 'categorize_transaction',
`Kategorisera: ${txDesc}`,
{
transaction_id: args.transaction_id,
category: args.category,
vat_treatment: args.vat_treatment || null,
notes: typeof args.notes === 'string' && args.notes.trim().length > 0
? (args.notes as string).trim()
: null,
},
{
debit_account: result.debit_account,
credit_account: result.credit_account,
amount: result.amount,
currency: result.currency,
vat_lines: result.vat_lines || [],
category: result.category,
underlag: result.underlag ?? null,
},
actor,
{
description: 'Once approved, the journal entry is posted. Continue with gnubok_list_uncategorized_transactions to keep clearing the backlog, or lock the period once it is empty.',
tool: 'gnubok_list_uncategorized_transactions',
},
tx?.date ? { dateForPeriodCheck: tx.date } : {},
)
},
},
// ── Receipt matcher tool ──────────────────────────────────────
{
name: 'gnubok_receipt_matcher',
description: 'Open an interactive widget for drag-and-drop receipt-to-transaction matching. Renders inline in compatible clients.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
limit: { type: 'number', description: 'Max transactions to show, 1–50 (default 20)' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transactions: { type: 'array', items: { type: 'object' } },
categories: { type: 'array', items: { type: 'string' } },
vat_treatments: { type: 'array', items: { type: 'string' } },
},
required: ['transactions', 'categories', 'vat_treatments'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
_meta: { ui: { resourceUri: 'ui://receipt-matcher/app.html' } },
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
const { data, error } = await supabase
.from('transactions')
.select(
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.order('date', { ascending: false })
.limit(limit)
if (error) throw new Error(`Database error: ${error.message}`)
return {
transactions: data ?? [],
categories: [...VALID_CATEGORIES],
vat_treatments: [...VALID_VAT_TREATMENTS],
}
},
},
// ── Customer tools ───────────────────────────────────────────
{
name: 'gnubok_list_customers',
description: 'List all customers for the active company. Use to look up customer_id for invoice creation.',
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
customers: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
},
required: ['customers', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, userId, supabase) {
const { data, error } = await supabase
.from('customers')
.select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country')
.eq('company_id', companyId)
.order('name')
if (error) throw new Error(`Database error: ${error.message}`)
return { customers: data, count: data?.length ?? 0 }
},
},
{
name: 'gnubok_create_customer',
description: 'Stage a new customer. Stages for user approval — NOT created until approved in the web app. EU VAT numbers trigger VIES validation.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', description: 'Customer name' },
customer_type: {
type: 'string',
enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'],
description: 'Customer type',
},
email: { type: 'string', description: 'Email address' },
org_number: { type: 'string', description: 'Swedish org number' },
vat_number: { type: 'string', description: 'EU VAT number' },
payment_terms: { type: 'number', description: 'Payment terms in days (default 30)' },
address: { type: 'string', description: 'Street address' },
postal_code: { type: 'string' },
city: { type: 'string' },
country: { type: 'string', description: 'Country (default Sweden)' },
dry_run: {
type: 'boolean',
description: 'If true, validate inputs and return the would-be preview without staging or creating. No DB writes, no side-effects.',
},
idempotency_key: {
type: 'string',
description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.',
},
},
required: ['name', 'customer_type'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true, // safe to retry with idempotency_key
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const name = args.name as string
const customerType = args.customer_type as string
if (!name?.trim()) throw new Error('Customer name is required.')
if (!['individual', 'swedish_business', 'eu_business', 'non_eu_business'].includes(customerType)) {
throw new Error('Invalid customer_type. Must be: individual, swedish_business, eu_business, non_eu_business')
}
const params = {
name: name.trim(),
customer_type: customerType,
email: (args.email as string) || null,
org_number: (args.org_number as string) || null,
vat_number: (args.vat_number as string) || null,
payment_terms: Number(args.payment_terms) || 30,
address: (args.address as string) || null,
postal_code: (args.postal_code as string) || null,
city: (args.city as string) || null,
country: (args.country as string) || 'Sweden',
}
return stagePendingOperation(supabase, companyId, userId, 'create_customer',
`Ny kund: ${params.name}`,
params,
params, // params ARE the preview for customers
actor,
{
description: 'Once approved, you can invoice this customer with gnubok_create_invoice using the returned customer_id.',
tool: 'gnubok_create_invoice',
},
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
}
)
},
},
// ── Invoice tools ────────────────────────────────────────────
{
name: 'gnubok_list_invoices',
description: 'List invoices for the active company, newest first. Optional status filter.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: {
type: 'string',
enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited'],
description: 'Filter by invoice status',
},
limit: { type: 'number', description: 'Max results (default 50, max 100)' },
},
},
outputSchema: paginatedSchema('invoices', { type: 'object' }),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
const status = args.status as string | undefined
let query = supabase
.from('invoices')
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, customers(name)', { count: 'exact' })
.eq('company_id', companyId)
if (status) {
query = query.eq('status', status)
}
const { data, error, count } = await query
.order('invoice_date', { ascending: false })
.limit(limit)
if (error) throw new Error(`Database error: ${error.message}`)
const invoices = (data ?? []).map((inv: Record<string, unknown>) => ({
id: inv.id,
invoice_number: inv.invoice_number,
status: inv.status,
customer_name: (inv.customers as Record<string, unknown>)?.name ?? null,
total: inv.total,
currency: inv.currency,
invoice_date: inv.invoice_date,
due_date: inv.due_date,
document_type: inv.document_type,
}))
return {
invoices,
count: invoices.length,
total_count: count ?? invoices.length,
}
},
},
{
name: 'gnubok_create_invoice',
description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Stages for user approval — invoice number assigned at approval.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
customer_id: { type: 'string', description: 'Customer UUID' },
items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string' },
quantity: { type: 'number' },
unit: { type: 'string', description: 'st, tim, dag, mån' },
unit_price: { type: 'number', description: 'Price per unit excl. VAT' },
vat_rate: { type: 'number', description: 'VAT rate 0–100 (optional override)' },
},
required: ['description', 'quantity', 'unit', 'unit_price'],
},
description: 'Invoice line items',
},
invoice_date: { type: 'string', description: 'YYYY-MM-DD (default today)' },
due_date: { type: 'string', description: 'YYYY-MM-DD (default from payment terms)' },
currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] },
our_reference: { type: 'string' },
your_reference: { type: 'string' },
notes: { type: 'string' },
},
required: ['customer_id', 'items'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const customerId = args.customer_id as string
const items = args.items as Array<{
description: string
quantity: number
unit: string
unit_price: number
vat_rate?: number
}>
if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.')
if (!items?.length) throw new Error('At least one item is required.')
for (const [i, item] of items.entries()) {
if (!item.description?.trim()) throw new Error(`Item ${i + 1}: description is required`)
if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${i + 1}: quantity must be positive`)
if (!item.unit?.trim()) throw new Error(`Item ${i + 1}: unit is required (st, tim, dag)`)
if (item.unit_price == null) throw new Error(`Item ${i + 1}: unit_price is required`)
}
const today = new Date().toISOString().split('T')[0]
const currency = ((args.currency as string) || 'SEK') as Currency
const invoiceDate = (args.invoice_date as string) || today
// Fetch customer (full row for VAT rules)
const { data: customer, error: custError } = await supabase
.from('customers')
.select('*')
.eq('id', customerId)
.eq('company_id', companyId)
.single()
if (custError || !customer) {
throw new Error('Customer not found. Use gnubok_list_customers to find valid IDs.')
}
// VAT rules from customer type (same logic as web UI)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
// Calculate per-item VAT
const subtotal = items.reduce((s, item) => s + item.quantity * item.unit_price, 0)
let vatAmount = 0
for (const item of items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
throw new Error(
`VAT rate ${itemRate}% is not allowed for customer type "${customer.customer_type}". ` +
`Allowed rates: ${availableRates.map((r) => r.rate + '%').join(', ')}`
)
}
const lineTotal = item.quantity * item.unit_price
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
const total = subtotal + vatAmount
// Due date from payment terms if not provided
let dueDate = args.due_date as string | undefined
if (!dueDate) {
const d = new Date(invoiceDate)
d.setDate(d.getDate() + (customer.default_payment_terms || 30))
dueDate = d.toISOString().split('T')[0]
}
// Stage for user approval instead of creating directly
return stagePendingOperation(supabase, companyId, userId, 'create_invoice',
`Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`,
{
customer_id: customerId,
items,
invoice_date: invoiceDate,
due_date: dueDate,
currency,
our_reference: (args.our_reference as string) || null,
your_reference: (args.your_reference as string) || null,
notes: (args.notes as string) || null,
},
{
customer_name: customer.name,
customer_type: customer.customer_type,
items: items.map(item => ({
...item,
line_total: item.quantity * item.unit_price,
vat_rate: item.vat_rate ?? vatRules.rate,
})),
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round(total * 100) / 100,
currency,
vat_treatment: vatRules.treatment,
invoice_date: invoiceDate,
due_date: dueDate,
},
actor,
{
description: 'Once approved, the invoice is created as a draft. Send it with gnubok_send_invoice or use gnubok_mark_invoice_as_sent if delivered outside the system.',
tool: 'gnubok_send_invoice',
}
)
},
},
// ── Report tools ─────────────────────────────────────────────
{
name: 'gnubok_get_trial_balance',
description: 'Trial balance (huvudbok) for a fiscal period — all account balances with debit/credit totals. Defaults to most recent period.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
rows: { type: 'array', items: { type: 'object' } },
total_debit: { type: 'number' },
total_credit: { type: 'number' },
is_balanced: { type: 'boolean' },
period_name: { type: 'string' },
period_start: { type: 'string' },
period_end: { type: 'string' },
account_count: { type: 'number' },
},
required: ['rows', 'total_debit', 'total_credit', 'is_balanced'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
// If no period specified, find the most recent one
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id, name')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (!periods) {
throw new Error('No fiscal periods found. Categorize some transactions first to auto-create a period.')
}
periodId = periods.id
}
// Get period info
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found.')
// Aggregate journal entry lines
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(status, user_id, fiscal_period_id)')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
if (error) throw new Error(`Database error: ${error.message}`)
// Get account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('company_id', companyId)
const accountMap = new Map((accounts ?? []).map((a: { account_number: string; account_name: string }) => [a.account_number, a.account_name]))
// Aggregate by account
const totals = new Map<string, { debit: number; credit: number }>()
for (const line of lines ?? []) {
const acc = line.account_number
const existing = totals.get(acc) ?? { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
totals.set(acc, existing)
}
const rows = Array.from(totals.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([accNum, t]) => {
const net = Math.round((t.debit - t.credit) * 100) / 100
return {
account_number: accNum,
account_name: accountMap.get(accNum) ?? accNum,
period_debit: Math.round(t.debit * 100) / 100,
period_credit: Math.round(t.credit * 100) / 100,
closing_debit: net > 0 ? net : 0,
closing_credit: net < 0 ? Math.abs(net) : 0,
}
})
const totalDebit = Math.round(rows.reduce((s, r) => s + r.closing_debit, 0) * 100) / 100
const totalCredit = Math.round(rows.reduce((s, r) => s + r.closing_credit, 0) * 100) / 100
return {
rows,
total_debit: totalDebit,
total_credit: totalCredit,
is_balanced: Math.abs(totalDebit - totalCredit) < 0.01,
period_name: period.name,
period_start: period.period_start,
period_end: period.period_end,
account_count: rows.length,
}
},
},
{
name: 'gnubok_get_vat_report',
description: 'VAT declaration (momsdeklaration, SKV 4700) for a period. Returns all rutor; ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to also open the review widget (claude.ai / Desktop).',
outputSchema: VAT_REPORT_OUTPUT_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_type: {
type: 'string',
enum: ['monthly', 'quarterly', 'yearly'],
description: 'Period type',
},
year: { type: 'number', description: 'Year (e.g. 2025)' },
period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' },
render_ui: {
type: 'boolean',
description: 'When true, also render the interactive momsdeklaration review widget (claude.ai / Claude Desktop). The structured rutor are returned either way. Default false.',
},
},
required: ['period_type', 'year', 'period'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
// Renders the VAT widget only when the caller passes render_ui=true (the
// dispatcher emits result-level _meta in that case). This is the merged
// report+widget surface; gnubok_vat_review_widget remains as an alias.
uiResourceUri: 'ui://vat-review/app.html',
async execute(args, companyId, _userId, supabase) {
return computeVatReport(args, companyId, supabase)
},
},
{
name: 'gnubok_vat_review_widget',
description: 'Open the interactive VAT review widget for a period. Equivalent to gnubok_get_vat_report(render_ui=true); kept as an alias for clients pinned to this tool name.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' },
year: { type: 'number', description: 'Year (e.g. 2025)' },
period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' },
},
required: ['period_type', 'year', 'period'],
},
outputSchema: VAT_REPORT_OUTPUT_SCHEMA,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
_meta: { ui: { resourceUri: 'ui://vat-review/app.html' } },
async execute(args, companyId, _userId, supabase) {
return computeVatReport(args, companyId, supabase)
},
},
{
name: 'gnubok_vat_close_check',
description: "Answer 'can I close VAT?' in one call. Returns SKV 4700 rutor + blocker scan (uncategorized, unapproved supplier invoices, reconciliation diff, missing receipts ≥ 4000 kr, reverse-charge mirroring) + period sanity ratios + Skatteverket deadline + ready_to_close.",
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' },
year: { type: 'number', description: 'Year (e.g. 2026)' },
period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' },
},
required: ['period_type', 'year', 'period'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period: { type: 'object' },
period_label: { type: 'string' },
rutor: { type: 'object' },
payment: {
type: 'object',
properties: {
net_due: { type: 'number' },
direction: { type: 'string', enum: ['pay', 'refund', 'zero'] },
deadline: { type: ['string', 'null'] },
deadline_label: { type: ['string', 'null'] },
moms_period: { type: ['string', 'null'] },
},
},
blockers: { type: 'array', items: { type: 'object' } },
sanity: { type: 'object' },
ready_to_close: { type: 'boolean' },
summary: { type: 'string' },
},
required: ['period', 'rutor', 'payment', 'blockers', 'sanity', 'ready_to_close', 'summary'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
return computeVatCloseCheck(args, companyId, supabase)
},
},
// ── KPI & Income Statement tools ─────────────────────────────
{
name: 'gnubok_get_kpi_report',
description: 'Business KPIs for a fiscal period: gross margin, net result, cash position, receivables, expense ratio, payment days, VAT liability, monthly trend.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
},
},
outputSchema: { type: 'object' },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (!periods) {
throw new Error('No fiscal periods found. Categorize some transactions first.')
}
periodId = periods.id
}
// Verify period belongs to user
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found.')
// Run queries in parallel (same as the KPI API route)
const [incomeStatement, trialBalance, arLedger, monthlyBreakdown, paidInvoices] =
await Promise.all([
generateIncomeStatement(supabase, companyId, periodId!),
generateTrialBalance(supabase, companyId, periodId!),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId!),
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
])
const grossMargin = calculateGrossMargin(incomeStatement)
const cashPosition = calculateCashPosition(trialBalance.rows)
const expenseRatio = calculateExpenseRatio(incomeStatement)
const avgPaymentDays = calculateAvgPaymentDays(
(paidInvoices.data ?? []) as { invoice_date: string; paid_at: string }[]
)
// AR ledger uses entries, each with invoices that have outstanding amounts
const outstandingReceivables = arLedger.total_outstanding
const overdueReceivables = arLedger.total_overdue
// VAT liability from trial balance
const getClosing = (accNum: string) => {
const row = trialBalance.rows.find((r) => r.account_number === accNum)
if (!row) return 0
return row.closing_credit - row.closing_debit
}
const vatLiability = Math.round(
(getClosing('2611') + getClosing('2621') + getClosing('2631') -
getClosing('2641') - getClosing('2645')) * 100
) / 100
return {
period_name: period.name,
period_start: period.period_start,
period_end: period.period_end,
gross_margin: grossMargin,
net_result: incomeStatement.net_result,
cash_position: cashPosition,
outstanding_receivables: Math.round(outstandingReceivables * 100) / 100,
overdue_receivables: Math.round(overdueReceivables * 100) / 100,
expense_ratio: expenseRatio,
avg_payment_days: avgPaymentDays,
paid_invoice_count: paidInvoices.data?.length ?? 0,
vat_liability: vatLiability,
total_revenue: incomeStatement.total_revenue,
total_expenses: incomeStatement.total_expenses,
months: monthlyBreakdown.months,
}
},
},
{
name: 'gnubok_get_income_statement',
description: 'Income statement (resultaträkning) for a fiscal period: revenue, expenses, net result by account category.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
},
},
outputSchema: { type: 'object' },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (!periods) {
throw new Error('No fiscal periods found. Categorize some transactions first.')
}
periodId = periods.id
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found.')
const result = await generateIncomeStatement(supabase, companyId, periodId!)
result.period = { start: period.period_start, end: period.period_end }
return {
period_name: period.name,
...result,
}
},
},
// ── Invoice Operations ───────────────────────────────────────
{
name: 'gnubok_mark_invoice_as_paid',
description: 'Mark an invoice as paid and create the payment journal entry. Stages for approval. Status must be sent or overdue.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
invoice_id: { type: 'string', description: 'UUID of the invoice' },
payment_date: { type: 'string', description: 'Payment date YYYY-MM-DD (default: today)' },
},
required: ['invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const invoiceId = args.invoice_id as string
if (!invoiceId) throw new Error('invoice_id is required')
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) throw new Error('Invoice not found')
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
throw new Error('Invoice can only be marked as paid when status is "sent" or "overdue"')
}
const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0]
return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_paid',
`Betald: ${invoice.invoice_number} ${invoice.customer?.name || ''} ${invoice.total} ${invoice.currency}`,
{ invoice_id: invoiceId, payment_date: paymentDate },
{
invoice_number: invoice.invoice_number,
customer_name: invoice.customer?.name,
total: invoice.total,
currency: invoice.currency,
payment_date: paymentDate,
},
actor,
{
description: 'Once approved, the payment is booked (15xx → 19xx). Use gnubok_get_ar_ledger to confirm the customer balance reflects it.',
tool: 'gnubok_get_ar_ledger',
},
{ dateForPeriodCheck: paymentDate },
)
},
},
{
name: 'gnubok_send_invoice',
description: 'Send invoice via email with PDF attachment. Stages for approval. Requires customer email + email service configured.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
invoice_id: { type: 'string', description: 'UUID of the invoice to send' },
},
required: ['invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
async execute(args, companyId, userId, supabase, actor) {
const invoiceId = args.invoice_id as string
if (!invoiceId) throw new Error('invoice_id is required')
const emailService = getEmailService()
if (!emailService.isConfigured()) {
throw new Error('Email service not configured. Ensure RESEND_API_KEY and RESEND_FROM_EMAIL are set.')
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) throw new Error('Invoice not found')
const customer = invoice.customer as Customer
if (!customer.email) throw new Error('Customer has no email address. Update customer details first.')
return stagePendingOperation(supabase, companyId, userId, 'send_invoice',
`Skicka: ${invoice.invoice_number} till ${customer.email}`,
{ invoice_id: invoiceId },
{
invoice_number: invoice.invoice_number,
customer_name: customer.name,
customer_email: customer.email,
total: invoice.total,
currency: invoice.currency,
},
actor,
{
description: 'After the customer pays, mark the invoice paid via gnubok_mark_invoice_as_paid (or match it to the inbound bank transaction with gnubok_match_transaction_to_invoice).',
tool: 'gnubok_mark_invoice_as_paid',
args: { invoice_id: invoiceId },
}
)
},
},
{
name: 'gnubok_mark_invoice_as_sent',
description: 'Mark a draft invoice as sent without sending email (when delivered manually). Stages for approval. Status must be draft.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
invoice_id: { type: 'string', description: 'UUID of the draft invoice' },
},
required: ['invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const invoiceId = args.invoice_id as string
if (!invoiceId) throw new Error('invoice_id is required')
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) throw new Error('Invoice not found')
if (invoice.status !== 'draft') throw new Error('Only draft invoices can be marked as sent')
return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_sent',
`Markera skickad: ${invoice.invoice_number} ${invoice.customer?.name || ''}`,
{ invoice_id: invoiceId },
{
invoice_number: invoice.invoice_number,
customer_name: invoice.customer?.name,
total: invoice.total,
currency: invoice.currency,
},
actor,
{
description: 'Once approved, the invoice moves to "sent". Track its payment via gnubok_mark_invoice_as_paid when the customer pays.',
tool: 'gnubok_mark_invoice_as_paid',
args: { invoice_id: invoiceId },
}
)
},
},
// ── Supplier Operations (Read-Only) ──────────────────────────
{
name: 'gnubok_list_suppliers',
description: 'List all suppliers (leverantörer) with contact and payment details, sorted by name.',
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
suppliers: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
},
required: ['suppliers', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, userId, supabase) {
const { data, error } = await supabase
.from('suppliers')
.select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country')
.eq('company_id', companyId)
.order('name', { ascending: true })
if (error) throw new Error(`Database error: ${error.message}`)
return { suppliers: data ?? [], count: data?.length ?? 0 }
},
},
{
name: 'gnubok_create_supplier',
description: 'Stage a new supplier (leverantör). Stages for user approval — NOT created until approved in the web app. Use to add a vendor before booking a supplier invoice or matching expenses.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string', maxLength: 255, description: 'Supplier name' },
supplier_type: {
type: 'string',
enum: ['swedish_business', 'eu_business', 'non_eu_business'],
description: 'Supplier type (default swedish_business). eu_business requires vat_number.',
},
email: { type: 'string', maxLength: 255, format: 'email', description: 'Email address' },
phone: { type: 'string', maxLength: 50, description: 'Phone number' },
org_number: {
type: 'string',
maxLength: 20,
pattern: '^\\d{6}-?\\d{4}$|^\\d{12}$',
description: 'Swedish org number (10 digits with optional hyphen XXXXXX-XXXX, or 12 digits).',
},
vat_number: {
type: 'string',
maxLength: 20,
description: 'EU VAT number with country prefix (e.g. SE556677778800, DE123456789). Required when supplier_type is eu_business.',
},
address_line1: { type: 'string', maxLength: 255, description: 'Street address' },
address_line2: { type: 'string', maxLength: 255 },
postal_code: { type: 'string', maxLength: 20 },
city: { type: 'string', maxLength: 100 },
country: {
type: 'string',
maxLength: 2,
pattern: '^[A-Za-z]{2}$',
description: 'ISO 3166-1 alpha-2 country code (default SE)',
},
bankgiro: {
type: 'string',
maxLength: 20,
pattern: '^\\d{3,4}-?\\d{4}$',
description: 'Swedish Bankgiro number (7-8 digits with valid Luhn check digit).',
},
plusgiro: {
type: 'string',
maxLength: 20,
pattern: '^\\d{1,7}-?\\d{1}$',
description: 'Swedish Plusgiro number (2-8 digits).',
},
bank_account: { type: 'string', maxLength: 50, description: 'Bank account number' },
iban: {
type: 'string',
maxLength: 34,
pattern: '^[A-Z]{2}\\d{2}[A-Z0-9]{11,30}$',
description: 'IBAN (ISO 13616). Country code + 2 check digits + alphanumeric.',
},
bic: {
type: 'string',
maxLength: 11,
pattern: '^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$',
description: 'BIC/SWIFT code (8 or 11 chars).',
},
default_expense_account: {
type: 'string',
maxLength: 10,
pattern: '^[4567]\\d{3}$',
description: '4-digit BAS expense account (class 4, 5, 6, or 7). e.g. "5010".',
},
default_payment_terms: {
type: 'integer',
minimum: 0,
maximum: 365,
description: 'Payment terms in days (default 30). Use 0 for due-on-receipt.',
},
default_currency: {
type: 'string',
minLength: 3,
maxLength: 3,
description: 'Default invoice currency, 3-letter ISO code (default SEK).',
},
notes: { type: 'string', maxLength: 2000 },
dry_run: {
type: 'boolean',
description: 'If true, validate inputs and return the would-be preview without staging or creating. No DB writes, no side-effects.',
},
idempotency_key: {
type: 'string',
description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.',
},
},
required: ['name'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
// Server-side validation (defense in depth): MCP transport already
// checks the JSON Schema, but we re-validate with Zod so financial
// identifiers (IBAN, BIC, bankgiro Luhn, org_number, VAT format) are
// rejected at the ingestion boundary rather than persisted.
// Strip MCP control fields before parsing — the strict schema rejects
// unknown keys to satisfy ASVS V4.5 field-allow-listing.
const { dry_run, idempotency_key, ...supplierArgs } = args
let params
try {
params = CreateSupplierParamsSchema.parse(supplierArgs)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
const path = issue?.path?.join('.') ?? 'params'
throw new Error(`Invalid ${path}: ${issue?.message ?? 'validation failed'}`)
}
throw err
}
return stagePendingOperation(supabase, companyId, userId, 'create_supplier',
`Ny leverantör: ${params.name}`,
params,
params,
actor,
{
description: 'Once approved, you can book supplier invoices against this supplier with gnubok_create_supplier_invoice_from_inbox using the returned supplier_id.',
tool: 'gnubok_create_supplier_invoice_from_inbox',
},
{
dryRun: Boolean(dry_run),
idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined,
}
)
},
},
{
name: 'gnubok_list_supplier_invoices',
description: 'List supplier invoices (leverantörsfakturor), sorted by due date. Optional status filter; "to_pay" combines approved+overdue.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: {
type: 'string',
description: 'Filter: registered, approved, overdue, paid, to_pay, all (default)',
enum: ['registered', 'approved', 'overdue', 'paid', 'to_pay', 'all'],
},
limit: { type: 'number', description: 'Max results 1–100 (default 50)' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
invoices: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
},
required: ['invoices', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
const status = (args.status as string) || 'all'
let query = supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, supplier:suppliers(id, name)')
.eq('company_id', companyId)
if (status !== 'all') {
if (status === 'to_pay') {
query = query.in('status', ['approved', 'overdue'])
} else {
query = query.eq('status', status)
}
}
const { data, error } = await query.order('due_date', { ascending: true }).limit(limit)
if (error) throw new Error(`Database error: ${error.message}`)
return { invoices: data ?? [], count: data?.length ?? 0 }
},
},
// ── Counterparty Templates & Suggestions ─────────────────────
{
name: 'gnubok_get_counterparty_templates',
description: 'List active counterparty categorization templates — learned patterns from prior categorizations used for auto-matching new transactions.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
limit: { type: 'number', description: 'Max results 1–200 (default 100)' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
templates: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
},
required: ['templates', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 100), 200)
const { data, error } = await supabase
.from('categorization_templates')
.select('id, counterparty_name, counterparty_aliases, debit_account, credit_account, vat_treatment, vat_account, category, line_pattern, occurrence_count, confidence, last_seen_date, source')
.eq('company_id', companyId)
.eq('is_active', true)
.order('occurrence_count', { ascending: false })
.limit(limit)
if (error) throw new Error(`Database error: ${error.message}`)
return {
templates: (data ?? []).map((t) => ({
...t,
counterparty_name_display: formatCounterpartyName(t.counterparty_name),
})),
count: data?.length ?? 0,
}
},
},
{
name: 'gnubok_suggest_categories',
description: 'Suggest categories for uncategorized transactions using mapping rules, pattern matching, history, and counterparty templates. Up to 20 transactions per call.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transaction_ids: {
type: 'array',
items: { type: 'string' },
description: 'Up to 20 transaction UUIDs',
},
},
required: ['transaction_ids'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
suggestions: { type: 'object' },
counterparty_matches: { type: 'object' },
},
required: ['suggestions', 'counterparty_matches'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const ids = args.transaction_ids as string[]
if (!ids || ids.length === 0) throw new Error('transaction_ids is required (non-empty array)')
const limitedIds = ids.slice(0, 20)
// Fetch transactions
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.in('id', limitedIds)
if (txError) throw new Error(`Database error: ${txError.message}`)
if (!transactions || transactions.length === 0) throw new Error('No transactions found')
// Fetch mapping rules
const { data: mappingRules } = await supabase
.from('mapping_rules')
.select('*')
.or(`company_id.eq.${companyId},company_id.is.null`)
.eq('is_active', true)
.order('priority', { ascending: false })
// Build category history from past categorizations
const { data: historicalTxns } = await supabase
.from('transactions')
.select('category')
.eq('company_id', companyId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.neq('category', 'private')
.limit(200)
const categoryHistory: Record<string, number> = {}
for (const tx of historicalTxns || []) {
if (tx.category) categoryHistory[tx.category] = (categoryHistory[tx.category] || 0) + 1
}
// Batch counterparty template matching
const counterpartyMatches = await findCounterpartyTemplatesBatch(
supabase, companyId, transactions as Transaction[]
)
// Generate suggestions per transaction
const suggestions: Record<string, unknown[]> = {}
const counterpartyResult: Record<string, unknown> = {}
for (const tx of transactions) {
suggestions[tx.id] = getSuggestedCategories(
tx as Transaction, mappingRules ?? [], categoryHistory
)
const cpMatch = counterpartyMatches.get(tx.id)
if (cpMatch) {
counterpartyResult[tx.id] = {
template_name: formatCounterpartyName(cpMatch.template.counterparty_name),
debit_account: cpMatch.template.debit_account,
credit_account: cpMatch.template.credit_account,
category: cpMatch.template.category,
confidence: cpMatch.confidence,
match_method: cpMatch.matchMethod,
occurrence_count: cpMatch.template.occurrence_count,
}
}
}
return { suggestions, counterparty_matches: counterpartyResult }
},
},
// ── Accounts & Chart of Accounts ─────────────────────────────
{
name: 'gnubok_list_accounts',
description: 'List chart of accounts (kontoplan). account_class: 1=assets, 2=liabilities, 3=revenue, 4–7=expenses, 8=financial.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
account_class: { type: 'number', description: 'Filter by class (1–8)' },
active_only: { type: 'boolean', description: 'Only active accounts (default: true)' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
accounts: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
},
required: ['accounts', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const activeOnly = args.active_only !== false
const accountClass = args.account_class as number | undefined
let query = supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description')
.eq('company_id', companyId)
.order('sort_order')
if (activeOnly) query = query.eq('is_active', true)
if (accountClass !== undefined) query = query.eq('account_class', accountClass)
const { data, error } = await query
if (error) throw new Error(`Database error: ${error.message}`)
return { accounts: data ?? [], count: data?.length ?? 0 }
},
},
// ── Reports ──────────────────────────────────────────────────
{
name: 'gnubok_get_balance_sheet',
description: 'Balance sheet (balansräkning) for a fiscal period: assets, equity, and liabilities sections with totals + balance check.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
},
},
outputSchema: { type: 'object' },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (!periods) throw new Error('No fiscal periods found. Create one first.')
periodId = periods.id
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found.')
const result = await generateBalanceSheet(supabase, companyId, periodId!)
return {
period_name: period.name,
...result,
period: { start: period.period_start, end: period.period_end },
}
},
},
{
name: 'gnubok_get_general_ledger',
description: 'General ledger (huvudbok) for a fiscal period: per-account opening balance, entries, closing balance. Optional account range filter. For ad-hoc cross-account, amount, or free-text line queries use gnubok_query_journal.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
account_from: { type: 'string', description: 'Starting account number filter' },
account_to: { type: 'string', description: 'Ending account number filter' },
},
},
outputSchema: { type: 'object' },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (!periods) throw new Error('No fiscal periods found.')
periodId = periods.id
}
const accountFrom = args.account_from as string | undefined
const accountTo = args.account_to as string | undefined
return await generateGeneralLedger(supabase, companyId, periodId!, accountFrom, accountTo)
},
},
{
name: 'gnubok_query_journal',
description: "Flexible journal-line query — replaces chained ledger calls for ad-hoc questions. Filters: accounts, date range, amount range, voucher series/number, source type, status, project, cost center, free-text. Returns lines with parent voucher metadata + totals.",
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
account_from: { type: 'string', description: 'Lowest account number (inclusive). E.g. "4000" with account_to "4999" → all class-4 expenses.' },
account_to: { type: 'string', description: 'Highest account number (inclusive)' },
accounts: { type: 'array', items: { type: 'string' }, description: 'Specific account numbers (overrides account_from/account_to). Up to 50.' },
date_from: { type: 'string', description: 'Earliest entry date (YYYY-MM-DD, inclusive)' },
date_to: { type: 'string', description: 'Latest entry date (YYYY-MM-DD, inclusive)' },
amount_min: { type: 'number', description: 'Minimum line amount (absolute value of debit OR credit)' },
amount_max: { type: 'number', description: 'Maximum line amount (absolute value)' },
text: { type: 'string', description: 'Free-text search in entry description and line description' },
voucher_series: { type: 'string', description: 'Filter by voucher series (e.g. "A")' },
voucher_number_from: { type: 'number', description: 'Lowest voucher number (inclusive)' },
voucher_number_to: { type: 'number', description: 'Highest voucher number (inclusive)' },
source_type: { type: 'string', description: 'Filter by source: bank_transaction, invoice_created, supplier_invoice, currency_revaluation, year_end, opening_balance, etc.' },
status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' },
project: { type: 'string', description: 'Filter by project code' },
cost_center: { type: 'string', description: 'Filter by cost center' },
limit: { type: 'number', description: 'Max lines returned 1–500 (default 100). Aggregate totals are computed over the full match set even when truncated.' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
lines: { type: 'array', items: { type: 'object' } },
truncated: { type: 'boolean', description: 'True if more matching lines exist than were returned' },
total_lines: { type: 'number', description: 'Total lines matching ALL filters (incl. amount). When amount_min/amount_max is set this reflects the filtered set, not the wider DB-side match.' },
returned_lines: { type: 'number' },
amount_filter_applied_post_fetch: { type: 'boolean', description: 'True if amount_min/amount_max was applied client-side after the DB fetch.' },
db_matched_pre_amount_filter: { type: ['number', 'null'], description: 'Pre-amount-filter DB match count when amount_filter_applied_post_fetch is true; null otherwise.' },
totals: {
type: 'object',
properties: {
debit: { type: 'number' },
credit: { type: 'number' },
net: { type: 'number', description: 'debit minus credit (positive = net debit)' },
},
},
applied_filters: { type: 'object' },
},
required: ['lines', 'total_lines', 'returned_lines', 'totals'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 100), 500)
const status = (args.status as string) || 'posted'
const accounts = args.accounts as string[] | undefined
const accountFrom = args.account_from as string | undefined
const accountTo = args.account_to as string | undefined
if (accounts && accounts.length > 50) {
throw new Error('accounts list capped at 50 — use account_from/account_to for ranges')
}
// Build the line-level query with a forced inner join on journal_entries
// so we can filter by the parent's company_id, status, date range, etc.
let query = supabase
.from('journal_entry_lines')
.select(
'id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)',
{ count: 'exact' }
)
.eq('journal_entries.company_id', companyId)
if (status === 'all') {
query = query.in('journal_entries.status', ['posted', 'reversed'])
} else {
query = query.eq('journal_entries.status', status)
}
if (accounts && accounts.length > 0) {
query = query.in('account_number', accounts)
} else {
if (accountFrom) query = query.gte('account_number', accountFrom)
if (accountTo) query = query.lte('account_number', accountTo)
}
const dateFrom = args.date_from as string | undefined
const dateTo = args.date_to as string | undefined
if (dateFrom) query = query.gte('journal_entries.entry_date', dateFrom)
if (dateTo) query = query.lte('journal_entries.entry_date', dateTo)
const voucherSeries = args.voucher_series as string | undefined
if (voucherSeries) query = query.eq('journal_entries.voucher_series', voucherSeries)
const vnFrom = args.voucher_number_from as number | undefined
const vnTo = args.voucher_number_to as number | undefined
if (typeof vnFrom === 'number') query = query.gte('journal_entries.voucher_number', vnFrom)
if (typeof vnTo === 'number') query = query.lte('journal_entries.voucher_number', vnTo)
const sourceType = args.source_type as string | undefined
if (sourceType) query = query.eq('journal_entries.source_type', sourceType)
const project = args.project as string | undefined
if (project) query = query.eq('project', project)
const costCenter = args.cost_center as string | undefined
if (costCenter) query = query.eq('cost_center', costCenter)
// Free-text search across both line description and entry description.
// PostgREST `or` filter applies at the joined level when fully qualified.
const text = (args.text as string | undefined)?.trim()
if (text) {
// Escape both LIKE wildcards (`%` and `_`) so a search for "2_441"
// matches the literal string instead of "2X441". Replace `,` with a
// space because PostgREST treats it as the `or` separator.
const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_').replace(/,/g, ' ')
query = query.or(
`line_description.ilike.%${escaped}%,journal_entries.description.ilike.%${escaped}%`
)
}
// Order by date desc then voucher_number desc — most recent first
query = query
.order('entry_date', { foreignTable: 'journal_entries', ascending: false })
.order('voucher_number', { foreignTable: 'journal_entries', ascending: false })
.order('sort_order', { ascending: true })
.limit(limit)
const { data, error, count } = await query
if (error) throw new Error(`Database error: ${error.message}`)
type LineRow = {
id: string
account_number: string
debit_amount: number
credit_amount: number
currency: string | null
line_description: string | null
project: string | null
cost_center: string | null
sort_order: number
journal_entries: {
id: string
voucher_number: number
voucher_series: string
entry_date: string
description: string
source_type: string
status: string
}
}
// Apply amount filter post-fetch — PostgREST can't OR an abs(debit) >= n
// with abs(credit) >= n cleanly. Lines are debit XOR credit, so checking
// max(debit, credit) works.
const amountMin = args.amount_min as number | undefined
const amountMax = args.amount_max as number | undefined
const amountFilterApplied = typeof amountMin === 'number' || typeof amountMax === 'number'
const filtered = (data ?? []).filter((row) => {
const r = row as unknown as LineRow
const lineAmount = Math.max(Number(r.debit_amount) || 0, Number(r.credit_amount) || 0)
if (typeof amountMin === 'number' && lineAmount < amountMin) return false
if (typeof amountMax === 'number' && lineAmount > amountMax) return false
return true
}) as unknown as LineRow[]
// Compute totals on the fetched-and-filtered set. Note: when truncated,
// these are totals of the returned slice, not the full match. The
// truncated flag tells the agent whether to issue a narrower query.
let totalDebit = 0
let totalCredit = 0
const lines = filtered.map((r) => {
totalDebit += Number(r.debit_amount) || 0
totalCredit += Number(r.credit_amount) || 0
return {
line_id: r.id,
journal_entry_id: r.journal_entries.id,
voucher_series: r.journal_entries.voucher_series,
voucher_number: r.journal_entries.voucher_number,
entry_date: r.journal_entries.entry_date,
entry_description: r.journal_entries.description,
source_type: r.journal_entries.source_type,
status: r.journal_entries.status,
account_number: r.account_number,
debit: Number(r.debit_amount) || 0,
credit: Number(r.credit_amount) || 0,
line_description: r.line_description,
project: r.project,
cost_center: r.cost_center,
currency: r.currency,
}
})
// PostgREST's `count` is computed before the post-fetch amount filter,
// so when amount_min/amount_max is set it reflects the wider DB-side
// match — not the lines actually returned. Reporting that as
// `total_lines` would mislead an agent into chasing a truncated tail
// that has already been filtered out client-side. When the amount
// filter ran, anchor `total_lines` and `truncated` to the filtered
// result, and surface the pre-filter count + a flag separately so an
// agent can still tell the DB matched more (it just didn't pass the
// amount predicate).
const dbMatched = count ?? (data ?? []).length
const total_lines = amountFilterApplied ? lines.length : dbMatched
const truncated = amountFilterApplied
? (data ?? []).length >= limit && lines.length === limit
: dbMatched > lines.length
return {
lines,
truncated,
total_lines,
returned_lines: lines.length,
amount_filter_applied_post_fetch: amountFilterApplied,
db_matched_pre_amount_filter: amountFilterApplied ? dbMatched : null,
totals: {
debit: Math.round(totalDebit * 100) / 100,
credit: Math.round(totalCredit * 100) / 100,
net: Math.round((totalDebit - totalCredit) * 100) / 100,
},
applied_filters: {
account_from: accountFrom ?? null,
account_to: accountTo ?? null,
accounts: accounts ?? null,
date_from: dateFrom ?? null,
date_to: dateTo ?? null,
amount_min: amountMin ?? null,
amount_max: amountMax ?? null,
text: text ?? null,
voucher_series: voucherSeries ?? null,
voucher_number_from: vnFrom ?? null,
voucher_number_to: vnTo ?? null,
source_type: sourceType ?? null,
status,
project: project ?? null,
cost_center: costCenter ?? null,
},
}
},
},
{
name: 'gnubok_get_ar_ledger',
description: 'Accounts receivable ledger (kundreskontra): outstanding customer invoices with aging.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default: today)' },
},
},
outputSchema: { type: 'object' },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const asOfDate = args.as_of_date as string | undefined
return await generateARLedger(supabase, companyId, asOfDate)
},
},
{
name: 'gnubok_get_supplier_ledger',
description: 'Accounts payable ledger (leverantörsreskontra): outstanding supplier invoices with aging.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default: today)' },
},
},
outputSchema: { type: 'object' },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const asOfDate = args.as_of_date as string | undefined
return await generateSupplierLedger(supabase, companyId, asOfDate)
},
},
// ── Transaction Matching ─────────────────────────────────────
{
name: 'gnubok_match_transaction_to_invoice',
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer match before staging — preview mirrors what you pass. Supports partial payments and auto-storno of prior categorization.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transaction_id: { type: 'string', description: 'UUID of the bank transaction' },
invoice_id: { type: 'string', description: 'UUID of the invoice to match' },
},
required: ['transaction_id', 'invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const transactionId = args.transaction_id as string
const invoiceId = args.invoice_id as string
if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required')
// Validate both exist and are matchable
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id, description, merchant_name, amount, currency, date, invoice_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txError || !transaction) throw new Error('Transaction not found')
if (transaction.amount <= 0) throw new Error('Only income transactions (amount > 0) can be matched to invoices')
if (transaction.invoice_id) throw new Error('Transaction is already linked to an invoice')
const { data: invoice, error: invError } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('company_id', companyId)
.single()
if (invError || !invoice) throw new Error('Invoice not found')
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)')
}
const txDesc = transaction.merchant_name || transaction.description || transactionId
return stagePendingOperation(supabase, companyId, userId, 'match_transaction_invoice',
`Matcha: ${txDesc} → ${invoice.invoice_number}`,
{ transaction_id: transactionId, invoice_id: invoiceId },
{
transaction_description: txDesc,
transaction_amount: transaction.amount,
transaction_currency: transaction.currency,
// Surface both dates so the reviewer can spot a material mismatch
// between the payment and the invoice it's being matched against
// before approving.
transaction_date: transaction.date,
invoice_number: invoice.invoice_number,
invoice_total: invoice.total,
invoice_currency: invoice.currency,
invoice_date: invoice.invoice_date,
customer_name: (invoice.customer as Record<string, unknown>)?.name as string,
},
actor,
{
description: 'After approval the transaction is linked and the invoice is marked paid. Use gnubok_get_ar_ledger to verify the customer balance.',
tool: 'gnubok_get_ar_ledger',
}
)
},
},
{
name: 'gnubok_auto_match_period',
description: "Bulk reconciliation: scan unmatched income transactions in a date range and propose invoice matches with confidence + reasoning. dry_run=true (default) previews without staging; dry_run=false stages every match above confidence_threshold as a pending operation.",
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
date_from: { type: 'string', description: 'Period start YYYY-MM-DD' },
date_to: { type: 'string', description: 'Period end YYYY-MM-DD' },
confidence_threshold: { type: 'number', description: 'Minimum confidence to propose (0..1, default 0.9). Lower for more matches; raise for safety.' },
dry_run: { type: 'boolean', description: 'If true (default), preview proposals without staging. If false, stage each above-threshold match as a pending operation.' },
max_transactions: { type: 'number', description: 'Cap on transactions to process this call (default 100, max 500). Use multiple calls or narrower date ranges for very large periods.' },
},
required: ['date_from', 'date_to'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
dry_run: { type: 'boolean' },
confidence_threshold: { type: 'number' },
scanned_transactions: { type: 'number' },
proposed_matches: { type: 'number' },
below_threshold: { type: 'number' },
no_match_found: { type: 'number' },
truncated: { type: 'boolean' },
proposals: { type: 'array', items: { type: 'object' } },
staged_count: { type: 'number' },
stage_failures: { type: 'array', items: { type: 'object' } },
},
required: ['dry_run', 'scanned_transactions', 'proposed_matches', 'proposals'],
},
annotations: {
readOnlyHint: false, // can stage when dry_run=false
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const dateFrom = args.date_from as string
const dateTo = args.date_to as string
if (!dateFrom || !dateTo) throw new Error('date_from and date_to are required')
const confidenceThreshold = typeof args.confidence_threshold === 'number'
? Math.max(0, Math.min(1, args.confidence_threshold))
: 0.9
const dryRun = args.dry_run !== false
const maxTransactions = Math.min(Math.max(1, Number(args.max_transactions) || 100), 500)
// Fetch unmatched income transactions in window. We require positive
// amount because findMatchingInvoices only matches income; expenses are
// out of scope for this tool.
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('id, description, merchant_name, amount, currency, date, reference, journal_entry_id, invoice_id')
.eq('company_id', companyId)
.gte('date', dateFrom)
.lte('date', dateTo)
.gt('amount', 0)
.is('journal_entry_id', null)
.is('invoice_id', null)
.order('date', { ascending: true })
.limit(maxTransactions + 1)
if (txError) throw new Error(`Failed to fetch transactions: ${txError.message}`)
const txList = (transactions ?? []).slice(0, maxTransactions)
const truncated = (transactions ?? []).length > maxTransactions
type Proposal = {
transaction_id: string
transaction_date: string
transaction_amount: number
transaction_currency: string
transaction_description: string
invoice_id: string
invoice_number: string | null
invoice_total: number
customer_name: string | null
confidence: number
match_reason: string
decision: 'propose' | 'below_threshold' | 'no_match'
}
const proposals: Proposal[] = []
let belowThreshold = 0
let noMatchFound = 0
for (const tx of txList) {
const matches = await findMatchingInvoices(
supabase,
companyId,
tx as never,
)
if (matches.length === 0) {
noMatchFound++
continue
}
const best = matches[0]
const baseProposal: Omit<Proposal, 'decision'> = {
transaction_id: tx.id as string,
transaction_date: tx.date as string,
transaction_amount: Number(tx.amount) || 0,
transaction_currency: tx.currency as string,
transaction_description: (tx.merchant_name as string) || (tx.description as string) || '',
invoice_id: best.invoice.id,
invoice_number: best.invoice.invoice_number,
invoice_total: best.invoice.total,
customer_name: (best.invoice.customer as { name?: string } | undefined)?.name ?? null,
confidence: Math.round(best.confidence * 1000) / 1000,
match_reason: best.matchReason,
}
if (best.confidence < confidenceThreshold) {
proposals.push({ ...baseProposal, decision: 'below_threshold' as const })
belowThreshold++
} else {
proposals.push({ ...baseProposal, decision: 'propose' as const })
}
}
const proposed = proposals.filter((p) => p.decision === 'propose')
// Dry-run path: return proposals with reasoning, no side-effects
if (dryRun) {
return {
dry_run: true,
confidence_threshold: confidenceThreshold,
scanned_transactions: txList.length,
proposed_matches: proposed.length,
below_threshold: belowThreshold,
no_match_found: noMatchFound,
truncated,
proposals,
staged_count: 0,
stage_failures: [],
}
}
// Commit path: stage each above-threshold match through pending_operations.
// Per-item failure isolation — one bad match doesn't kill the rest.
const stageFailures: { transaction_id: string; invoice_id: string; error: string }[] = []
let stagedCount = 0
for (const p of proposed) {
try {
await stagePendingOperation(
supabase,
companyId,
userId,
'match_transaction_invoice',
`Matcha: ${p.transaction_description || p.transaction_id} → ${p.invoice_number}`,
{ transaction_id: p.transaction_id, invoice_id: p.invoice_id },
{
transaction_description: p.transaction_description,
transaction_amount: p.transaction_amount,
transaction_currency: p.transaction_currency,
invoice_number: p.invoice_number,
invoice_total: p.invoice_total,
customer_name: p.customer_name,
auto_match_confidence: p.confidence,
auto_match_reason: p.match_reason,
},
actor,
)
stagedCount++
} catch (err) {
stageFailures.push({
transaction_id: p.transaction_id,
invoice_id: p.invoice_id,
error: err instanceof Error ? err.message : 'Unknown stage error',
})
}
}
return {
dry_run: false,
confidence_threshold: confidenceThreshold,
scanned_transactions: txList.length,
proposed_matches: proposed.length,
below_threshold: belowThreshold,
no_match_found: noMatchFound,
truncated,
proposals,
staged_count: stagedCount,
stage_failures: stageFailures,
}
},
},
// ── Fiscal Periods ───────────────────────────────────────────
{
name: 'gnubok_list_fiscal_periods',
description: 'List all fiscal periods (räkenskapsperioder) with status: active (open), locked (no new entries), or closed (year-end completed).',
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
periods: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
},
required: ['periods', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, companyId, userId, supabase) {
const { data, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at, opening_balances_set')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
if (error) throw new Error(`Database error: ${error.message}`)
const periods = (data ?? []).map((p) => ({
id: p.id,
name: p.name,
period_start: p.period_start,
period_end: p.period_end,
opening_balances_set: p.opening_balances_set,
status: p.is_closed ? 'closed' : p.locked_at ? 'locked' : 'active',
}))
return { periods, count: periods.length }
},
},
// ── Reconciliation ───────────────────────────────────────────
{
name: 'gnubok_get_reconciliation_status',
description: 'Bank reconciliation status: matched/unmatched counts, match rate, bank vs ledger balance, difference. Optional date range.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
date_from: { type: 'string', description: 'Start date YYYY-MM-DD' },
date_to: { type: 'string', description: 'End date YYYY-MM-DD' },
},
},
outputSchema: { type: 'object' },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const dateFrom = args.date_from as string | undefined
const dateTo = args.date_to as string | undefined
return await getReconciliationStatus(supabase, companyId, dateFrom, dateTo)
},
},
// ── Document Inbox Tools ────────────────────────────────────
{
name: 'gnubok_upload_document',
description: 'Upload a PDF/JPEG/PNG/HEIC/WebP (max 20 MB) to the inbox. Runs deterministic field extraction on text-based PDFs.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
file_name: { type: 'string', description: 'File name with extension (e.g. "faktura.pdf")' },
file_content_base64: { type: 'string', description: 'Base64-encoded file content' },
mime_type: { type: 'string', description: 'MIME type (optional, inferred from extension)' },
},
required: ['file_name', 'file_content_base64'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
document_id: { type: 'string' },
inbox_item_id: { type: 'string' },
status: { type: 'string' },
extracted_data: { type: 'object' },
matched_supplier_id: { type: 'string' },
},
required: ['document_id', 'inbox_item_id', 'status'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const fileName = args.file_name as string
const base64Content = args.file_content_base64 as string
let mimeType = args.mime_type as string | undefined
if (!mimeType) {
const ext = fileName.split('.').pop()?.toLowerCase()
const mimeMap: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
heic: 'image/heic',
webp: 'image/webp',
}
mimeType = ext ? mimeMap[ext] : undefined
if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${ext}`)
}
const allowedMimeTypes = new Set([
'application/pdf', 'image/jpeg', 'image/png', 'image/heic', 'image/webp',
])
if (!allowedMimeTypes.has(mimeType)) {
throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`)
}
const buffer = Buffer.from(base64Content, 'base64')
if (buffer.byteLength > MAX_DOCUMENT_SIZE) {
throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`)
}
const doc = await uploadDocument(supabase, userId, companyId, {
name: fileName,
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
type: mimeType,
}, { upload_source: 'api' })
const { data: extracted } = await extractInvoiceFields({
buffer,
mimeType,
fileName,
})
let matchedSupplierId: string | null = null
if (extracted.supplier.orgNumber) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
const { data: inbox, error: inboxError } = await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'received',
source: 'upload',
document_id: doc.id,
extracted_data: extracted as unknown as Record<string, unknown>,
matched_supplier_id: matchedSupplierId,
})
.select('id, status')
.single()
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
return {
document_id: doc.id,
inbox_item_id: inbox.id,
status: inbox.status,
extracted_data: extracted,
matched_supplier_id: matchedSupplierId,
}
},
},
{
name: 'gnubok_list_inbox_items',
description: 'List document inbox items. Each has a `processed` flag covering all terminal links (transaction match, supplier invoice, or journal entry), so a booked receipt counts as done. unprocessed_only=true returns only docs still needing handling.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: { type: 'string', enum: ['received', 'error'], description: 'Filter by status' },
unprocessed_only: { type: 'boolean', description: 'When true, only return items with no terminal link yet (not matched to a transaction, supplier invoice, or journal entry) — i.e. documents that still need handling. Default false.' },
limit: { type: 'number', description: 'Max results (default 20, max 50)' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
items: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
},
required: ['items', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
const status = args.status as string | undefined
const unprocessedOnly = args.unprocessed_only === true
let query = supabase
.from('invoice_inbox_items')
.select('id, status, source, created_at, extracted_data, matched_supplier_id, matched_transaction_id, created_supplier_invoice_id, created_journal_entry_id, email_from, email_subject, error_message')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
// Fetch a wider window when filtering client-side so the limit
// applies to the post-filter set rather than truncating before it.
.limit(unprocessedOnly ? 200 : limit)
if (status) query = query.eq('status', status)
const { data, error } = await query
if (error) throw new Error(`Database error: ${error.message}`)
const mapped = (data || []).map((item) => {
const extracted = item.extracted_data as Record<string, unknown> | null
let vendorName: string | null = null
let amount: number | null = null
let invoiceDate: string | null = null
if (extracted) {
const supplier = extracted.supplier as Record<string, unknown> | undefined
const invoice = extracted.invoice as Record<string, unknown> | undefined
const totals = extracted.totals as Record<string, unknown> | undefined
vendorName = (supplier?.name as string) || null
amount = (totals?.total as number) || null
invoiceDate = (invoice?.invoiceDate as string) || null
}
// An item is "processed" once it has ANY terminal link: matched to a
// bank transaction, converted to a supplier invoice, or booked
// directly to a journal entry. Surfacing only the supplier fields
// (as before) made receipts booked against bank transactions look
// loose — and risked the agent flagging them as duplicates.
const processed = !!(
item.matched_transaction_id ||
item.created_supplier_invoice_id ||
item.created_journal_entry_id
)
return {
id: item.id,
status: item.status,
source: item.source,
created_at: item.created_at,
vendor_name: vendorName,
amount,
invoice_date: invoiceDate,
processed,
matched_supplier_id: item.matched_supplier_id,
matched_transaction_id: item.matched_transaction_id,
created_supplier_invoice_id: item.created_supplier_invoice_id,
created_journal_entry_id: item.created_journal_entry_id,
email_from: item.email_from,
email_subject: item.email_subject,
error_message: item.error_message,
}
})
const filtered = unprocessedOnly ? mapped.filter((i) => !i.processed) : mapped
const items = filtered.slice(0, limit)
return { items, count: items.length }
},
},
{
name: 'gnubok_get_inbox_item',
description: 'Get a single inbox item with complete extracted data, supplier match, email metadata, and timestamps.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
inbox_item_id: { type: 'string', description: 'UUID of the inbox item' },
},
required: ['inbox_item_id'],
},
outputSchema: { type: 'object' },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const id = args.inbox_item_id as string
const { data, error } = await supabase
.from('invoice_inbox_items')
.select('*, document_attachments(id, file_name, mime_type, file_size_bytes, created_at)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error) throw new Error(`Database error: ${error.message}`)
if (!data) throw new Error('Inbox item not found')
return data
},
},
{
name: 'gnubok_create_supplier_invoice_from_inbox',
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier (matched or via org_number/name), assembles line items from extracted_data, applies VAT + FX, attaches the source document. Stages for human review; honors dry_run.",
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
inbox_item_id: { type: 'string', description: 'UUID of the inbox item to convert' },
supplier_id_override: { type: 'string', description: 'Force this supplier UUID instead of the matched/extracted one' },
vat_treatment_override: { type: 'string', enum: ['standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt'], description: 'Override extracted VAT treatment' },
due_date_override: { type: 'string', description: 'Override extracted due date (YYYY-MM-DD)' },
notes: { type: 'string', description: 'Optional notes appended to the supplier invoice' },
dry_run: { type: 'boolean', description: 'If true, return the assembled payload without staging (default false)' },
idempotency_key: { type: 'string', description: 'UUID. Repeat calls with same key + payload return cached response.' },
},
required: ['inbox_item_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const inboxItemId = args.inbox_item_id as string
if (!inboxItemId) throw new Error('inbox_item_id is required')
const dryRun = args.dry_run === true
const idempotencyKey = args.idempotency_key as string | undefined
// Fetch the inbox item with the attached source document
const { data: inbox, error: inboxErr } = await supabase
.from('invoice_inbox_items')
.select('id, status, extracted_data, matched_supplier_id, created_supplier_invoice_id, document_id')
.eq('id', inboxItemId)
.eq('company_id', companyId)
.single()
if (inboxErr || !inbox) throw new Error('Inbox item not found')
if (inbox.created_supplier_invoice_id) {
throw new Error(`Inbox item already converted to supplier invoice ${inbox.created_supplier_invoice_id}`)
}
const extracted = (inbox.extracted_data as Record<string, unknown> | null) ?? null
if (!extracted) throw new Error('Inbox item has no extracted_data — re-run extraction first')
const supplierExt = extracted.supplier as Record<string, unknown> | undefined
const invoiceExt = extracted.invoice as Record<string, unknown> | undefined
const totalsExt = extracted.totals as Record<string, unknown> | undefined
const lineItemsExt = (extracted.lineItems as Array<Record<string, unknown>> | undefined) ?? []
// Resolve supplier — explicit override > matched > org_number lookup > name lookup
const supplierIdOverride = args.supplier_id_override as string | undefined
let supplierId: string | null = supplierIdOverride ?? (inbox.matched_supplier_id as string | null) ?? null
let supplierResolution: 'override' | 'matched' | 'lookup_org_number' | 'lookup_name' | 'unresolved' =
supplierIdOverride ? 'override' : inbox.matched_supplier_id ? 'matched' : 'unresolved'
if (!supplierId) {
const orgNumber = supplierExt?.organizationNumber as string | undefined
const supplierName = supplierExt?.name as string | undefined
if (orgNumber) {
const { data } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', orgNumber)
.maybeSingle()
if (data) {
supplierId = data.id
supplierResolution = 'lookup_org_number'
}
}
if (!supplierId && supplierName) {
const { data } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.ilike('name', supplierName)
.maybeSingle()
if (data) {
supplierId = data.id
supplierResolution = 'lookup_name'
}
}
}
if (!supplierId) {
throw new Error(
`Cannot resolve supplier from extracted data. Pass supplier_id_override, or create the supplier first (extracted name: ${supplierExt?.name ?? 'unknown'}, org: ${supplierExt?.organizationNumber ?? 'unknown'}).`
)
}
// Assemble core invoice fields
const currency = (invoiceExt?.currency as string) || 'SEK'
const invoiceDate = (invoiceExt?.invoiceDate as string) || null
const dueDate = (args.due_date_override as string | undefined) ?? (invoiceExt?.dueDate as string | undefined) ?? null
const supplierInvoiceNumber = (invoiceExt?.invoiceNumber as string) || ''
if (!invoiceDate) throw new Error('Extracted invoice has no invoice date')
if (!supplierInvoiceNumber) throw new Error('Extracted invoice has no invoice number')
const total = Number(totalsExt?.total) || 0
const subtotal = Number(totalsExt?.subtotal) || 0
const vatAmount = Number(totalsExt?.vat) || 0
// VAT treatment: explicit override wins, else heuristic from extracted data
const vatTreatment = (args.vat_treatment_override as string | undefined)
?? (invoiceExt?.vatTreatment as string | undefined)
?? 'standard_25'
// FX: if non-SEK, fetch rate at fakturadatum (best-effort; agent can re-stage on failure)
let exchangeRate: number | null = null
if (currency !== 'SEK' && invoiceDate) {
try {
const result = await fetchExchangeRate(currency as Currency, new Date(invoiceDate))
exchangeRate = result?.rate ?? null
} catch {
exchangeRate = null // Agent will be informed via preview; can override later
}
}
// Translate extracted line items into the supplier_invoice_items shape.
// Default account 4000 (varuinköp/inköp) when extraction didn't pin one.
const lineItems = lineItemsExt.map((li, idx) => ({
line_number: idx + 1,
description: (li.description as string) ?? `Position ${idx + 1}`,
quantity: Number(li.quantity) || 1,
unit: (li.unit as string) ?? 'st',
unit_price: Number(li.unit_price ?? li.unitPrice ?? li.amount) || 0,
line_total: Number(li.line_total ?? li.lineTotal ?? li.amount) || 0,
account_number: (li.account_number as string | undefined) ?? '4000',
vat_rate: Number(li.vat_rate ?? li.vatRate) || 0,
vat_amount: Number(li.vat_amount ?? li.vatAmount) || 0,
}))
const params = {
inbox_item_id: inboxItemId,
supplier_id: supplierId,
document_id: inbox.document_id,
supplier_invoice_number: supplierInvoiceNumber,
invoice_date: invoiceDate,
due_date: dueDate,
currency,
exchange_rate: exchangeRate,
vat_treatment: vatTreatment,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round(total * 100) / 100,
notes: (args.notes as string | undefined) ?? null,
items: lineItems,
}
const previewData = {
inbox_item_id: inboxItemId,
supplier_id: supplierId,
supplier_resolution: supplierResolution,
extracted_supplier_name: supplierExt?.name ?? null,
extracted_org_number: supplierExt?.organizationNumber ?? null,
supplier_invoice_number: supplierInvoiceNumber,
invoice_date: invoiceDate,
due_date: dueDate,
currency,
exchange_rate: exchangeRate,
exchange_rate_source: exchangeRate !== null ? 'riksbanken' : currency === 'SEK' ? 'not_applicable' : 'lookup_failed',
vat_treatment: vatTreatment,
subtotal: params.subtotal,
vat_amount: params.vat_amount,
total: params.total,
line_count: lineItems.length,
items_preview: lineItems.slice(0, 5),
will: 'register supplier invoice (status=registered), attach the inbox document, post a registration journal entry on confirm — leverantörsskuld (2440) credited and the cost/VAT split debited per the per-line VAT rules',
}
return stagePendingOperation(
supabase,
companyId,
userId,
'create_supplier_invoice_from_inbox',
`Leverantörsfaktura: ${supplierInvoiceNumber} (${(supplierExt?.name as string) ?? 'okänd'})`,
params,
previewData,
actor,
{
description: 'After approval, attest via gnubok_approve_supplier_invoice and pay via the bank flow.',
tool: 'gnubok_get_inbox_item',
args: { inbox_item_id: inboxItemId },
},
{ dryRun, idempotencyKey },
)
},
},
{
name: 'gnubok_list_unmatched_documents',
description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. The amount is in the invoice currency — FX-normalise before comparing to transactions.amount.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
limit: { type: 'number', description: 'Max results (default 20, max 50)' },
cursor: { type: 'string', description: 'Composite "<created_at>__<inbox_item_id>" from previous page (exclusive). Pass next_cursor verbatim.' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
items: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
next_cursor: { type: 'string', description: 'Pass as cursor on next call. Absent = no more pages.' },
},
required: ['items', 'count'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
const cursor = typeof args.cursor === 'string' ? args.cursor : null
// Composite cursor: "<created_at>__<id>". Falls back to plain timestamp
// for backward compat with older callers.
let cursorTs: string | null = null
let cursorId: string | null = null
if (cursor) {
const sep = cursor.indexOf('__')
if (sep === -1) {
cursorTs = cursor
} else {
cursorTs = cursor.slice(0, sep)
cursorId = cursor.slice(sep + 2)
}
}
// Pull recent inbox items with a document, no supplier invoice yet, then
// filter out those whose document is already pinned to a transaction.
// Two-step query because PostgREST doesn't expose anti-joins.
const fetchSize = limit * 2
let inboxQuery = supabase
.from('invoice_inbox_items')
.select('id, document_id, source, email_from, email_subject, email_received_at, extracted_data, created_at')
.eq('company_id', companyId)
.not('document_id', 'is', null)
.is('created_supplier_invoice_id', null)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(fetchSize)
if (cursorTs && cursorId) {
// (created_at, id) < (cursorTs, cursorId) — keyset pagination
inboxQuery = inboxQuery.or(
`created_at.lt.${cursorTs},and(created_at.eq.${cursorTs},id.lt.${cursorId})`
)
} else if (cursorTs) {
inboxQuery = inboxQuery.lt('created_at', cursorTs)
}
const { data: inboxRows, error: inboxError } = await inboxQuery
if (inboxError) throw new Error(`Database error: ${inboxError.message}`)
if (!inboxRows || inboxRows.length === 0) {
return { items: [], count: 0 }
}
const docIds = inboxRows.map((r) => r.document_id).filter((d): d is string => d != null)
const { data: txMatches, error: txError } = await supabase
.from('transactions')
.select('document_id')
.eq('company_id', companyId)
.in('document_id', docIds)
if (txError) throw new Error(`Database error: ${txError.message}`)
const matchedDocIds = new Set((txMatches || []).map((t) => t.document_id))
const unmatched = inboxRows
.filter((r) => r.document_id && !matchedDocIds.has(r.document_id))
.slice(0, limit)
.map((item) => {
const extracted = item.extracted_data as Record<string, unknown> | null
let vendorName: string | null = null
let orgNumber: string | null = null
let amount: number | null = null
let currency: string | null = null
let invoiceDate: string | null = null
let paymentReference: string | null = null
if (extracted) {
const supplier = extracted.supplier as Record<string, unknown> | undefined
const invoice = extracted.invoice as Record<string, unknown> | undefined
const totals = extracted.totals as Record<string, unknown> | undefined
vendorName = (supplier?.name as string) || null
orgNumber = (supplier?.orgNumber as string) || null
amount = (totals?.total as number) || null
// Surface currency alongside amount so the agent doesn't compare a
// non-SEK invoice numerically to a SEK transaction. transactions.amount
// is in transactions.currency; if these don't match, the agent must
// FX-normalise before ranking matches. Defaulting to null when absent
// (rather than 'SEK') makes the missing-currency case explicit.
currency = (invoice?.currency as string) || null
invoiceDate = (invoice?.invoiceDate as string) || null
paymentReference = (invoice?.paymentReference as string) || null
}
return {
inbox_item_id: item.id,
document_id: item.document_id,
source: item.source,
created_at: item.created_at,
email_from: item.email_from,
email_subject: item.email_subject,
email_received_at: item.email_received_at,
vendor_name: vendorName,
org_number: orgNumber,
amount,
currency,
invoice_date: invoiceDate,
payment_reference: paymentReference,
}
})
// Pagination contract: emit next_cursor whenever the caller might be
// missing rows. Two cases:
// (a) slice was full → cursor on last returned item (next page picks up
// any leftover unmatched rows we filtered past);
// (b) slice was short but inbox query returned a full batch → cursor on
// last inspected row (more unmatched may exist deeper in the inbox).
// Only suppress the cursor when we exhausted the inbox stream entirely.
let nextCursor: string | null = null
if (unmatched.length === limit) {
const last = unmatched[unmatched.length - 1]
nextCursor = `${last.created_at}__${last.inbox_item_id}`
} else if (inboxRows.length === fetchSize) {
const last = inboxRows[inboxRows.length - 1]
nextCursor = `${last.created_at}__${last.id}`
}
return {
items: unmatched,
count: unmatched.length,
...(nextCursor ? { next_cursor: nextCursor } : {}),
}
},
},
{
name: 'gnubok_get_document_content',
description: 'Get a 5-minute signed download URL for a document so the agent can read its contents (e.g. with vision). Use after gnubok_list_unmatched_documents to inspect a specific PDF before deciding which transaction it matches.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
document_id: { type: 'string', description: 'UUID of the document_attachments row' },
},
required: ['document_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
document_id: { type: 'string' },
file_name: { type: 'string' },
mime_type: { type: 'string' },
size_bytes: { type: 'number' },
signed_url: { type: 'string' },
expires_at: { type: 'string' },
},
required: ['document_id', 'file_name', 'signed_url', 'expires_at'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const documentId = args.document_id as string
if (!documentId) throw new Error('document_id is required')
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, file_name, mime_type, file_size_bytes, storage_path')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
if (docError) throw new Error(`Database error: ${docError.message}`)
if (!doc) throw new Error('Document not found')
const ttlSeconds = 300
const { data: signed, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(doc.storage_path, ttlSeconds)
if (signError || !signed) {
throw new Error(`Failed to create signed URL: ${signError?.message ?? 'unknown error'}`)
}
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString()
return {
document_id: doc.id,
file_name: doc.file_name,
mime_type: doc.mime_type,
size_bytes: doc.file_size_bytes,
signed_url: signed.signedUrl,
expires_at: expiresAt,
}
},
},
{
name: 'gnubok_attach_document_to_transaction',
description: 'Stage attaching a document to a bank transaction. Verify tx (date, amount, counterparty) and document (filename, vendor, amount) match first — the preview shown to the human reviewer mirrors what you pass here. Stages for approval.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transaction_id: { type: 'string', description: 'UUID of the bank transaction' },
document_id: { type: 'string', description: 'UUID of the document_attachments row' },
idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' },
dry_run: { type: 'boolean', description: 'Preview without staging' },
},
required: ['transaction_id', 'document_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const transactionId = args.transaction_id as string
const documentId = args.document_id as string
if (!transactionId) throw new Error('transaction_id is required')
if (!documentId) throw new Error('document_id is required')
const { data: tx, error: txError } = await supabase
.from('transactions')
.select('id, description, merchant_name, amount, currency, date, document_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
if (txError || !tx) throw new Error('Transaction not found')
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, file_name, mime_type')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
if (docError || !doc) throw new Error('Document not found')
// If the tx already has a different doc pinned, fetch its identity so the
// human approver sees "replaces X.pdf with Y.pdf" rather than just a flag.
// Required by BFL 5 kap 5 § rättelse (the approver must know what's being
// displaced before authorising the change).
type ExistingDoc = { id: string; file_name: string; journal_entry_id: string | null }
let existingDoc: ExistingDoc | null = null
if (tx.document_id && tx.document_id !== documentId) {
const { data: prev } = await supabase
.from('document_attachments')
.select('id, file_name, journal_entry_id')
.eq('id', tx.document_id)
.eq('company_id', companyId)
.maybeSingle()
if (prev) {
existingDoc = prev as unknown as ExistingDoc
}
}
// Pull the matching invoice_inbox_items extracted_data so the approver
// sees vendor/amount/currency/date — the same hints the agent had when
// choosing this attachment. Mirrors the BFL 5 kap 6 § informed-rättelse
// intent: the human authorising the link should see what's on the doc.
let docVendorName: string | null = null
let docAmount: number | null = null
let docCurrency: string | null = null
let docInvoiceDate: string | null = null
const { data: inbox } = await supabase
.from('invoice_inbox_items')
.select('extracted_data')
.eq('document_id', documentId)
.eq('company_id', companyId)
.limit(1)
.maybeSingle()
if (inbox?.extracted_data) {
const ext = inbox.extracted_data as Record<string, unknown>
const supplier = ext.supplier as Record<string, unknown> | undefined
const invoice = ext.invoice as Record<string, unknown> | undefined
const totals = ext.totals as Record<string, unknown> | undefined
docVendorName = (supplier?.name as string) || null
docAmount = (totals?.total as number) || null
docCurrency = (invoice?.currency as string) || null
docInvoiceDate = (invoice?.invoiceDate as string) || null
}
return stagePendingOperation(
supabase, companyId, userId, 'attach_document_to_transaction',
`Koppla bilaga: ${doc.file_name} → ${tx.merchant_name || tx.description || transactionId}`,
{ transaction_id: transactionId, document_id: documentId },
{
transaction_description: tx.merchant_name || tx.description,
transaction_amount: tx.amount,
transaction_currency: tx.currency,
transaction_date: tx.date,
document_file_name: doc.file_name,
document_mime_type: doc.mime_type,
document_vendor_name: docVendorName,
document_amount: docAmount,
document_currency: docCurrency,
document_invoice_date: docInvoiceDate,
will_overwrite_existing: existingDoc != null,
existing_document_id: existingDoc?.id ?? null,
existing_document_file_name: existingDoc?.file_name ?? null,
existing_document_is_rakenskapsinformation: existingDoc?.journal_entry_id != null,
},
actor,
{
description: 'Once approved, the receipt is linked to the transaction. If the transaction is still uncategorized, follow up with gnubok_categorize_transaction.',
tool: 'gnubok_categorize_transaction',
args: { transaction_id: transactionId },
},
{
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
dryRun: args.dry_run === true,
// Pin the period-status envelope to the transaction date so the
// approver sees locked/closed periods on the same row that
// categorize_transaction surfaces them — the attach silently
// becomes part of the verifikation underlag once categorize
// propagates it (BFL 5 kap 6 § rättelse-räkenskapsinformation).
dateForPeriodCheck: typeof tx.date === 'string' ? tx.date : undefined,
}
)
},
},
// ── Payroll (Lönehantering) ──────────────────────────────────
{
name: 'gnubok_list_employees',
description: 'List employees for the active company. Personnummer returned masked (YYYYMMDD-XXXX).',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
active_only: { type: 'boolean', description: 'Only active employees (default: true)' },
},
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employees: { type: 'array', items: { type: 'object' } },
count: { type: 'number' },
},
required: ['employees', 'count'],
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const activeOnly = args.active_only !== false
let query = supabase
.from('employees')
.select('id, first_name, last_name, personnummer, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active')
.eq('company_id', companyId)
if (activeOnly) query = query.eq('is_active', true)
const { data, error } = await query.order('last_name')
if (error) throw new Error(`Database error: ${error.message}`)
const employees = (data || []).map(e => ({ ...e, personnummer: maskPersonnummer(decryptPersonnummer(e.personnummer as string)) }))
return { employees, count: employees.length }
},
},
{
name: 'gnubok_get_salary_run',
description: 'Get salary run with status, totals, per-employee breakdown (gross, tax, net, avgifter, vacation accrual) and step-by-step calculation breakdown.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
},
required: ['salary_run_id'],
},
outputSchema: { type: 'object' },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const id = args.salary_run_id as string
const { data: run, error } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error || !run) throw new Error('Salary run not found')
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4)')
.eq('salary_run_id', id)
return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record<string, unknown>), personnummer: maskPersonnummer(decryptPersonnummer((e.employee as Record<string, unknown>).personnummer as string)) } : null })) }
},
},
{
name: 'gnubok_get_salary_journal',
description: 'Salary journal (lönejournal) for a year: per-employee per-month rows + yearly totals.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
year: { type: 'number', description: 'Year to report on' },
},
required: ['year'],
},
outputSchema: { type: 'object' },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const { generateSalaryJournal } = await import('@/lib/reports/salary-journal')
return generateSalaryJournal(supabase, companyId, args.year as number)
},
},
{
name: 'gnubok_create_salary_run',
description: 'Stage creation of a draft salary run for a period + base lines for all active employees. Commit via gnubok_approve_pending_operation; then run gnubok_calculate_salary_run. Final booking happens in the web UI.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_year: { type: 'number', description: 'Year' },
period_month: { type: 'number', description: 'Month (1-12)' },
payment_date: { type: 'string', description: 'Payment date (YYYY-MM-DD)' },
},
required: ['period_year', 'period_month', 'payment_date'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const { period_year, period_month, payment_date } = args as { period_year: number; period_month: number; payment_date: string }
if (!Number.isInteger(period_year) || period_year < 1900 || period_year > 9999) {
throw new Error('period_year must be a 4-digit year')
}
if (!Number.isInteger(period_month) || period_month < 1 || period_month > 12) {
throw new Error('period_month must be 1-12')
}
if (typeof payment_date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(payment_date)) {
throw new Error('payment_date must be YYYY-MM-DD')
}
// Preview: count active employees and surface base monthly salaries so
// the approver knows what would be seeded. No writes here — the commit
// path re-runs createSalaryRunWithEmployees atomically.
const { count: employeeCount } = await supabase
.from('employees')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_active', true)
const period = `${period_year}-${String(period_month).padStart(2, '0')}`
return stagePendingOperation(
supabase, companyId, userId, 'create_salary_run',
`Skapa löneutbetalning: ${period} (${employeeCount ?? 0} anställda)`,
{ period_year, period_month, payment_date },
{
period,
payment_date,
employee_count: employeeCount ?? 0,
},
actor,
{
description: 'After approval, calculate tax, avgifter and totals.',
tool: 'gnubok_calculate_salary_run',
},
{ dateForPeriodCheck: payment_date },
)
},
},
{
name: 'gnubok_calculate_salary_run',
description: 'Calculate a draft salary run: tax, avgifter, vacation accrual, totals. Run must be in draft status.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
},
required: ['salary_run_id'],
},
outputSchema: { type: 'object' },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const id = args.salary_run_id as string
if (!id) throw new Error('salary_run_id is required')
// Call the extracted calculation lib directly — no self-fetch / forged
// cookie, no NEXT_PUBLIC_APP_URL dependency. The lib enforces draft status
// and owner-by-company itself.
const { runSalaryCalculation } = await import('@/lib/salary/run-calculation')
const { createLogger } = await import('@/lib/logger')
const { randomUUID } = await import('node:crypto')
const result = await runSalaryCalculation({
supabase,
companyId,
salaryRunId: id,
log: createLogger('mcp/calculate_salary_run'),
requestId: randomUUID(),
})
if (!result.ok) {
throw new Error(`Salary calculation failed: ${result.code}`)
}
return {
salary_run_id: id,
status: (result.run as { status?: string }).status ?? 'draft',
warnings: result.warnings,
message: 'Calculation complete. Review and book the run in the web UI.',
next: {
description: 'Review the calculated run; approval and booking happen in the web UI.',
tool: 'gnubok_get_salary_run',
args: { salary_run_id: id },
},
}
},
},
{
name: 'gnubok_generate_agi',
description: 'Stage AGI XML generation (Arbetsgivardeklaration) for a salary run. High-risk: produces statutory Skatteverket underlag (BFL 7-year retention). Commit via gnubok_approve_pending_operation.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
},
required: ['salary_run_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const id = args.salary_run_id as string
if (!id) throw new Error('salary_run_id is required')
const { data: run } = await supabase
.from('salary_runs')
.select('id, status, period_year, period_month, payment_date')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!run) throw new Error('Salary run not found')
if (run.status === 'draft') {
throw new Error('Salary run must be past draft before AGI can be generated')
}
const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
return stagePendingOperation(
supabase, companyId, userId, 'generate_agi',
`Generera AGI: ${period}`,
{ salary_run_id: id },
{
period,
status: run.status,
payment_date: run.payment_date,
retention_years: 7,
},
actor,
undefined,
run.payment_date ? { dateForPeriodCheck: run.payment_date } : {},
)
},
},
// ── Stream 1 Phase 1: Bookkeeping write (high-risk, always staged) ──
{
name: 'gnubok_close_period',
description: 'Stage period close (irreversible per BFL). Requires period locked + year-end closing entry posted. High-risk — always staged, never auto-committed.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to close' },
},
required: ['fiscal_period_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) throw new Error('Fiscal period not found')
if (period.is_closed) throw new Error('Period is already closed')
if (!period.locked_at) throw new Error('Period must be locked before closing — call gnubok_lock_period first')
if (!period.closing_entry_id) throw new Error('Year-end closing entry must exist before the period can be closed')
return stagePendingOperation(supabase, companyId, userId, 'close_period',
`Stäng period: ${period.name} (${period.period_start} – ${period.period_end})`,
{ fiscal_period_id: fiscalPeriodId },
{
period_name: period.name,
period_start: period.period_start,
period_end: period.period_end,
locked_at: period.locked_at,
closing_entry_id: period.closing_entry_id,
irreversible: true,
},
actor,
{
description: 'Closing is irreversible. Verify the balance sheet and income statement first.',
tool: 'gnubok_get_balance_sheet',
args: { fiscal_period_id: fiscalPeriodId },
}
)
},
},
{
name: 'gnubok_lock_period',
description: 'Stage period lock — blocks new entries. Requires zero unbooked business transactions. High-risk, always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to lock' },
},
required: ['fiscal_period_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) throw new Error('Fiscal period not found')
if (period.is_closed) throw new Error('Period is already closed')
if (period.locked_at) throw new Error('Period is already locked')
const { count: unbookedCount } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_business', true)
.gte('date', period.period_start)
.lte('date', period.period_end)
if (unbookedCount && unbookedCount > 0) {
throw new Error(
`Kan inte låsa period: ${unbookedCount} affärstransaktion(er) saknar bokföring. Bokför alla transaktioner först.`
)
}
return stagePendingOperation(supabase, companyId, userId, 'lock_period',
`Lås period: ${period.name} (${period.period_start} – ${period.period_end})`,
{ fiscal_period_id: fiscalPeriodId },
{
period_name: period.name,
period_start: period.period_start,
period_end: period.period_end,
unbooked_business_transactions: 0,
},
actor,
{
description: 'After locking, run year-end closing before the period can be closed via gnubok_close_period. Verify balances first with gnubok_get_trial_balance.',
tool: 'gnubok_get_trial_balance',
args: { fiscal_period_id: fiscalPeriodId },
}
)
},
},
{
name: 'gnubok_uncategorize_transaction',
description: 'Stage uncategorize: reverses linked journal entry via storno (never deletes) and clears the category. Stages for approval.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transaction_id: { type: 'string', description: 'UUID of the transaction to uncategorize' },
},
required: ['transaction_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase, actor) {
const transactionId = args.transaction_id as string
if (!transactionId) throw new Error('transaction_id is required')
const { data: tx, error: txError } = await supabase
.from('transactions')
.select('id, description, merchant_name, amount, currency, date, category, journal_entry_id')
.eq('id', transactionId)
.eq('company_id', companyId)
.single()
if (txError || !tx) throw new Error('Transaction not found')
if (!tx.journal_entry_id) throw new Error('Transaction has no journal entry to reverse')
const { data: entry } = await supabase
.from('journal_entries')
.select('id, voucher_number, voucher_series, status')
.eq('id', tx.journal_entry_id)
.eq('company_id', companyId)
.single()
if (!entry || entry.status !== 'posted') {
throw new Error('Linked journal entry is not posted — nothing to reverse')
}
return stagePendingOperation(supabase, companyId, userId, 'uncategorize_transaction',
`Återta kategorisering: ${tx.merchant_name || tx.description || transactionId}`,
{ transaction_id: transactionId, journal_entry_id: tx.journal_entry_id },
{
transaction_description: tx.merchant_name || tx.description,
amount: tx.amount,
currency: tx.currency,
date: tx.date,
current_category: tx.category,
will_reverse_voucher: `${entry.voucher_series}${entry.voucher_number}`,
method: 'storno (reversal entry, never deletes)',
},
actor,
{
description: 'After approval the transaction is uncategorized again — book it with the correct category via gnubok_categorize_transaction.',
tool: 'gnubok_categorize_transaction',
args: { transaction_id: transactionId },
}
)
},
},
{
name: 'gnubok_export_sie',
description: 'Generate SIE-4 file for a fiscal period (standard Swedish bookkeeping interchange format). Returns SIE text content.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to export' },
},
required: ['fiscal_period_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
content: { type: 'string' },
byte_size: { type: 'number' },
fiscal_period_id: { type: 'string' },
company_name: { type: 'string' },
generated_at: { type: 'string' },
},
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { data: company } = await supabase
.from('company_settings')
.select('company_name, org_number')
.eq('company_id', companyId)
.single()
if (!company) throw new Error('Company settings not found')
const sieContent = await generateSIEExport(supabase, companyId, {
fiscal_period_id: fiscalPeriodId,
company_name: company.company_name || 'Unknown',
org_number: company.org_number,
})
return {
content: sieContent,
byte_size: Buffer.byteLength(sieContent, 'utf8'),
fiscal_period_id: fiscalPeriodId,
company_name: company.company_name,
org_number: company.org_number,
generated_at: new Date().toISOString(),
}
},
},
{
name: 'gnubok_audit_package',
description: "Single-call audit package for a fiscal period: SIE-4 + reports (trial balance, income statement, balance sheet, general ledger, journal register, VAT) + receipts + audit log + voucher gaps, zipped. Returns a 1-hour signed download URL. Long-running on large datasets.",
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to package' },
include_documents: { type: 'boolean', description: 'Include receipts/document binaries in the zip (default true)' },
estimate_only: { type: 'boolean', description: 'Return size estimate without generating (default false)' },
},
required: ['fiscal_period_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
download_url: { type: ['string', 'null'], description: 'Signed Supabase Storage URL valid for 1 hour. Null when estimate_only=true.' },
storage_path: { type: ['string', 'null'] },
file_name: { type: 'string' },
size_bytes: { type: 'number' },
size_limit_bytes: { type: 'number' },
within_limit: { type: 'boolean' },
period: { type: 'object' },
generated_at: { type: 'string' },
expires_at: { type: ['string', 'null'] },
estimate_only: { type: 'boolean' },
},
required: ['file_name', 'size_bytes', 'period', 'generated_at', 'estimate_only'],
},
annotations: {
readOnlyHint: false, // produces a Storage artifact
destructiveHint: false,
idempotentHint: true, // repeat calls produce equivalent archives, fresh URL
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const includeDocuments = args.include_documents !== false
const estimateOnly = args.estimate_only === true
const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
// Verify period belongs to the company
const { data: period, error: periodErr } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (periodErr || !period) throw new Error('Fiscal period not found')
const generatedAt = new Date().toISOString()
// Pre-flight size estimate — also serves the estimate-only path
const estimate = await estimateArchiveSize(supabase, companyId, 'period', fiscalPeriodId)
const sizeBytes = estimate.total_bytes
const withinLimit = sizeBytes <= SIZE_LIMIT_BYTES
const fileName = `arkiv_${period.name.replace(/[^\w-]/g, '_')}_${fiscalPeriodId.slice(0, 8)}.zip`
if (estimateOnly) {
return {
download_url: null,
storage_path: null,
file_name: fileName,
size_bytes: sizeBytes,
size_limit_bytes: SIZE_LIMIT_BYTES,
within_limit: withinLimit,
period: {
id: period.id,
name: period.name,
period_start: period.period_start,
period_end: period.period_end,
},
generated_at: generatedAt,
expires_at: null,
estimate_only: true,
}
}
if (includeDocuments && !withinLimit) {
throw new Error(
`Archive would exceed ${Math.round(SIZE_LIMIT_BYTES / 1024 / 1024)} MB (estimate: ${Math.round(sizeBytes / 1024 / 1024)} MB). Retry with include_documents=false to omit receipt binaries.`
)
}
// Generate the archive (long-running)
const zipBuffer = await generateFullArchive(supabase, companyId, {
scope: 'period',
period_id: fiscalPeriodId,
include_documents: includeDocuments,
})
// Upload to Storage under a per-user audit-packages folder
const storagePath = `${userId}/audit-packages/${Date.now()}_${fileName}`
const { error: uploadErr } = await supabase.storage
.from('documents')
.upload(storagePath, new Uint8Array(zipBuffer), {
contentType: 'application/zip',
upsert: false,
})
if (uploadErr) throw new Error(`Failed to upload archive: ${uploadErr.message}`)
// Sign for 1 hour
const SIGNED_URL_TTL_SECONDS = 3600
const { data: signed, error: signErr } = await supabase.storage
.from('documents')
.createSignedUrl(storagePath, SIGNED_URL_TTL_SECONDS)
if (signErr || !signed) {
// Best-effort cleanup of the uploaded blob if signing failed
await supabase.storage.from('documents').remove([storagePath])
throw new Error(`Failed to sign archive URL: ${signErr?.message ?? 'unknown error'}`)
}
const expiresAt = new Date(Date.now() + SIGNED_URL_TTL_SECONDS * 1000).toISOString()
return {
download_url: signed.signedUrl,
storage_path: storagePath,
file_name: fileName,
size_bytes: zipBuffer.byteLength,
size_limit_bytes: SIZE_LIMIT_BYTES,
within_limit: true,
period: {
id: period.id,
name: period.name,
period_start: period.period_start,
period_end: period.period_end,
},
generated_at: generatedAt,
expires_at: expiresAt,
estimate_only: false,
}
},
},
// ── Stream 1 Phase 1 follow-up: year-end, opening balances, revaluation,
// voucher gaps, supplier-invoice lifecycle, proforma conversion ──
{
name: 'gnubok_year_end_readiness',
description: "Pre-flight before irreversible gnubok_run_year_end. Returns ready (bool) + ordered blockers (drafts, voucher gaps, sequence mismatches, unbalanced trial balance, FX revaluation needed) + warnings + optional preview of the closing entry. Use this before staging year-end.",
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to year-end' },
include_preview: { type: 'boolean', description: 'If true, also return the would-be closing journal entry preview (default false)' },
},
required: ['fiscal_period_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period: { type: 'object' },
ready: { type: 'boolean' },
blockers: { type: 'array', items: { type: 'object' } },
warnings: { type: 'array', items: { type: 'string' } },
draft_count: { type: 'number' },
unexplained_voucher_gap_count: { type: 'number' },
sequence_mismatch_count: { type: 'number' },
trial_balance_balanced: { type: 'boolean' },
preview: { type: ['object', 'null'] },
summary: { type: 'string' },
},
required: ['ready', 'blockers', 'warnings', 'summary'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const fiscalPeriodId = args.fiscal_period_id as string
const includePreview = args.include_preview === true
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
// Fetch period for context (the validate function returns errors if not found,
// but agents benefit from period metadata in the response)
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id, continuity_verified')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found')
const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
// Reshape error strings into structured blockers so the agent (and any
// dashboard) can render and act on each one independently. The lib
// returns flat strings; we tag each with a `kind` heuristic for routing.
const blockers = validation.errors.map((message) => {
let kind: string = 'other'
if (/draft journal entries/i.test(message)) kind = 'draft_entries'
else if (/voucher gap/i.test(message)) kind = 'unexplained_voucher_gap'
else if (/Sequence counter integrity/i.test(message)) kind = 'sequence_mismatch'
else if (/Trial balance is not balanced/i.test(message)) kind = 'trial_balance_unbalanced'
else if (/already closed/i.test(message)) kind = 'period_already_closed'
else if (/has not yet ended/i.test(message)) kind = 'period_not_ended'
else if (/closing entry already exists/i.test(message)) kind = 'closing_entry_exists'
else if (/continuity check failed/i.test(message)) kind = 'opening_balance_continuity'
else if (/Fiscal period not found/i.test(message)) kind = 'period_not_found'
return { kind, severity: 'high' as const, message }
})
let preview = null
if (includePreview && validation.ready) {
try {
preview = await previewYearEndClosing(supabase, companyId, userId, fiscalPeriodId)
} catch (err) {
// Preview is opportunistic — never fail the readiness check on it.
preview = { error: err instanceof Error ? err.message : 'Preview unavailable' }
}
}
const summary = validation.ready
? validation.warnings.length > 0
? `Klart för bokslut. ${validation.warnings.length} varning(ar) att granska.`
: 'Klart för bokslut.'
: `Inte klart: ${blockers.length} blockerare måste åtgärdas.`
return {
period: {
id: period.id,
name: period.name,
period_start: period.period_start,
period_end: period.period_end,
is_closed: period.is_closed,
locked_at: period.locked_at,
closing_entry_id: period.closing_entry_id,
continuity_verified: period.continuity_verified,
},
ready: validation.ready,
blockers,
warnings: validation.warnings,
draft_count: validation.draftCount,
unexplained_voucher_gap_count: validation.unexplainedGaps.length,
sequence_mismatch_count: validation.sequenceMismatches.length,
trial_balance_balanced: validation.trialBalanceBalanced,
preview,
summary,
}
},
},
{
name: 'gnubok_run_year_end',
description: 'Stage year-end closing: zero result accounts (class 3–8) into 2099, lock period, create next period, seed opening balances. High-risk, always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to close out' },
},
required: ['fiscal_period_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at')
.eq('id', fiscalPeriodId).eq('company_id', companyId).single()
if (!period) throw new Error('Fiscal period not found')
if (period.is_closed) throw new Error('Period is already closed')
return stagePendingOperation(supabase, companyId, userId, 'run_year_end',
`Bokslut: ${period.name}`,
{ fiscal_period_id: fiscalPeriodId },
{
period_name: period.name,
period_start: period.period_start,
period_end: period.period_end,
will: 'zero result accounts into 2099, lock period, create next period, generate opening balances',
},
actor,
{
description: 'After year-end, the period is locked and ready for closing via gnubok_close_period.',
tool: 'gnubok_close_period',
args: { fiscal_period_id: fiscalPeriodId },
}
)
},
},
{
name: 'gnubok_set_opening_balances',
description: 'Stage opening-balance entry: copy class 1–2 closing balances from a closed period into the next period.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
closed_period_id: { type: 'string', description: 'UUID of the closed source period' },
next_period_id: { type: 'string', description: 'UUID of the next (target) period' },
},
required: ['closed_period_id', 'next_period_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const closedId = args.closed_period_id as string
const nextId = args.next_period_id as string
if (!closedId || !nextId) throw new Error('closed_period_id and next_period_id are required')
// Resolve human-readable period names so the approver doesn't see two raw
// UUIDs in the staged-ops list. Both lookups are scoped to the company so
// a mis-typed UUID from another tenant just yields a thin (but safe) title.
const [{ data: closed }, { data: next }] = await Promise.all([
supabase.from('fiscal_periods').select('name, period_end').eq('id', closedId).eq('company_id', companyId).maybeSingle(),
supabase.from('fiscal_periods').select('name, period_start').eq('id', nextId).eq('company_id', companyId).maybeSingle(),
])
const closedLabel = closed?.name ?? closedId
const nextLabel = next?.name ?? nextId
return stagePendingOperation(supabase, companyId, userId, 'set_opening_balances',
`Ingående balans: ${closedLabel} → ${nextLabel}`,
{ closed_period_id: closedId, next_period_id: nextId },
{
closed_period_id: closedId,
closed_period_name: closed?.name ?? null,
next_period_id: nextId,
next_period_name: next?.name ?? null,
will: 'create opening balance entry from closed-period trial balance',
},
actor,
{
description: 'After approval, verify the opening balance matches the closed period\'s UB via gnubok_get_trial_balance on the next period.',
tool: 'gnubok_get_trial_balance',
args: { fiscal_period_id: nextId },
}
)
},
},
{
name: 'gnubok_run_currency_revaluation',
description: 'Stage currency revaluation: revalue open FX receivables/payables to closing-date rate (posts 3960/7960). One per period max.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
closing_date: { type: 'string', description: 'Revaluation date (YYYY-MM-DD)' },
},
required: ['fiscal_period_id', 'closing_date'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const fiscalPeriodId = args.fiscal_period_id as string
const closingDate = args.closing_date as string
if (!fiscalPeriodId || !closingDate) throw new Error('fiscal_period_id and closing_date are required')
return stagePendingOperation(supabase, companyId, userId, 'run_currency_revaluation',
`Valutaomvärdering ${closingDate}`,
{ fiscal_period_id: fiscalPeriodId, closing_date: closingDate },
{ fiscal_period_id: fiscalPeriodId, closing_date: closingDate, posts_to: ['3960', '7960'] },
actor,
{
description: 'After approval, confirm the new FX-adjusted balances via gnubok_get_balance_sheet.',
tool: 'gnubok_get_balance_sheet',
args: { fiscal_period_id: fiscalPeriodId },
}
)
},
},
{
name: 'gnubok_list_voucher_gaps',
description: 'List voucher number gaps in a fiscal period (BFNAR 2013:2 audit requirement). Each gap shows whether it has an explanation.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string' },
voucher_series: { type: 'string', description: 'Optional series filter (e.g. "A")' },
},
required: ['fiscal_period_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
gaps: { type: 'array', items: { type: 'object' } },
total_gaps: { type: 'number' },
unexplained_gaps: { type: 'number' },
},
required: ['gaps', 'total_gaps', 'unexplained_gaps'],
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const fiscalPeriodId = args.fiscal_period_id as string
const voucherSeries = args.voucher_series as string | undefined
let seriesQuery = supabase
.from('voucher_sequences').select('voucher_series')
.eq('company_id', companyId).eq('fiscal_period_id', fiscalPeriodId)
if (voucherSeries) seriesQuery = seriesQuery.eq('voucher_series', voucherSeries)
const { data: seriesRows } = await seriesQuery
if (!seriesRows || seriesRows.length === 0) {
return { gaps: [], total_gaps: 0, unexplained_gaps: 0 }
}
const allGaps: Array<{ series: string; gap_start: number; gap_end: number; explanation: unknown }> = []
for (const row of seriesRows) {
const { data: gaps } = await supabase.rpc('detect_voucher_gaps', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: row.voucher_series,
})
if (gaps) {
for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) {
allGaps.push({ series: row.voucher_series, gap_start: gap.gap_start, gap_end: gap.gap_end, explanation: null })
}
}
}
if (allGaps.length > 0) {
const { data: explanations } = await supabase
.from('voucher_gap_explanations')
.select('id, voucher_series, gap_start, gap_end, explanation, created_at')
.eq('company_id', companyId).eq('fiscal_period_id', fiscalPeriodId)
if (explanations) {
const map = new Map(explanations.map((e) => [`${e.voucher_series}:${e.gap_start}:${e.gap_end}`, e]))
for (const g of allGaps) {
g.explanation = map.get(`${g.series}:${g.gap_start}:${g.gap_end}`) ?? null
}
}
}
return {
gaps: allGaps,
total_gaps: allGaps.length,
unexplained_gaps: allGaps.filter((g) => !g.explanation).length,
}
},
},
{
name: 'gnubok_explain_voucher_gap',
description: 'Stage explanation for a voucher gap (BFNAR 2013:2 compliance — every gap needs a documented reason).',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string' },
voucher_series: { type: 'string' },
gap_start: { type: 'number' },
gap_end: { type: 'number' },
explanation: { type: 'string', description: 'Swedish prose: why the gap exists' },
},
required: ['fiscal_period_id', 'voucher_series', 'gap_start', 'gap_end', 'explanation'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const explanation = args.explanation as string
if (!explanation?.trim()) throw new Error('explanation is required')
return stagePendingOperation(supabase, companyId, userId, 'explain_voucher_gap',
`Förklara verifikationslucka ${args.voucher_series}:${args.gap_start}-${args.gap_end}`,
{
fiscal_period_id: args.fiscal_period_id,
voucher_series: args.voucher_series,
gap_start: args.gap_start,
gap_end: args.gap_end,
explanation: explanation.trim(),
},
{
voucher_series: args.voucher_series,
gap_start: args.gap_start,
gap_end: args.gap_end,
explanation: explanation.trim(),
},
actor,
{
description: 'After approval, run gnubok_list_voucher_gaps again to confirm all gaps in the period now have explanations (BFNAR 2013:2).',
tool: 'gnubok_list_voucher_gaps',
args: { fiscal_period_id: args.fiscal_period_id },
}
)
},
},
{
name: 'gnubok_approve_supplier_invoice',
description: 'Stage approval of a registered supplier invoice (registered → approved). High-risk, always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: { supplier_invoice_id: { type: 'string' } },
required: ['supplier_invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const id = args.supplier_invoice_id as string
if (!id) throw new Error('supplier_invoice_id is required')
const { data: inv } = await supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, total, currency, status, supplier:suppliers(name)')
.eq('id', id).eq('company_id', companyId).single()
if (!inv) throw new Error('Supplier invoice not found')
if (inv.status !== 'registered') throw new Error('Kan bara godkänna registrerade fakturor')
return stagePendingOperation(supabase, companyId, userId, 'approve_supplier_invoice',
`Godkänn leverantörsfaktura ${inv.supplier_invoice_number}`,
{ supplier_invoice_id: id },
{
supplier_invoice_number: inv.supplier_invoice_number,
supplier_name: (inv.supplier as { name?: string } | null)?.name,
total: inv.total,
currency: inv.currency,
invoice_date: inv.invoice_date,
},
actor,
{
description: 'After approval the invoice is attested and ready for payment. When paid, match the outbound bank transaction via gnubok_match_transaction_to_invoice.',
tool: 'gnubok_get_supplier_ledger',
},
inv.invoice_date ? { dateForPeriodCheck: inv.invoice_date } : {},
)
},
},
{
name: 'gnubok_credit_supplier_invoice',
description: 'Stage credit-note (kreditfaktura) for a supplier invoice: mirror invoice with negative effect + reverses registration JE (accrual).',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: { supplier_invoice_id: { type: 'string' } },
required: ['supplier_invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const id = args.supplier_invoice_id as string
if (!id) throw new Error('supplier_invoice_id is required')
const { data: inv } = await supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, total, currency, status, supplier:suppliers(name)')
.eq('id', id).eq('company_id', companyId).single()
if (!inv) throw new Error('Supplier invoice not found')
if (inv.status === 'credited') throw new Error('Fakturan har redan krediterats')
return stagePendingOperation(supabase, companyId, userId, 'credit_supplier_invoice',
`Kreditera leverantörsfaktura ${inv.supplier_invoice_number}`,
{ supplier_invoice_id: id },
{
supplier_invoice_number: inv.supplier_invoice_number,
supplier_name: (inv.supplier as { name?: string } | null)?.name,
total: inv.total,
currency: inv.currency,
method: 'creates KREDIT- mirror invoice + reverses registration JE (accrual)',
},
actor,
{
description: 'After approval the credit note is posted and the leverantörsskuld cleared. Verify with gnubok_get_supplier_ledger.',
tool: 'gnubok_get_supplier_ledger',
}
)
},
},
{
name: 'gnubok_convert_invoice',
description: 'Stage conversion of a proforma invoice to a real invoice. Allocates F-series number, copies items, marks proforma cancelled.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: { invoice_id: { type: 'string' } },
required: ['invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const id = args.invoice_id as string
if (!id) throw new Error('invoice_id is required')
const { data: inv } = await supabase
.from('invoices')
.select('id, document_type, status, total, currency, customer:customers(name)')
.eq('id', id).eq('company_id', companyId).single()
if (!inv) throw new Error('Invoice not found')
if (inv.document_type !== 'proforma') throw new Error('Endast proformafakturor kan konverteras')
if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makuleras')
const customerName = (inv.customer as { name?: string } | null)?.name ?? 'okänd kund'
return stagePendingOperation(supabase, companyId, userId, 'convert_invoice',
`Konvertera proforma → faktura: ${customerName} ${Math.round(Number(inv.total) * 100) / 100} ${inv.currency}`,
{ invoice_id: id },
{
customer_name: (inv.customer as { name?: string } | null)?.name,
total: inv.total,
currency: inv.currency,
will: 'allocate F-series number, copy items, cancel proforma',
},
actor,
{
description: 'After conversion, send the new invoice with gnubok_send_invoice.',
tool: 'gnubok_send_invoice',
}
)
},
},
{
name: 'gnubok_unlock_period',
description: 'Stage period unlock — clears locked_at so entries can be posted again. Cannot unlock a closed period. High-risk, always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to unlock' },
},
required: ['fiscal_period_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) throw new Error('Fiscal period not found')
if (period.is_closed) throw new Error('Cannot unlock a closed period')
if (!period.locked_at) throw new Error('Period is not locked')
return stagePendingOperation(supabase, companyId, userId, 'unlock_period',
`Lås upp period: ${period.name} (${period.period_start} – ${period.period_end})`,
{ fiscal_period_id: fiscalPeriodId },
{
period_name: period.name,
period_start: period.period_start,
period_end: period.period_end,
locked_at: period.locked_at,
will: 'clear locked_at — new entries can be posted into the period again',
},
actor,
{
description: 'After approval, post the rättelse via gnubok_correct_entry or new entries via gnubok_create_voucher, then re-lock with gnubok_lock_period.',
tool: 'gnubok_lock_period',
args: { fiscal_period_id: fiscalPeriodId },
}
)
},
},
{
name: 'gnubok_credit_invoice',
description: 'Stage credit note (kreditfaktura) for a customer invoice: KR- prefixed mirror invoice + reverses original JE (accrual). Original must be sent/paid/overdue and not already credited.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
invoice_id: { type: 'string', description: 'UUID of the invoice to credit' },
reason: { type: 'string', description: 'Optional reason note (Swedish, shown on the credit note)' },
},
required: ['invoice_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const id = args.invoice_id as string
const reason = args.reason as string | undefined
if (!id) throw new Error('invoice_id is required')
const { data: inv } = await supabase
.from('invoices')
.select('id, invoice_number, document_type, status, total, currency, customer:customers(name)')
.eq('id', id).eq('company_id', companyId).single()
if (!inv) throw new Error('Invoice not found')
if (inv.document_type && inv.document_type !== 'invoice') {
throw new Error('Credit notes can only be created from standard invoices')
}
if (inv.status === 'credited') throw new Error('Fakturan har redan krediterats')
if (!['sent', 'paid', 'overdue'].includes(inv.status)) {
throw new Error('Endast skickade, betalda eller förfallna fakturor kan krediteras')
}
return stagePendingOperation(supabase, companyId, userId, 'credit_invoice',
`Kreditera faktura ${inv.invoice_number}`,
{ invoice_id: id, reason },
{
invoice_number: inv.invoice_number,
customer_name: (inv.customer as { name?: string } | null)?.name,
total: inv.total,
currency: inv.currency,
reason: reason || null,
method: 'creates KR- mirror invoice + reverses original JE (accrual)',
},
actor,
{
description: 'After approval the credit note posts and the kundfordring is cleared. If a refund is owed to the customer, book the outbound payment when it leaves the bank.',
tool: 'gnubok_get_ar_ledger',
}
)
},
},
{
name: 'gnubok_import_sie',
description: 'Stage SIE-file import (types 1–4, CP437/UTF-8/Latin-1). On commit creates fiscal period, opening balances, and journal entries. High-risk, always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
file_content: { type: 'string', description: 'Full SIE file contents' },
filename: { type: 'string', description: 'Original filename' },
mappings: {
type: 'array',
description: 'Account mappings: { sourceAccount, sourceName, targetAccount, targetName, confidence, matchType, isOverride }',
items: { type: 'object' },
},
create_fiscal_period: { type: 'boolean' },
import_opening_balances: { type: 'boolean' },
import_transactions: { type: 'boolean' },
voucher_series: { type: 'string', description: 'Override voucher series for imported vouchers' },
},
required: ['file_content', 'filename', 'mappings'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const fileContent = args.file_content as string
const filename = args.filename as string
const mappings = args.mappings as unknown[] | undefined
if (!fileContent || !filename || !Array.isArray(mappings)) {
throw new Error('file_content, filename, and mappings are required')
}
// Parse + validate at stage time so the approver sees real content (which
// entries, what balances) and a broken/unbalanced file is rejected HERE,
// not after they approve a blind byte count. commitImportSie re-parses on
// commit (defense-in-depth — the staged string could be tampered).
const { parseSIEFile, validateSIEFile } = await import('@/lib/import/sie-parser')
let parsed
try {
parsed = parseSIEFile(fileContent)
} catch (e) {
throw new Error(`SIE-filen kunde inte tolkas: ${e instanceof Error ? e.message : 'okänt fel'}`)
}
const validation = validateSIEFile(parsed)
if (!validation.valid) {
throw new Error(`SIE-filen är ogiltig och importeras inte: ${validation.errors.join('; ')}`)
}
const ibCurrent = parsed.openingBalances.filter((b) => b.yearIndex === 0)
const ibTotal = Math.round(ibCurrent.reduce((s, b) => s + b.amount, 0) * 100) / 100
return stagePendingOperation(supabase, companyId, userId, 'import_sie',
`SIE-import: ${filename}`,
{
file_content: fileContent,
filename,
mappings,
create_fiscal_period: Boolean(args.create_fiscal_period),
import_opening_balances: Boolean(args.import_opening_balances),
import_transactions: Boolean(args.import_transactions),
voucher_series: args.voucher_series,
},
{
filename,
file_size_bytes: fileContent.length,
mappings_count: mappings.length,
company_name: parsed.header.companyName,
org_number: parsed.header.orgNumber,
fiscal_year: { start: parsed.stats.fiscalYearStart, end: parsed.stats.fiscalYearEnd },
account_count: parsed.stats.totalAccounts,
voucher_count: parsed.stats.totalVouchers,
transaction_line_count: parsed.stats.totalTransactionLines,
opening_balance: { total: ibTotal, is_balanced: ibTotal === 0 },
warnings: validation.warnings,
create_fiscal_period: Boolean(args.create_fiscal_period),
import_opening_balances: Boolean(args.import_opening_balances),
import_transactions: Boolean(args.import_transactions),
will: 'create fiscal period + opening balances + journal entries from the parsed SIE',
},
actor,
{
description: 'After commit, verify the imported balances with gnubok_get_trial_balance and check continuity via the IB/UB of adjacent periods.',
tool: 'gnubok_get_trial_balance',
}
)
},
},
// ── Phase 4: arbitrary-line bookkeeping primitives ───────────────
{
name: 'gnubok_create_voucher',
description: 'Stage a manual verifikation with arbitrary balanced lines. Use for capitalization (1010), period-end accruals, FX adjustments, rättelseposter outside categorize_transaction. Pass inbox_item_id to book a kvitto direct — links inbox + attaches doc. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
entry_date: { type: 'string', description: 'Voucher date (YYYY-MM-DD)' },
description: { type: 'string', description: 'Verifikationstext (required, min 1 char)' },
fiscal_period_id: { type: 'string', description: 'UUID of fiscal period. If omitted, resolved from entry_date.' },
voucher_series: { type: 'string', description: 'Single letter A–Z. Defaults to A.' },
notes: { type: 'string', description: 'Internal notes (max 2000 chars) — visible on the verifikation but not on reports.' },
inbox_item_id: { type: 'string', description: 'Optional inbox item UUID to book directly. On confirm, the inbox item is linked to the new verifikat and its OCR document is attached to the journal entry. Fails if the inbox item is already booked (as voucher) or converted (to supplier invoice).' },
lines: {
type: 'array',
description: 'At least 2 balanced lines. sum(debit_amount) === sum(credit_amount), both > 0.',
items: {
type: 'object',
properties: {
account_number: { type: 'string', description: '4-digit BAS account number, e.g. "1010"' },
debit_amount: { type: 'number', description: 'Debit amount in SEK (≥ 0)' },
credit_amount: { type: 'number', description: 'Credit amount in SEK (≥ 0)' },
line_description: { type: 'string' },
currency: { type: 'string', description: 'ISO 4217, defaults to SEK' },
amount_in_currency: { type: 'number', description: 'Original amount if currency is not SEK' },
exchange_rate: { type: 'number' },
tax_code: { type: 'string', description: 'Free-text tag — does NOT drive momsdeklaration ruta mapping. The BAS account number is what determines which ruta the line lands in (e.g. 2641 → ruta 48, 2614 → ruta 30). Pick the correct account first.' },
cost_center: { type: 'string' },
project: { type: 'string' },
},
required: ['account_number'],
},
},
},
required: ['entry_date', 'description', 'lines'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const entryDate = args.entry_date as string
const description = args.description as string
const rawLines = args.lines as Array<Record<string, unknown>> | undefined
if (!entryDate || !description || !Array.isArray(rawLines) || rawLines.length < 2) {
throw new Error('entry_date, description, and at least two lines are required')
}
// Normalize so validateBalance + preview see consistent numeric types.
const lines = rawLines.map((l) => ({
account_number: String(l.account_number ?? ''),
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
line_description: l.line_description ? String(l.line_description) : undefined,
currency: l.currency ? String(l.currency) : undefined,
amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined,
exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined,
tax_code: l.tax_code ? String(l.tax_code) : undefined,
cost_center: l.cost_center ? String(l.cost_center) : undefined,
project: l.project ? String(l.project) : undefined,
}))
// Pre-flight: catch unbalanced lines before staging so the agent gets a
// tight feedback loop instead of a rejected pending_operation later.
const balance = validateBalance(lines)
if (!balance.valid) {
throw new Error(
`Lines are not balanced: debits ${balance.totalDebit} SEK, credits ${balance.totalCredit} SEK. ` +
'Both must be positive and equal.'
)
}
// Resolve fiscal period. Two paths:
// 1. Caller supplied fiscal_period_id → verify it exists and is open.
// 2. Omitted → look up the open period covering entry_date.
// Both paths converge on a Swedish-language error if no valid open
// period is available. (NOTE: the executor re-checks period_lock at
// commit time — this staging gate is advisory and exists for UX, the
// commit-time guard is the authoritative one. Don't remove it as
// "redundant".)
let fiscalPeriodId = (args.fiscal_period_id as string | undefined) ?? null
if (fiscalPeriodId) {
const { data: period, error: periodErr } = await supabase
.from('fiscal_periods')
.select('id, is_closed, period_start, period_end, name')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
if (periodErr || !period) {
throw new Error(`Fiscal period ${fiscalPeriodId} not found for this company.`)
}
if (period.is_closed) {
throw new Error(
`Räkenskapsperioden "${period.name ?? fiscalPeriodId}" är låst. ` +
'Lås upp perioden, eller välj en öppen period.'
)
}
// Defense in depth: also verify the supplied period actually covers
// entry_date so the engine's EntryDateOutsideFiscalPeriodError surfaces
// as a Swedish message rather than a generic engine error.
if (entryDate < period.period_start || entryDate > period.period_end) {
throw new Error(
`Datumet ${entryDate} ligger utanför "${period.name ?? 'perioden'}" (${period.period_start}–${period.period_end}).`
)
}
} else {
fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
}
if (!fiscalPeriodId) {
throw new Error(`No open fiscal period covers ${entryDate}. Open a period or pick a different date.`)
}
// Resolve account names for the preview so the approver reads
// "1010 Balanserade utgifter / 2440 Leverantörsskulder" rather than
// bare numbers. Also gate: refuse to stage when any line references an
// unknown or inactive account so the approver isn't shown a voucher
// that would fail at commit time anyway.
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, is_active')
.eq('company_id', companyId)
.in('account_number', accountNumbers)
const accountInfo = new Map<string, { name: string; active: boolean }>()
for (const a of accounts || []) {
accountInfo.set(a.account_number as string, {
name: (a.account_name as string) ?? '',
active: Boolean(a.is_active),
})
}
const unknownAccounts = accountNumbers.filter((n) => !accountInfo.has(n))
const inactiveAccounts = accountNumbers.filter(
(n) => accountInfo.has(n) && !accountInfo.get(n)!.active,
)
if (unknownAccounts.length > 0 || inactiveAccounts.length > 0) {
const parts: string[] = []
if (unknownAccounts.length > 0) {
parts.push(`saknas i kontoplanen: ${unknownAccounts.join(', ')}`)
}
if (inactiveAccounts.length > 0) {
parts.push(`inaktiva: ${inactiveAccounts.join(', ')}`)
}
throw new Error(
`Kan inte skapa verifikation. Konton ${parts.join('; ')}. ` +
'Aktivera dem i kontoplanen eller välj andra konton.'
)
}
const previewLines = lines.map((l) => ({
account_number: l.account_number,
account_name: accountInfo.get(l.account_number)?.name ?? null,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
}))
// Optional inbox-direct booking. Validate at staging so the agent gets a
// tight rejection signal — once staged, an already-booked inbox item
// would only surface at commit time with a generic 409. The executor
// re-checks idempotently via UNIQUE constraint on
// invoice_inbox_items.created_journal_entry_id.
const inboxItemId = (args.inbox_item_id as string | undefined) ?? null
let inboxDocumentId: string | null = null
if (inboxItemId) {
const { data: inbox, error: inboxErr } = await supabase
.from('invoice_inbox_items')
.select('id, document_id, created_journal_entry_id, created_supplier_invoice_id')
.eq('id', inboxItemId)
.eq('company_id', companyId)
.single()
if (inboxErr || !inbox) {
throw new Error(`Inbox item ${inboxItemId} not found for this company.`)
}
if (inbox.created_journal_entry_id) {
throw new Error(
`Inbox item is already booked as journal entry ${inbox.created_journal_entry_id}. ` +
'Use gnubok_correct_entry or gnubok_reverse_entry if it needs to be changed.'
)
}
if (inbox.created_supplier_invoice_id) {
throw new Error(
`Inbox item is already converted to supplier invoice ${inbox.created_supplier_invoice_id}. ` +
'Cancel that path before booking it as a verifikat.'
)
}
inboxDocumentId = (inbox.document_id as string | null) ?? null
}
// NOTE: source_type is intentionally NOT included in the staged params.
// The executor hardcodes 'manual' so a tampered or future direct-staged
// pending_operations row can't misrepresent the entry's origin.
return stagePendingOperation(supabase, companyId, userId, 'create_voucher',
`Manuell verifikation: ${description}`,
{
entry_date: entryDate,
description,
fiscal_period_id: fiscalPeriodId,
voucher_series: (args.voucher_series as string) || undefined,
notes: (args.notes as string) || undefined,
inbox_item_id: inboxItemId,
document_id: inboxDocumentId,
lines,
},
{
entry_date: entryDate,
description,
fiscal_period_id: fiscalPeriodId,
voucher_series: (args.voucher_series as string) || 'A',
total_debit: balance.totalDebit,
total_credit: balance.totalCredit,
line_count: lines.length,
lines: previewLines,
inbox_item_id: inboxItemId,
document_attached: Boolean(inboxDocumentId),
will: inboxItemId
? 'create a posted journal entry with a fresh sequential voucher number, link the inbox item to it, and attach the OCR document to the verifikat'
: 'create a posted journal entry with a fresh sequential voucher number',
},
actor,
{
description: 'After commit, confirm the new verifikation lands on the right accounts with gnubok_get_general_ledger or gnubok_query_journal.',
tool: 'gnubok_query_journal',
},
{ dateForPeriodCheck: entryDate },
)
},
},
{
name: 'gnubok_correct_entry',
description: 'Stage a rättelse for a posted verifikation per BFL 5 kap 5§ — storno + new corrected entry in the original period (never in-place edit). Use for partial fixes like 2641 → 2614/2645 while preserving the expense leg. Account drives momsdeklaration ruta, not tax_code. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
lines: {
type: 'array',
description: 'Replacement lines (≥ 2, balanced). Use the same accounts as the original where unchanged.',
items: {
type: 'object',
properties: {
account_number: { type: 'string' },
debit_amount: { type: 'number' },
credit_amount: { type: 'number' },
line_description: { type: 'string' },
currency: { type: 'string' },
amount_in_currency: { type: 'number' },
exchange_rate: { type: 'number' },
tax_code: { type: 'string', description: 'Free-text tag — does NOT drive momsdeklaration ruta. Pick the correct BAS account first.' },
cost_center: { type: 'string' },
project: { type: 'string' },
},
required: ['account_number'],
},
},
},
required: ['entry_id', 'lines'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const entryRef = args.entry_id as string
const rawLines = args.lines as Array<Record<string, unknown>> | undefined
if (!entryRef || !Array.isArray(rawLines) || rawLines.length < 2) {
throw new Error('entry_id and at least two lines are required')
}
const lines = rawLines.map((l) => ({
account_number: String(l.account_number ?? ''),
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
line_description: l.line_description ? String(l.line_description) : undefined,
currency: l.currency ? String(l.currency) : undefined,
amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined,
exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined,
tax_code: l.tax_code ? String(l.tax_code) : undefined,
cost_center: l.cost_center ? String(l.cost_center) : undefined,
project: l.project ? String(l.project) : undefined,
}))
const balance = validateBalance(lines)
if (!balance.valid) {
throw new Error(
`Correction lines not balanced: debits ${balance.totalDebit}, credits ${balance.totalCredit}. ` +
'Both must be positive and equal.'
)
}
const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef)
// Pre-flight: the executor checks again, but failing fast here gives the
// agent a clearer error message than waiting until commit-time.
// The Supabase types don't infer through `fiscal_periods!inner(...)`,
// so we type the row shape manually rather than fight the generics.
type OriginalRow = {
id: string
status: string
entry_date: string
description: string
voucher_number: number
voucher_series: string
fiscal_period_id: string
fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null
lines: Array<{
account_number: string
debit_amount: number | string
credit_amount: number | string
line_description: string | null
}> | null
}
const { data, error: origErr } = await supabase
.from('journal_entries')
.select(
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' +
'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description)'
)
.eq('id', entryId)
.eq('company_id', companyId)
.maybeSingle()
const original = data as OriginalRow | null
if (origErr) {
throw new Error(`Database error looking up journal entry ${entryId}: ${origErr.message}`)
}
if (!original) {
throw new Error(
`Journal entry not found: id=${entryId}. ` +
`If this UUID came from an earlier tool result, re-fetch via gnubok_query_journal — ` +
`UUIDs are frequently hallucinated when reused across turns. You can also pass a voucher ref like "A-113".`
)
}
if (original.status !== 'posted') {
throw new Error(`Only posted entries can be corrected. Current status: ${original.status}.`)
}
const periodInfo = Array.isArray(original.fiscal_periods)
? original.fiscal_periods[0]
: original.fiscal_periods
if (periodInfo?.is_closed || periodInfo?.locked_at) {
throw new Error(
`Fiscal period "${periodInfo.name ?? 'okänd'}" is locked or closed. Unlock the period, or use omprövning for already-filed VAT.`
)
}
const originalLines = original.lines || []
return stagePendingOperation(supabase, companyId, userId, 'correct_entry',
`Rättelse: V${original.voucher_series}${original.voucher_number} — ${original.description}`,
{
entry_id: entryId,
lines,
},
{
original: {
entry_id: entryId,
voucher: `${original.voucher_series}${original.voucher_number}`,
entry_date: original.entry_date,
description: original.description,
lines: originalLines.map((l) => ({
account_number: l.account_number,
debit_amount: Number(l.debit_amount),
credit_amount: Number(l.credit_amount),
line_description: l.line_description,
})),
},
correction: {
total_debit: balance.totalDebit,
total_credit: balance.totalCredit,
line_count: lines.length,
lines: lines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
})),
},
will: 'post a storno that mirrors the original, then post a new corrected entry, then mark the original as reversed (BFL 5 kap 5§)',
},
actor,
{
description: 'After commit, the original is marked reversed and a corrected verifikation lands in its place. Confirm both with gnubok_query_journal.',
tool: 'gnubok_query_journal',
},
{ dateForPeriodCheck: original.entry_date },
)
},
},
{
name: 'gnubok_reverse_journal_entry',
description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Use only when the affärshändelse should never have been booked (duplicate, ghost, test). If booked wrong, use gnubok_correct_entry; for refunds, gnubok_credit_invoice. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to today (Swedish timezone). Period attribution always follows the original entry, regardless of this date.' },
reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason — shown in pending_operations review. Not stored on the storno itself. Max 500 chars.' },
},
required: ['entry_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const entryRef = args.entry_id as string
const reversalDate = typeof args.reversal_date === 'string' ? args.reversal_date : undefined
const reason = typeof args.reason === 'string' ? args.reason : undefined
if (!entryRef) {
throw new Error('entry_id is required')
}
// Belt-and-braces runtime check: inputSchema declares the pattern, but the
// MCP dispatcher does not always enforce it — validate again here so a
// malformed date never reaches the pending_operations payload.
if (reversalDate !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(reversalDate)) {
throw new Error('reversal_date must be ISO yyyy-MM-dd')
}
if (reason !== undefined && reason.length > 500) {
throw new Error('reason must be 500 characters or fewer')
}
const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef)
// Pre-flight mirrors commitReverseEntry: posted + period not closed/locked.
// Failing fast gives a clearer Swedish error than waiting until commit-time.
// Both is_closed and locked_at are checked so the staging-time signal
// matches the commit-time gate; without locked_at, an agent could see
// staged:true with period_status:locked and only discover the rejection
// at commit time.
type OriginalRow = {
id: string
status: string
entry_date: string
description: string
voucher_number: number
voucher_series: string
fiscal_period_id: string
fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null
lines: Array<{
account_number: string
debit_amount: number | string
credit_amount: number | string
line_description: string | null
}> | null
}
const { data, error: origErr } = await supabase
.from('journal_entries')
.select(
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' +
'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description)'
)
.eq('id', entryId)
.eq('company_id', companyId)
.maybeSingle()
const original = data as OriginalRow | null
if (origErr) {
throw new Error(`Database error looking up journal entry ${entryId}: ${origErr.message}`)
}
if (!original) {
throw new Error(
`Journal entry not found: id=${entryId}. ` +
`If this UUID came from an earlier tool result, re-fetch via gnubok_query_journal — ` +
`UUIDs are frequently hallucinated when reused across turns. You can also pass a voucher ref like "A-113".`
)
}
if (original.status !== 'posted') {
throw new Error(`Only posted entries can be reversed. Current status: ${original.status}.`)
}
const periodInfo = Array.isArray(original.fiscal_periods)
? original.fiscal_periods[0]
: original.fiscal_periods
if (periodInfo?.is_closed || periodInfo?.locked_at) {
throw new Error(
`Fiscal period "${periodInfo.name ?? 'okänd'}" is locked or closed. Unlock the period, or use omprövning for already-filed VAT.`
)
}
const originalLines = original.lines || []
const reversedPreviewLines = originalLines.map((l) => ({
account_number: l.account_number,
debit_amount: Number(l.credit_amount),
credit_amount: Number(l.debit_amount),
line_description: `Reversal: ${l.line_description ?? ''}`,
}))
// If the original touches output/input VAT accounts (2610–2670), a storno
// is correct ONLY if the moms period covering entry_date has not yet been
// filed with Skatteverket. For filed periods the legal path is an
// omprövning (rättelse-omprövning per ML 2023:200, SFL 22 kap). gnubok
// doesn't track per-VAT-period filing status today, so we surface a
// soft warning rather than block — the human approver decides.
const vatAccounts = originalLines
.map((l) => l.account_number)
.filter((acc) => /^26[1-7]\d$/.test(acc))
const vatWarning = vatAccounts.length > 0
? `Original innehåller momskonton (${[...new Set(vatAccounts)].join(', ')}). Om momsperioden är inlämnad till Skatteverket krävs omprövning (ML 2023:200) — storno räcker inte. Bekräfta att perioden inte är inlämnad innan godkännande.`
: null
return stagePendingOperation(supabase, companyId, userId, 'reverse_entry',
`Makulering: V${original.voucher_series}${original.voucher_number} — ${original.description}`,
{
entry_id: entryId,
reversal_date: reversalDate,
},
{
original: {
entry_id: entryId,
voucher: `${original.voucher_series}${original.voucher_number}`,
entry_date: original.entry_date,
description: original.description,
lines: originalLines.map((l) => ({
account_number: l.account_number,
debit_amount: Number(l.debit_amount),
credit_amount: Number(l.credit_amount),
line_description: l.line_description,
})),
},
reversal: {
entry_date: reversalDate ?? null,
fiscal_period_id: original.fiscal_period_id,
line_count: reversedPreviewLines.length,
lines: reversedPreviewLines,
},
reason: reason ?? null,
...(vatWarning ? { warnings: [vatWarning] } : {}),
will: 'post a storno that mirrors the original with debits and credits swapped, link via reverses_id, and leave the original visible (BFL 5 kap, makulering)',
},
actor,
{
description: 'After commit, the storno is posted and the original stays visible. Confirm with gnubok_query_journal.',
tool: 'gnubok_query_journal',
},
{ dateForPeriodCheck: original.entry_date },
)
},
},
// ─── Phase 4-7: bokslut wizard surfaces exposed to agents ───────────
{
name: 'gnubok_propose_dispositioner',
description:
'Read-only proposal of bokslutsdispositioner for a fiscal period: periodiseringsfond (avsättning + obligatorisk återföring), överavskrivningar, SLP, bolagsskatt. Call before staging postings.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
// Output is the same DispositionsProposal shape returned by GET
// /bokslutsdispositioner — surface as a permissive object so the
// strict-schema test passes without duplicating the type tree here.
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { buildDispositionsProposal } = await import('@/lib/bokslut/dispositions-proposal-builder')
return buildDispositionsProposal(supabase, companyId, fiscalPeriodId)
},
},
{
name: 'gnubok_propose_accruals',
description:
'Read-only proposal of periodiseringar (förutbetalda/upplupna kostnader). Currently surfaces the vacation-liability change; manual prepaid/accrued entries are submitted by the UI form.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { buildAccrualsProposal } = await import('@/lib/bokslut/accruals/accrual-detector')
return buildAccrualsProposal(supabase, companyId, fiscalPeriodId)
},
},
{
name: 'gnubok_propose_annual_depreciation',
description:
'Read-only per-asset planenlig avskrivning proposal for a fiscal period. Reads the asset register and existing depreciation schedules. Call before staging the post.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { proposeAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine')
return proposeAnnualPostings(supabase, companyId, fiscalPeriodId)
},
},
{
name: 'gnubok_post_annual_depreciation',
description:
'Stage planenlig avskrivning posts — one journal entry per asset for independent reversibility. Mid-risk, always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
asset_ids: {
type: 'array',
items: { type: 'string' },
description: 'Optional whitelist of asset UUIDs to post; omit to post all proposed.',
},
},
required: ['fiscal_period_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined
// Mirror the HTTP route's `requireWrite: true` guard so a viewer-role
// member can't post depreciation through the MCP surface. RLS would
// reject the underlying INSERTs anyway, but failing fast here
// produces a much cleaner error than the cascaded RLS rejection.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!membership || membership.role === 'viewer') {
throw new Error('Write permission required')
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found')
if (period.is_closed || period.closing_entry_id || period.locked_at) {
throw new Error('Period is locked or closed')
}
const { proposeAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine')
const proposal = await proposeAnnualPostings(supabase, companyId, fiscalPeriodId)
const filtered = assetIds
? proposal.items.filter((i) => assetIds.includes(i.asset.id))
: proposal.items
const pending = filtered.filter((i) => !i.existingJournalEntryId)
const totalAmount = pending.reduce((s, i) => s + i.amount, 0)
return stagePendingOperation(
supabase, companyId, userId, 'post_annual_depreciation',
`Planenlig avskrivning: ${period.name} — ${pending.length} tillgång(ar), ${Math.round(totalAmount * 100) / 100} SEK`,
{ fiscal_period_id: fiscalPeriodId, asset_ids: assetIds },
{
period_name: period.name,
item_count: pending.length,
total_amount: totalAmount,
will: `book ${pending.length} planenlig avskrivning(ar) — one journal entry per asset`,
items: pending.map((i) => ({
asset_id: i.asset.id,
asset_name: i.asset.name,
amount: i.amount,
pro_rated: i.proRated,
})),
},
actor,
{
description: 'After approval, depreciation entries are posted. Continue the year-end flow via gnubok_year_end_readiness, then gnubok_run_year_end.',
tool: 'gnubok_year_end_readiness',
args: { fiscal_period_id: fiscalPeriodId },
},
{ dateForPeriodCheck: period.period_end },
)
},
},
{
name: 'gnubok_preview_arsredovisning',
description:
'Read-only K2 årsredovisning preview for a fiscal period. Returns flerårsöversikt, eget-kapital-förändring, RR, BR, K2 noter, signature slots. PDF download is via UI.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { buildArsredovisningData } = await import('@/lib/bokslut/arsredovisning/build-data')
return buildArsredovisningData(supabase, companyId, fiscalPeriodId)
},
},
{
name: 'gnubok_preview_ef_declaration',
description:
'Read-only EF declaration preview: egenavgifter schablonavdrag, räntefördelning, periodiseringsfond, expansionsfond. All declaration-only, never booked. Pass kapitalunderlag and prior-year amounts as inputs.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
category: {
type: 'string',
enum: ['full', 'pensioner', 'passive'],
description: 'Egenavgifter category — defaults to "full"',
},
kapitalunderlag: { type: 'number', description: 'Justerat eget kapital vid föregående års utgång (default 0)' },
prior_year_schablonavdrag: { type: 'number' },
prior_year_actual_charged: { type: 'number' },
pfond_desired_amount: { type: 'number' },
expansionsfond_existing_balance: { type: 'number' },
expansionsfond_desired_change: { type: 'number' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { computeEfDeclarationPreview } = await import('@/lib/bokslut/enskild-firma/ef-declaration-preview')
return computeEfDeclarationPreview(supabase, companyId, fiscalPeriodId, {
category: args.category as 'full' | 'pensioner' | 'passive' | undefined,
kapitalunderlag: args.kapitalunderlag as number | undefined,
priorYearSchablonavdrag: args.prior_year_schablonavdrag as number | undefined,
priorYearActualCharged: args.prior_year_actual_charged as number | undefined,
pfondDesiredAmount: args.pfond_desired_amount as number | undefined,
expansionsfondExistingBalance: args.expansionsfond_existing_balance as number | undefined,
expansionsfondDesiredChange: args.expansionsfond_desired_change as number | undefined,
})
},
},
// ── Pending operations: list / approve / reject ──────────────
// Mirrors the /pending web UI for agents that self-review before committing.
{
name: 'gnubok_list_pending_operations',
description: 'List staged pending_operations. Filter by status (default pending), risk_level, or operation_type. Use to review the queue before calling gnubok_approve_pending_operation or gnubok_reject_pending_operation.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: { type: 'string', enum: ['pending', 'committing', 'committed', 'rejected'], description: 'Default: pending' },
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] },
operation_type: { type: 'string', description: 'Filter to a single operation_type (e.g. "create_invoice")' },
limit: { type: 'number', minimum: 1, maximum: 200, description: 'Default 50' },
offset: { type: 'number', minimum: 0, description: 'Default 0' },
},
required: [],
},
outputSchema: paginatedSchema('operations'),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const status = (args.status as string) ?? 'pending'
const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50))
const offset = Math.max(0, (args.offset as number) ?? 0)
// `params` holds the raw operation inputs (invoice line items, supplier
// PII, voucher descriptions) — excluded from the list response to
// satisfy data-minimisation (GDPR Art. 5(1)(b)). Use preview_data for
// a redacted, human-readable summary, or call the underlying entity
// endpoint when the agent needs the full payload.
let query = supabase
.from('pending_operations')
.select(
'id, operation_type, title, preview_data, status, risk_level, actor_type, actor_id, actor_label, created_at, resolved_at, result_data',
{ count: 'exact' }
)
.eq('company_id', companyId)
.eq('status', status)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (args.risk_level) query = query.eq('risk_level', args.risk_level as string)
if (args.operation_type) query = query.eq('operation_type', args.operation_type as string)
const { data, error, count } = await query
if (error) throw new Error(`Failed to list pending operations: ${error.message}`)
const operations = data ?? []
const totalCount = count ?? operations.length
const hasMore = offset + operations.length < totalCount
return {
operations,
count: operations.length,
total_count: totalCount,
has_more: hasMore,
...(hasMore ? { next_offset: offset + operations.length } : {}),
}
},
},
{
name: 'gnubok_approve_pending_operation',
description: "Commit a staged pending_operation when the user has explicitly authorised the operation_id. risk_level=high requires confirmed=true — surface the BFL 5 kap 5§ irreversibility to the user first. The /pending web UI offers an equivalent commit path.",
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
operation_id: { type: 'string', description: 'UUID of the pending_operations row to approve' },
confirmed: {
type: 'boolean',
description: 'Required when the operation has risk_level=high (create_voucher, correct_entry, reverse_entry, year-end, period lock/close). Acknowledges the BFL/BFNAR irreversibility implications. The web UI surfaces the same gate via an explicit warning dialog.',
},
},
required: ['operation_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: { type: 'string', enum: ['committed', 'rejected', 'failed'] },
operation_id: { type: 'string' },
data: { type: 'object' },
error: { type: 'string' },
auto_rejected: { type: 'boolean' },
},
required: ['status', 'operation_id'],
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const operationId = args.operation_id as string
if (!operationId) throw new Error('operation_id is required')
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('*')
.eq('id', operationId)
.eq('company_id', companyId)
.single()
if (fetchError || !op) throw new Error('Pending operation not found')
// High-risk operations require explicit confirmation in addition to the
// standard pending_operations:approve scope. Mirrors the web-UI gate
// (BFL 5 kap 5§ — irreversible postings require positive acknowledgment).
const operation = op as PendingOperation
if (operation.risk_level === 'high' && args.confirmed !== true) {
throw new Error(
`Operation "${operation.operation_type}" is risk_level=high — pass confirmed=true to approve. The web UI requires the same positive acknowledgment per BFL 5 kap 5§ (irreversible postings).`
)
}
// Resolve the user's email so commitPendingOperation can attribute the
// journal_entries.committed_by_email and any user-facing email side
// effects (send_invoice cc) to the actor — matches the web-UI commit
// path attribution (V8.2.1, GDPR Art. 25(1)).
let userEmail: string | undefined
try {
const { data: userData } = await supabase.auth.admin.getUserById(userId)
userEmail = userData.user?.email ?? undefined
} catch (err) {
log.warn('Failed to resolve user email for MCP approval', { userId, err })
}
const result = await commitPendingOperation(
supabase,
userId,
companyId,
operation,
{ commitMethod: 'user_accept', ...(userEmail ? { userEmail } : {}) }
)
// Audit the MCP-initiated approval. Failure must not break the user
// flow — the side-effects have already happened.
try {
await appendProcessingHistory({
companyId,
correlationId: operationId,
aggregateType: 'System',
aggregateId: operationId,
eventType: 'PendingOperationApproved',
payload: {
operation_id: operationId,
operation_type: operation.operation_type,
risk_level: operation.risk_level,
outcome: result.status,
commit_method: 'user_accept',
channel: 'mcp',
confirmed: args.confirmed === true,
},
actor: {
type: actor?.type === 'api_key' ? 'api_key' : 'user',
id: actor?.id ?? userId,
...(actor?.label ? { label: actor.label } : {}),
},
occurredAt: new Date(),
})
} catch (auditErr) {
log.warn('Failed to append PendingOperationApproved audit event', auditErr)
}
return {
status: result.status,
operation_id: operationId,
...(result.data ? { data: result.data } : {}),
...(result.error ? { error: result.error } : {}),
...(result.auto_rejected ? { auto_rejected: true } : {}),
}
},
},
{
name: 'gnubok_reject_pending_operation',
description: 'Reject a staged pending_operation without executing it. Status flips to rejected; no journal entries, invoices, or other side-effects created. Idempotent on already-resolved ops (returns 409).',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
operation_id: { type: 'string', description: 'UUID of the pending_operations row to reject' },
reason: {
type: 'string',
description: 'Optional human-readable reason recorded in result_data for the audit trail',
maxLength: 500,
},
},
required: ['operation_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: { type: 'string', enum: ['rejected'] },
operation_id: { type: 'string' },
},
required: ['status', 'operation_id'],
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const operationId = args.operation_id as string
if (!operationId) throw new Error('operation_id is required')
const reason = typeof args.reason === 'string' ? args.reason.slice(0, 500) : undefined
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('id, status, operation_type, risk_level')
.eq('id', operationId)
.eq('company_id', companyId)
.single()
if (fetchError || !op) throw new Error('Pending operation not found')
if (op.status !== 'pending') throw new Error(`Operation already ${op.status}`)
// Atomic claim — flips pending → rejected only when the row is still
// pending AND in the caller's tenant (V8.3.1, CC6.3 tenant isolation).
// The .eq('status', 'pending') guard makes this a CAS so a concurrent
// approval cannot lose to a parallel reject.
const { data: updated, error: updateError } = await supabase
.from('pending_operations')
.update({
status: 'rejected',
resolved_at: new Date().toISOString(),
result_data: {
rejected_by: userId,
rejected_via: actor?.type ?? 'user',
...(actor?.id ? { actor_id: actor.id } : {}),
...(reason ? { reason } : {}),
},
})
.eq('id', operationId)
.eq('company_id', companyId)
.eq('status', 'pending')
.select('id')
if (updateError) throw new Error(`Failed to reject operation: ${updateError.message}`)
if (!updated || updated.length === 0) {
throw new Error('Operation no longer pending — another caller claimed it')
}
// Audit the rejection so the trail mirrors the approval path.
try {
await appendProcessingHistory({
companyId,
correlationId: operationId,
aggregateType: 'System',
aggregateId: operationId,
eventType: 'PendingOperationRejected',
payload: {
operation_id: operationId,
operation_type: op.operation_type,
risk_level: op.risk_level,
channel: 'mcp',
...(reason ? { has_reason: true } : { has_reason: false }),
},
actor: {
type: actor?.type === 'api_key' ? 'api_key' : 'user',
id: actor?.id ?? userId,
...(actor?.label ? { label: actor.label } : {}),
},
occurredAt: new Date(),
})
} catch (auditErr) {
log.warn('Failed to append PendingOperationRejected audit event', auditErr)
}
return { status: 'rejected' as const, operation_id: operationId }
},
},
// ── Bring-your-own-extraction for inbox items ────────────────
{
name: 'gnubok_set_inbox_extracted_data',
description: 'Replace extracted_data on an inbox item with agent-supplied fields (bring-your-own-extraction). Use when your own pipeline parses the document better than gnubok\'s OCR. Follow with gnubok_create_supplier_invoice_from_inbox to stage.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
inbox_item_id: { type: 'string', description: 'UUID of the invoice_inbox_items row' },
extracted_data: {
type: 'object',
description: 'Full InvoiceExtractionResult (supplier, invoice, lineItems, totals, vatBreakdown). Validated server-side via the same Zod schema as the AI extractor.',
},
},
required: ['inbox_item_id', 'extracted_data'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
inbox_item_id: { type: 'string' },
matched_supplier_id: { type: ['string', 'null'] },
extracted_data: { type: 'object' },
},
required: ['inbox_item_id', 'extracted_data'],
},
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const inboxItemId = args.inbox_item_id as string
if (!inboxItemId) throw new Error('inbox_item_id is required')
const parsed = InvoiceExtractionSchema.parse(args.extracted_data)
// BYO extraction: confidence 0.95 marks the result as agent-supplied
// (vs 1.0 the AI extractor uses on a perfect parse) so downstream UI
// can render the provenance differently (ISO 27001 A.8.12).
const extracted = { ...parsed, confidence: 0.95 }
const { data: item, error: fetchError } = await supabase
.from('invoice_inbox_items')
.select('id, company_id, created_supplier_invoice_id')
.eq('id', inboxItemId)
.eq('company_id', companyId)
.maybeSingle()
if (fetchError) throw new Error(`Failed to fetch inbox item: ${fetchError.message}`)
if (!item) throw new Error('Inbox item not found')
// Explicit defense-in-depth tenant check (V4.5.1) alongside the .eq()
// filter on the SELECT — surfaces a tampered service-role query
// before it reaches the UPDATE.
if (item.company_id !== companyId) {
throw new Error('Inbox item belongs to a different company')
}
if (item.created_supplier_invoice_id) {
throw new Error('Inbox item is already linked to a supplier invoice and cannot be modified')
}
// Re-run supplier match so agent-supplied fields trigger the same
// auto-link the AI path does (org-nr → name, ILIKE).
let matchedSupplierId: string | null = null
if (extracted.supplier.orgNumber) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
if (!matchedSupplierId && extracted.supplier.name) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.ilike('name', extracted.supplier.name)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
const { error: updateError } = await supabase
.from('invoice_inbox_items')
.update({
extracted_data: extracted as unknown as Record<string, unknown>,
matched_supplier_id: matchedSupplierId,
})
.eq('id', inboxItemId)
.eq('company_id', companyId)
if (updateError) throw new Error(`Failed to update inbox item: ${updateError.message}`)
// Audit the BYO override so financial-data provenance is traceable
// (GDPR Art. 5(1)(f), SOC 2 CC9.2). Failure must not block the user
// flow — the override has already landed in the DB.
try {
await appendProcessingHistory({
companyId,
correlationId: inboxItemId,
aggregateType: 'Document',
aggregateId: inboxItemId,
eventType: 'DocumentExtractionOverridden',
payload: {
inbox_item_id: inboxItemId,
channel: 'mcp',
has_supplier_org_number: extracted.supplier.orgNumber != null,
has_invoice_number: extracted.invoice.invoiceNumber != null,
extracted_total: extracted.totals.total,
matched_supplier_id: matchedSupplierId,
},
actor: {
type: actor?.type === 'api_key' ? 'api_key' : 'user',
id: actor?.id ?? userId,
...(actor?.label ? { label: actor.label } : {}),
},
occurredAt: new Date(),
})
} catch (auditErr) {
log.warn('Failed to append DocumentExtractionOverridden audit event', auditErr)
}
return {
inbox_item_id: inboxItemId,
matched_supplier_id: matchedSupplierId,
extracted_data: extracted as unknown as Record<string, unknown>,
}
},
},
]
// ── MCP Protocol Handler ─────────────────────────────────────
const SERVER_INFO = {
name: 'gnubok',
version: '1.0.0',
}
const PROTOCOL_VERSION = '2025-06-18'
function jsonRpc(id: string | number | null, result: unknown): JsonRpcResponse {
return { jsonrpc: '2.0', id, result }
}
function jsonRpcError(
id: string | number | null,
code: number,
message: string,
data?: unknown
): JsonRpcResponse {
return { jsonrpc: '2.0', id, error: { code, message, data } }
}
/**
* Emit `mcp.tool_called` telemetry to the event bus. Fire-and-forget — the
* dispatcher must never block the JSON-RPC response on telemetry, and a failing
* handler must never surface to the client. The event bus already isolates
* handlers via Promise.allSettled, but we belt-and-braces here too.
*/
function emitToolCallTelemetry(payload: {
tool: string
requiredScope: string | null
actor: ActorContext
latencyMs: number
success: boolean
isError: boolean
errorCode: string | null
errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null
requestId: string | number | null
userId: string
companyId: string
}): void {
void eventBus
.emit({
type: 'mcp.tool_called',
payload: {
tool: payload.tool,
requiredScope: payload.requiredScope,
actorType: payload.actor.type,
actorId: payload.actor.id ?? null,
actorLabel: payload.actor.label ?? null,
latencyMs: payload.latencyMs,
success: payload.success,
isError: payload.isError,
errorCode: payload.errorCode,
errorKind: payload.errorKind,
requestId: payload.requestId,
userId: payload.userId,
companyId: payload.companyId,
sessionId: payload.actor.sessionId ?? null,
},
})
.catch((err) => {
// Last-resort guard. EventBus.emit already swallows handler failures,
// but if the bus itself is in a bad state we still don't want to break tools.
console.error('[mcp] tool_called telemetry emit failed:', err)
})
}
/** Fire-and-forget telemetry for a tools/list call. */
function emitToolsListTelemetry(payload: {
toolCount: number
actor: ActorContext
latencyMs: number
requestId: string | number | null
userId: string
companyId: string
}): void {
void eventBus
.emit({
type: 'mcp.tools_list_called',
payload: {
toolCount: payload.toolCount,
actorType: payload.actor.type,
actorId: payload.actor.id ?? null,
actorLabel: payload.actor.label ?? null,
latencyMs: payload.latencyMs,
requestId: payload.requestId,
userId: payload.userId,
companyId: payload.companyId,
sessionId: payload.actor.sessionId ?? null,
},
})
.catch((err) => {
console.error('[mcp] tools_list_called telemetry emit failed:', err)
})
}
/** Fire-and-forget telemetry for a resources/read call. */
function emitResourceReadTelemetry(payload: {
uri: string
kind: 'widget' | 'skill' | 'data' | 'unknown'
success: boolean
errorCode: string | null
actor: ActorContext
latencyMs: number
requestId: string | number | null
userId: string
companyId: string
}): void {
void eventBus
.emit({
type: 'mcp.resource_read',
payload: {
uri: payload.uri,
kind: payload.kind,
success: payload.success,
errorCode: payload.errorCode,
latencyMs: payload.latencyMs,
actorType: payload.actor.type,
actorId: payload.actor.id ?? null,
actorLabel: payload.actor.label ?? null,
requestId: payload.requestId,
userId: payload.userId,
companyId: payload.companyId,
sessionId: payload.actor.sessionId ?? null,
},
})
.catch((err) => {
console.error('[mcp] resource_read telemetry emit failed:', err)
})
}
/**
* Per-session ring of "what was the most recent tool call, and what did its
* response suggest as the `next` tool?" Used to detect `mcp.next_hint_followed`
* when the agent's next call matches the previous nextHint.tool.
*
* In-memory only. Single-process visibility is acceptable for telemetry — a
* miss in a multi-instance deploy only loses signal, never blocks a tool call.
* Entries auto-expire after NEXT_HINT_TTL_MS to keep the map bounded.
*/
const NEXT_HINT_TTL_MS = 10 * 60 * 1000
const lastResponseHintBySession = new Map<string, { fromTool: string; suggestedTool: string; expiresAt: number }>()
function rememberNextHint(sessionId: string | null | undefined, fromTool: string, suggestedTool: string | undefined): void {
if (!sessionId || !suggestedTool) return
// Opportunistic eviction: drop a few expired entries on each write so the
// map can't grow without bound under steady load.
if (lastResponseHintBySession.size > 200) {
const now = Date.now()
for (const [k, v] of lastResponseHintBySession) {
if (v.expiresAt < now) {
lastResponseHintBySession.delete(k)
if (lastResponseHintBySession.size < 100) break
}
}
}
lastResponseHintBySession.set(sessionId, {
fromTool,
suggestedTool,
expiresAt: Date.now() + NEXT_HINT_TTL_MS,
})
}
function checkAndEmitNextHintFollowed(
sessionId: string | null | undefined,
toolName: string,
actor: ActorContext,
userId: string,
companyId: string,
): void {
if (!sessionId) return
const prev = lastResponseHintBySession.get(sessionId)
if (!prev || prev.expiresAt < Date.now() || prev.suggestedTool !== toolName) return
// Consume the hint so we don't double-count if the agent calls the same
// tool twice in a row (idempotent retries shouldn't inflate the metric).
lastResponseHintBySession.delete(sessionId)
void eventBus
.emit({
type: 'mcp.next_hint_followed',
payload: {
fromTool: prev.fromTool,
toTool: toolName,
sessionId,
actorType: actor.type,
actorId: actor.id ?? null,
actorLabel: actor.label ?? null,
userId,
companyId,
},
})
.catch((err) => console.error('[mcp] next_hint_followed emit failed:', err))
}
/** Fire-and-forget telemetry for workflow lifecycle. */
function emitWorkflowStarted(payload: {
slug: string
actor: ActorContext
userId: string
companyId: string
}): void {
void eventBus
.emit({
type: 'mcp.workflow_started',
payload: {
slug: payload.slug,
sessionId: payload.actor.sessionId ?? null,
actorType: payload.actor.type,
actorId: payload.actor.id ?? null,
actorLabel: payload.actor.label ?? null,
userId: payload.userId,
companyId: payload.companyId,
},
})
.catch((err) => console.error('[mcp] workflow_started emit failed:', err))
}
/**
* Handle an MCP JSON-RPC request.
* Auth is done via Bearer API key (extension route has skipAuth: true).
*/
export async function handleMcpRequest(request: Request): Promise<Response> {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const wwwAuth = `Bearer resource_metadata="${appUrl}/.well-known/oauth-protected-resource"`
// ── Pre-auth: handle fire-and-forget notifications before auth check ──
// MCP notifications have no id and don't expect error responses.
// Checking auth on them would return 401 which confuses clients.
const clonedRequest = request.clone()
try {
const peek = await clonedRequest.json()
if (peek.method === 'notifications/initialized') {
return new Response(null, { status: 202 })
}
} catch {
// Not valid JSON — fall through to auth + parse below
}
// ── Auth ──
const token = extractBearerToken(request)
if (!token) {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': wwwAuth },
})
}
const authResult = await validateApiKey(token)
if ('error' in authResult) {
const status = authResult.status
if (status === 429) {
return new Response(authResult.error, {
status: 429,
headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' },
})
}
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': wwwAuth },
})
}
const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName } = authResult
const supabase = createServiceClientNoCookies()
// The Mcp-Session-Id header (introduced in spec 2025-06-18) is the canonical
// way for an agent to keep a stable identifier across tools/call invocations
// in one conversation. We use it to correlate telemetry + drive the next-hint
// followed metric. It is NOT used for auth.
const rawSessionId = request.headers.get('mcp-session-id')
const sessionId = rawSessionId && /^[A-Za-z0-9_-]{1,128}$/.test(rawSessionId) ? rawSessionId : null
const actor: ActorContext = {
type: 'api_key',
id: apiKeyId,
label: apiKeyName ?? 'Unnamed API key',
sessionId,
}
// ── Parse JSON-RPC ──
let body: JsonRpcRequest
try {
body = await request.json()
} catch {
return NextResponse.json(
jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'),
{ status: 400 }
)
}
if (body.jsonrpc !== '2.0' || !body.method) {
return NextResponse.json(
jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'),
{ status: 400 }
)
}
// ── Dispatch ──
const { method, id, params } = body
switch (method) {
case 'initialize': {
const SUPPORTED_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05'])
const clientVersion = (params as Record<string, unknown>)?.protocolVersion as string | undefined
const negotiatedVersion =
clientVersion && SUPPORTED_VERSIONS.has(clientVersion) ? clientVersion : PROTOCOL_VERSION
return NextResponse.json(
jsonRpc(id ?? null, {
protocolVersion: negotiatedVersion,
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
prompts: { listChanged: false },
},
serverInfo: SERVER_INFO,
instructions: [
'gnubok — Swedish double-entry bookkeeping via conversation.',
'',
'Discovery:',
'• tools/list returns the full schema for every tool. To narrow a large catalog, call gnubok_search_tools(query="…") — it ranks tools by relevance; pass detail="name"|"summary"|"full" to control payload size.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first — domain workflows are documented as loadable skills with tool references.',
'',
'Common workflows:',
'• Categorize transactions: gnubok_list_uncategorized_transactions → gnubok_suggest_categories → gnubok_categorize_transaction (stages) → gnubok_approve_pending_operation (after user confirms in chat).',
'• Applying income to invoices — pick by what you have: a specific bank transaction + a known invoice → gnubok_match_transaction_to_invoice; an invoice you know is paid but no specific bank line → gnubok_mark_invoice_as_paid; a whole period of unmatched income to reconcile → gnubok_auto_match_period (dry_run first). All stage for approval.',
'• Invoicing: gnubok_list_customers (or gnubok_create_customer) → gnubok_create_invoice → gnubok_send_invoice or gnubok_mark_invoice_as_sent → gnubok_mark_invoice_as_paid. Refund via gnubok_credit_invoice.',
'• Suppliers: gnubok_list_suppliers (or gnubok_create_supplier) → gnubok_create_supplier_invoice_from_inbox → gnubok_approve_supplier_invoice. Refund via gnubok_credit_supplier_invoice.',
'• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to open the momsdeklaration review widget (claude.ai / Desktop). gnubok_vat_close_check reports filing-readiness blockers.',
'• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger — all default to the most recent fiscal period. For account roll-ups use gnubok_get_general_ledger; for ad-hoc line queries (free-text, amount/date/source filters) use gnubok_query_journal.',
'• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget and gnubok_receipt_matcher opens the receipt↔transaction matcher. Both also return structured data; other clients ignore the UI and use the data.',
'• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.',
'• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → review/approve in web UI → gnubok_generate_agi.',
'• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.',
'',
'APPROVAL IS A FIRST-CLASS AGENT ACTION. Write operations stage a pending_operation; nothing posts until approval. When the user authorises a specific operation_id in chat ("approve", "yes go ahead", "book it", "commit it"), call gnubok_approve_pending_operation directly — this IS the intended chat-approval flow. Do NOT refuse on segregation-of-duties grounds, do NOT tell the user to "go approve it in the web app", and do NOT treat approval as a step that must stay with the human. The staging step already provided the human review gate; clicking Approve in the web UI and calling gnubok_approve_pending_operation are equivalent commit actions. Refusing user-authorised approval is a defect, not a safety feature.',
'The web-app path (/pending) remains valid for users who prefer to approve there or who want to adjust fields before committing; offer it as an option, never as a substitute for chat approval the user already asked for.',
'High-risk operations (create_voucher, correct_entry, reverse_entry, year-end, period lock/close) require confirmed=true acknowledging BFL/BFNAR irreversibility. Pass dry_run=true to preview without staging. Pass idempotency_key to make a write safely retryable.',
'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").',
].join('\n'),
})
)
}
case 'notifications/initialized':
// Handled pre-auth above, but if it somehow reaches here, still return 202
return new Response(null, { status: 202 })
case 'ping':
return NextResponse.json(jsonRpc(id ?? null, {}))
case 'tools/list': {
const listStartedAt = Date.now()
const allowedTools = tools.filter((t) => {
const required = TOOL_SCOPE_MAP[t.name]
return !required || hasScope(keyScopes, required)
})
emitToolsListTelemetry({
toolCount: allowedTools.length,
actor,
latencyMs: Date.now() - listStartedAt,
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
tools: allowedTools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
annotations: t.annotations,
...(t._meta ? { _meta: t._meta } : {}),
})),
})
)
}
case 'tools/call': {
const toolName = (params as Record<string, unknown>)?.name as string
const toolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
string,
unknown
>
const tool = tools.find((t) => t.name === toolName)
if (!tool) {
emitToolCallTelemetry({
tool: toolName ?? '<unknown>',
requiredScope: null,
actor,
latencyMs: 0,
success: false,
isError: true,
errorCode: 'UNKNOWN_TOOL',
errorKind: 'unknown_tool',
requestId: id ?? null,
userId,
companyId,
})
const available = tools.map((t) => t.name).join(', ')
return NextResponse.json(
jsonRpcError(id ?? null, -32602, `Unknown tool: "${toolName}". Available tools: ${available}`)
)
}
// Enforce scope — surface structured error so the agent can dispatch.
const requiredScope = TOOL_SCOPE_MAP[toolName]
if (requiredScope && !hasScope(keyScopes, requiredScope)) {
const scopeError = toToolError(
new Error(`Insufficient scope: this API key does not have the "${requiredScope}" scope`),
{ toolName }
)
emitToolCallTelemetry({
tool: toolName,
requiredScope,
actor,
latencyMs: 0,
success: false,
isError: true,
errorCode: scopeError.error.code,
errorKind: 'scope_denied',
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(scopeError, null, 2) }],
isError: true,
})
)
}
// Detect if THIS call follows the previous call's `next` hint — must
// run before execute() so we don't double-store on this call. Emits
// mcp.next_hint_followed when the agent's behaviour matches the hint.
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, companyId)
const callStartedAt = Date.now()
try {
// gnubok_search_tools needs the caller's scopes to filter results to
// what the API key can actually invoke. Inject privately via __keyScopes.
if (toolName === 'gnubok_search_tools') {
(toolArgs as Record<string, unknown>).__keyScopes = keyScopes
}
const result = await tool.execute(toolArgs, companyId, userId, supabase, actor)
const latencyMs = Date.now() - callStartedAt
const response: Record<string, unknown> = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
}
// Emit structuredContent for every tool — clients with outputSchema support
// can consume this directly without re-parsing the JSON-stringified text block.
// structuredContent must be an object, so wrap non-objects.
if (result !== null && result !== undefined) {
response.structuredContent =
typeof result === 'object' && !Array.isArray(result) ? result : { value: result }
}
// Result-level UI hint: render the widget only when the caller opted in
// via render_ui=true. This keeps the merged report+widget tool data-only
// by default and never sends a render directive a plain-data call didn't ask for.
if (tool.uiResourceUri && (toolArgs as Record<string, unknown>).render_ui === true) {
response._meta = { ui: { resourceUri: tool.uiResourceUri } }
}
// Record the response's `next.tool` (when present) so the next call
// from the same session can be matched against it.
if (result && typeof result === 'object' && !Array.isArray(result)) {
const next = (result as Record<string, unknown>).next
if (next && typeof next === 'object') {
const suggestedTool = (next as Record<string, unknown>).tool
if (typeof suggestedTool === 'string') {
rememberNextHint(sessionId, toolName, suggestedTool)
}
}
}
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
actor,
latencyMs,
success: true,
isError: false,
errorCode: null,
errorKind: null,
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(jsonRpc(id ?? null, response))
} catch (err) {
const latencyMs = Date.now() - callStartedAt
const structured = toToolError(err, { toolName })
emitToolCallTelemetry({
tool: toolName,
requiredScope: requiredScope ?? null,
actor,
latencyMs,
success: false,
isError: true,
errorCode: structured.error.code,
errorKind: 'execution',
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
isError: true,
})
)
}
}
case 'resources/list': {
const allSkills = await loadAllSkills(supabase)
return NextResponse.json(
jsonRpc(id ?? null, {
resources: [
...uiWidgets.map((w) => ({
uri: w.uri,
name: w.name,
description: w.description,
mimeType: WIDGET_MIME_TYPE,
})),
...allSkills.map((s) => ({
uri: skillUri(s.slug),
name: s.name,
description: s.summary,
mimeType: SKILL_MIME_TYPE,
})),
...dataResources.map((r) => ({
uri: r.uri,
name: r.name,
description: r.description,
mimeType: r.mimeType,
})),
],
})
)
}
case 'resources/read': {
const uri = (params as Record<string, unknown>)?.uri as string
const readStartedAt = Date.now()
const widget = findUiWidget(uri)
if (widget) {
emitResourceReadTelemetry({
uri,
kind: 'widget',
success: true,
errorCode: null,
actor,
latencyMs: Date.now() - readStartedAt,
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
contents: [
{
uri,
mimeType: WIDGET_MIME_TYPE,
text: widget.html,
},
],
})
)
}
// Skills exposed at gnubok://skill/<slug> — Markdown bodies, forward-compatible
// with a future native MCP skills/list primitive. Atom slugs (slash-bearing
// registry ids) are URL-encoded in the URI; skillSlugFromUri decodes.
if (uri.startsWith(SKILL_URI_PREFIX)) {
const slug = skillSlugFromUri(uri)
const skill = slug ? await findSkill(slug, supabase) : null
if (skill) {
emitResourceReadTelemetry({
uri,
kind: 'skill',
success: true,
errorCode: null,
actor,
latencyMs: Date.now() - readStartedAt,
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
contents: [
{
uri,
mimeType: SKILL_MIME_TYPE,
text: skill.body,
},
],
})
)
}
}
const dataResource = findResource(uri)
if (dataResource) {
try {
const result = await dataResource.read({
supabase,
companyId,
userId,
scopes: keyScopes,
query: parseResourceQuery(uri),
})
emitResourceReadTelemetry({
uri,
kind: 'data',
success: true,
errorCode: null,
actor,
latencyMs: Date.now() - readStartedAt,
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
contents: [
{
uri,
mimeType: dataResource.mimeType,
text: JSON.stringify(result, null, 2),
},
],
})
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Resource read failed'
emitResourceReadTelemetry({
uri,
kind: 'data',
success: false,
errorCode: 'RESOURCE_READ_FAILED',
actor,
latencyMs: Date.now() - readStartedAt,
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(
jsonRpcError(id ?? null, -32603, `Resource read error: ${message}`)
)
}
}
emitResourceReadTelemetry({
uri,
kind: 'unknown',
success: false,
errorCode: 'RESOURCE_NOT_FOUND',
actor,
latencyMs: Date.now() - readStartedAt,
requestId: id ?? null,
userId,
companyId,
})
return NextResponse.json(
jsonRpcError(id ?? null, -32602, `Resource not found: "${uri}"`)
)
}
case 'prompts/list':
return NextResponse.json(
jsonRpc(id ?? null, {
prompts: prompts.map((p) => ({
name: p.name,
description: p.description,
})),
})
)
case 'prompts/get': {
const promptName = (params as Record<string, unknown>)?.name as string
const prompt = findPrompt(promptName)
if (!prompt) {
return NextResponse.json(
jsonRpcError(id ?? null, -32602, `Unknown prompt: "${promptName}"`)
)
}
return NextResponse.json(
jsonRpc(id ?? null, {
description: prompt.description,
messages: [
{
role: 'user',
content: { type: 'text', text: prompt.text },
},
],
})
)
}
default:
return NextResponse.json(
jsonRpcError(id ?? null, -32601, `Method not found: "${method}"`)
)
}
}