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>
This commit is contained in:
co-authored by
Claude Opus 4.7
Emil
parent
a9b43ebeb7
commit
f53725b20a
@@ -0,0 +1,196 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import type { DocumentAttachment } from '@/types'
|
||||
|
||||
const log = createLogger('document-extraction')
|
||||
|
||||
// Mime types we know Claude can read directly via Bedrock. Anything else
|
||||
// (HEIC, ZIP, TXT, …) is skipped — extracted_at still gets stamped so the
|
||||
// row is marked as "attempted, not eligible".
|
||||
const SUPPORTED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
])
|
||||
|
||||
// AI-extraction extension — paid AI tier only.
|
||||
//
|
||||
// Subscribes to the existing document.uploaded event bus topic and runs
|
||||
// Sonnet 4.6 (via Bedrock, reusing invoice-inbox's extractInvoiceFields) on
|
||||
// every uploaded receipt or invoice. Writes the result to
|
||||
// document_attachments.extracted_data so the agent intent capture can use
|
||||
// it without re-asking the user.
|
||||
//
|
||||
// Idempotency: skips when extracted_at is already set on the row. Also
|
||||
// dedupes against invoice_inbox_items.extracted_data — when the inbox
|
||||
// extension already extracted the same file, we copy its result instead
|
||||
// of paying for a second Sonnet call.
|
||||
//
|
||||
// Free tier: disable this extension in extensions.config.json. Uploads
|
||||
// still work; the agent intent will see null extracted_data and either
|
||||
// ask the user or call gnubok_get_document_content at chat-time.
|
||||
//
|
||||
// See dev_docs/specialized-agent-plan.md (§ paid/free tier note) — to be
|
||||
// authored.
|
||||
export const documentExtractionExtension: Extension = {
|
||||
id: 'document-extraction',
|
||||
name: 'AI document extraction',
|
||||
version: '1.0.0',
|
||||
|
||||
eventHandlers: [
|
||||
{
|
||||
eventType: 'document.uploaded',
|
||||
handler: async (payload) => {
|
||||
const { document, companyId } = payload as {
|
||||
document: DocumentAttachment
|
||||
userId: string
|
||||
companyId: string
|
||||
}
|
||||
await extractAndPersist(document, companyId)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function extractAndPersist(
|
||||
document: DocumentAttachment,
|
||||
companyId: string,
|
||||
): Promise<void> {
|
||||
// Service-role client: the handler runs out-of-band of the request that
|
||||
// emitted the event, so we don't have user cookies. RLS doesn't fit —
|
||||
// events have no user context.
|
||||
const supabase: SupabaseClient = createServiceClient()
|
||||
|
||||
// Idempotency guard: never re-extract a row that already has extracted_at.
|
||||
// Note: the column may be null OR the row may not yet have the new
|
||||
// schema (legacy supabase types). Fail closed on missing schema.
|
||||
const { data: existing, error: existingErr } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, mime_type, storage_path, extracted_at')
|
||||
.eq('id', document.id)
|
||||
.single()
|
||||
if (existingErr || !existing) {
|
||||
log.warn('document not found, skipping extraction', {
|
||||
doc: document.id,
|
||||
err: existingErr?.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (existing.extracted_at) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dedup against inbox: if invoice-inbox already extracted this exact file
|
||||
// (same document_id), copy its result to avoid a second AI call. If the
|
||||
// inbox row marked the upload as skip_extraction=true, the inbox row's
|
||||
// extracted_data is an empty skeleton — we must stamp the doc with a
|
||||
// 'skipped:*' model so the client-side useDocumentExtraction hook reports
|
||||
// 'unsupported' rather than 'succeeded' (otherwise the UI would claim AI
|
||||
// finished reading a doc it never opened).
|
||||
const { data: inboxRow } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('extracted_data, extraction_skipped')
|
||||
.eq('document_id', document.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (inboxRow?.extraction_skipped) {
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({
|
||||
extracted_at: new Date().toISOString(),
|
||||
extraction_model: 'skipped:invoice_inbox_gate',
|
||||
})
|
||||
.eq('id', document.id)
|
||||
return
|
||||
}
|
||||
|
||||
let extractedData: Record<string, unknown> | null = null
|
||||
let model: string = 'copied-from-invoice-inbox'
|
||||
|
||||
if (inboxRow?.extracted_data) {
|
||||
extractedData = inboxRow.extracted_data as Record<string, unknown>
|
||||
} else {
|
||||
const mimeType = existing.mime_type as string | null
|
||||
if (!mimeType || !SUPPORTED_MIME_TYPES.has(mimeType)) {
|
||||
// Stamp the attempt so we don't keep retrying unsupported types.
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({ extracted_at: new Date().toISOString(), extraction_model: 'skipped:unsupported_mime' })
|
||||
.eq('id', document.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Download the file from Supabase Storage. The bucket is private — the
|
||||
// service-role client can read any path.
|
||||
const storagePath = existing.storage_path as string | null
|
||||
if (!storagePath) {
|
||||
log.warn('document has no storage_path, skipping', { doc: document.id })
|
||||
return
|
||||
}
|
||||
|
||||
const { data: blob, error: dlErr } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(storagePath)
|
||||
if (dlErr || !blob) {
|
||||
log.warn('storage download failed', { doc: document.id, err: dlErr?.message })
|
||||
return
|
||||
}
|
||||
const buffer = Buffer.from(await blob.arrayBuffer())
|
||||
|
||||
try {
|
||||
const { data, rawText } = await extractInvoiceFields({
|
||||
buffer,
|
||||
mimeType,
|
||||
fileName: (document.file_name as string) || 'document',
|
||||
})
|
||||
// extractInvoiceFields returns an "empty" result on failure rather
|
||||
// than throwing — distinguish by checking rawText. When rawText is
|
||||
// null, the call was skipped (creds missing, unsupported type) or
|
||||
// the JSON parse failed.
|
||||
if (!rawText) {
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({
|
||||
extracted_at: new Date().toISOString(),
|
||||
extraction_model: 'failed:no_raw_text',
|
||||
})
|
||||
.eq('id', document.id)
|
||||
return
|
||||
}
|
||||
extractedData = data as unknown as Record<string, unknown>
|
||||
model = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
|
||||
} catch (err) {
|
||||
log.warn('extraction threw', {
|
||||
doc: document.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({
|
||||
extracted_at: new Date().toISOString(),
|
||||
extraction_model: 'failed:exception',
|
||||
})
|
||||
.eq('id', document.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateErr } = await supabase
|
||||
.from('document_attachments')
|
||||
.update({
|
||||
extracted_data: extractedData,
|
||||
extracted_at: new Date().toISOString(),
|
||||
extraction_model: model,
|
||||
})
|
||||
.eq('id', document.id)
|
||||
if (updateErr) {
|
||||
log.warn('persist failed', { doc: document.id, err: updateErr.message, companyId })
|
||||
return
|
||||
}
|
||||
log.info('extraction persisted', { doc: document.id, model, companyId })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "document-extraction",
|
||||
"sector": "general",
|
||||
"exportName": "documentExtractionExtension",
|
||||
"entryPoint": "@/extensions/general/document-extraction",
|
||||
"requiredEnvVars": [
|
||||
"AWS_REGION"
|
||||
],
|
||||
"definition": {
|
||||
"name": "AI-extrahering av underlag",
|
||||
"category": "accounting",
|
||||
"icon": "MessageCircle",
|
||||
"dataPattern": "both",
|
||||
"hasOwnData": false,
|
||||
"readsCoreTables": ["document_attachments", "invoice_inbox_items"],
|
||||
"description": "Läser kvitton och fakturor med AI och fyller i leverantör, belopp, moms och datum automatiskt",
|
||||
"longDescription": "Lyssnar på document.uploaded-händelser och kör Sonnet 4.6 via AWS Bedrock på varje uppladdat kvitto eller faktura (PDF eller bild). De extraherade fälten skrivs till document_attachments.extracted_data så att den specialiserade bokföringsassistenten kan föreslå rätt BAS-konto utan att fråga användaren om sådant som redan står på underlaget. Hoppar över dokument som redan extraherats av andra extensions (t.ex. invoice-inbox) för att undvika dubbla AI-anrop."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
return invoiceInboxExtension.apiRoutes!.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
)!
|
||||
}
|
||||
|
||||
const matchRoute = findRoute('POST', '/items/:id/match-transaction')
|
||||
const unmatchRoute = findRoute('POST', '/items/:id/unmatch-transaction')
|
||||
|
||||
function buildCtx(supabase: unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'invoice-inbox',
|
||||
supabase: supabase as ExtensionContext['supabase'],
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn() },
|
||||
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
||||
services: {},
|
||||
} as ExtensionContext
|
||||
}
|
||||
|
||||
function makeReq(path: string, body?: unknown) {
|
||||
return createMockRequest(path, {
|
||||
method: 'POST',
|
||||
searchParams: { _id: 'item-1' },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /items/:id/match-transaction', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
beforeEach(() => {
|
||||
mock = createQueuedMockSupabase()
|
||||
})
|
||||
|
||||
it('returns 401 without ctx', async () => {
|
||||
const res = await matchRoute.handler(makeReq('/items/item-1/match-transaction'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when transaction_id is missing', async () => {
|
||||
const ctx = buildCtx(mock.supabase)
|
||||
const res = await matchRoute.handler(
|
||||
makeReq('/items/item-1/match-transaction', {}),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when transaction is outside this company', async () => {
|
||||
// tx lookup returns null → 404 before any further calls
|
||||
mock.enqueue({ data: null })
|
||||
const ctx = buildCtx(mock.supabase)
|
||||
const res = await matchRoute.handler(
|
||||
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
|
||||
ctx,
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('sets matched_transaction_id and mirrors document_id onto the transaction', async () => {
|
||||
// Sequence: tx lookup (no doc yet) → inbox doc lookup → inbox update → tx update
|
||||
mock.enqueue({ data: { id: 'tx-1', document_id: null } })
|
||||
mock.enqueue({ data: { id: 'item-1', document_id: 'doc-1' } })
|
||||
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: 'tx-1' } })
|
||||
mock.enqueue({ data: null }) // tx update result (we don't read)
|
||||
const ctx = buildCtx(mock.supabase)
|
||||
const res = await matchRoute.handler(
|
||||
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
|
||||
ctx,
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ data: { matched_transaction_id: string } }>(res)
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.matched_transaction_id).toBe('tx-1')
|
||||
})
|
||||
|
||||
it('does not overwrite an existing tx.document_id', async () => {
|
||||
// tx already has a doc — match still succeeds but the tx update for
|
||||
// document_id is skipped (no enqueue beyond the inbox update).
|
||||
mock.enqueue({ data: { id: 'tx-1', document_id: 'existing-doc' } })
|
||||
mock.enqueue({ data: { id: 'item-1', document_id: 'doc-1' } })
|
||||
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: 'tx-1' } })
|
||||
const ctx = buildCtx(mock.supabase)
|
||||
const res = await matchRoute.handler(
|
||||
makeReq('/items/item-1/match-transaction', { transaction_id: 'tx-1' }),
|
||||
ctx,
|
||||
)
|
||||
const { body } = await parseJsonResponse<{ data: { matched_transaction_id: string } }>(res)
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.matched_transaction_id).toBe('tx-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /items/:id/unmatch-transaction', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
beforeEach(() => {
|
||||
mock = createQueuedMockSupabase()
|
||||
})
|
||||
|
||||
it('clears matched_transaction_id and mirrors the unmatch onto the transaction', async () => {
|
||||
// Sequence: existing-state lookup → inbox update → tx update
|
||||
mock.enqueue({
|
||||
data: {
|
||||
id: 'item-1',
|
||||
document_id: 'doc-1',
|
||||
matched_transaction_id: 'tx-1',
|
||||
},
|
||||
})
|
||||
mock.enqueue({ data: { id: 'item-1', matched_transaction_id: null } })
|
||||
mock.enqueue({ data: null }) // tx update result
|
||||
const ctx = buildCtx(mock.supabase)
|
||||
const res = await unmatchRoute.handler(
|
||||
makeReq('/items/item-1/unmatch-transaction'),
|
||||
ctx,
|
||||
)
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { matched_transaction_id: string | null }
|
||||
}>(res)
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.matched_transaction_id).toBeNull()
|
||||
})
|
||||
|
||||
it('returns 401 without ctx', async () => {
|
||||
const res = await unmatchRoute.handler(makeReq('/items/item-1/unmatch-transaction'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -148,7 +148,12 @@ async function uploadAndExtract(
|
||||
file: { name: string; buffer: ArrayBuffer; type: string },
|
||||
source: 'upload' | 'email',
|
||||
emailMeta?: EmailMeta,
|
||||
opts: { skipExtraction?: boolean } = {}
|
||||
// Pre-match the new inbox item to a bank transaction. Set when the caller
|
||||
// already knows which transaction this receipt belongs to (e.g. the
|
||||
// VerifyAndBookOverlay opened from a transaction row's paperclip or from
|
||||
// a transaction-anchored chat). Skipped silently if missing.
|
||||
matchedTransactionId?: string | null,
|
||||
opts: { skipExtraction?: boolean } = {},
|
||||
) {
|
||||
const correlationId = crypto.randomUUID()
|
||||
|
||||
@@ -254,6 +259,7 @@ async function uploadAndExtract(
|
||||
? { messageId: emailMeta.messageId, filename: file.name }
|
||||
: null,
|
||||
correlation_id: correlationId,
|
||||
matched_transaction_id: matchedTransactionId ?? null,
|
||||
})
|
||||
.select('*')
|
||||
.single()
|
||||
@@ -291,6 +297,7 @@ async function uploadAndExtract(
|
||||
status: inbox.status,
|
||||
extracted_data: extracted,
|
||||
matched_supplier_id: inbox.matched_supplier_id,
|
||||
matched_transaction_id: inbox.matched_transaction_id,
|
||||
extraction_skipped: skipExtraction,
|
||||
skip_reason: skipReason,
|
||||
page_count: pageCount,
|
||||
@@ -347,6 +354,11 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const matchedTransactionIdRaw = formData.get('matched_transaction_id')
|
||||
const matchedTransactionId =
|
||||
typeof matchedTransactionIdRaw === 'string' && matchedTransactionIdRaw.length > 0
|
||||
? matchedTransactionIdRaw
|
||||
: null
|
||||
// Opt-out of the built-in Claude/Bedrock OCR. Agents with their own
|
||||
// extraction pipeline upload the document, get the inbox row, then
|
||||
// PUT /items/:id/extracted-data with their parsed fields.
|
||||
@@ -365,6 +377,29 @@ export const invoiceInboxExtension: Extension = {
|
||||
)
|
||||
}
|
||||
|
||||
// Validate matched_transaction_id belongs to this company before we
|
||||
// spend the AI extraction budget. RLS would also catch a mismatch on
|
||||
// the insert, but failing fast gives a clearer error and lets the
|
||||
// caller distinguish "your context_ref pointed at a tx you don't own"
|
||||
// from a generic upload failure.
|
||||
if (matchedTransactionId) {
|
||||
const { data: tx, error: txErr } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('id', matchedTransactionId)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
if (txErr) {
|
||||
return NextResponse.json({ error: txErr.message }, { status: 500 })
|
||||
}
|
||||
if (!tx) {
|
||||
return NextResponse.json(
|
||||
{ error: 'matched_transaction_id refers to a transaction outside this company.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const result = await uploadAndExtract(
|
||||
@@ -374,7 +409,8 @@ export const invoiceInboxExtension: Extension = {
|
||||
{ name: file.name, buffer, type: file.type },
|
||||
'upload',
|
||||
undefined,
|
||||
{ skipExtraction }
|
||||
matchedTransactionId,
|
||||
{ skipExtraction },
|
||||
)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
@@ -396,7 +432,11 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
const url = new URL(request.url)
|
||||
const status = url.searchParams.get('status')
|
||||
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 20), 50)
|
||||
// Cap raised from 50 → 500: the inbox is a workqueue and booked items
|
||||
// now drop out of the default view client-side, so a low cap silently
|
||||
// hid active underlag behind older booked ones. 500 covers realistic
|
||||
// single-company volumes; pagination is the next step beyond that.
|
||||
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 50), 500)
|
||||
|
||||
let query = ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
@@ -847,6 +887,143 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Match a bank transaction to an inbox item ──────────
|
||||
// Sets invoice_inbox_items.matched_transaction_id. Used by the
|
||||
// TransactionMatchPicker dialog after the user picks a candidate from
|
||||
// the confidence-scored list. The transaction.categorization agent
|
||||
// intent already reads this column in its capture() so the agent will
|
||||
// see the inbox metadata as underlag on its next invocation.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/:id/match-transaction',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
let body: { transaction_id?: string }
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
if (!body.transaction_id || typeof body.transaction_id !== 'string') {
|
||||
return NextResponse.json({ error: 'transaction_id required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Confirm transaction belongs to this company before linking. RLS
|
||||
// would also catch it on the update, but failing fast keeps the
|
||||
// error specific. Also fetch the existing document_id so we can
|
||||
// decide whether to backfill it from the inbox doc below.
|
||||
const { data: tx } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.select('id, document_id')
|
||||
.eq('id', body.transaction_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
if (!tx) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch the inbox item's document_id so we can mirror it to
|
||||
// transactions.document_id below — the TransactionInboxCard reads
|
||||
// that column to decide whether to show the paperclip/file-check
|
||||
// indicators on the /transactions list. Without this, a row that
|
||||
// has a matched inbox item still appears doc-less in the UI.
|
||||
const { data: inboxItem } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const { data: updated, error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ matched_transaction_id: body.transaction_id })
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.select('id, matched_transaction_id')
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Mirror the inbox document onto the transaction so the list view
|
||||
// reflects "underlag bifogat" immediately. Only when the tx has
|
||||
// no other doc already (we never overwrite an existing link).
|
||||
if (inboxItem?.document_id && !tx.document_id) {
|
||||
const { error: txUpdateError } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({ document_id: inboxItem.document_id })
|
||||
.eq('id', body.transaction_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.is('document_id', null)
|
||||
if (txUpdateError) {
|
||||
// Non-fatal: the match itself succeeded; the UI indicator just
|
||||
// won't flip until next page refresh. Log but don't roll back.
|
||||
console.error('[invoice-inbox/match-transaction] tx.document_id backfill failed:', txUpdateError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Clear matched_transaction_id (user mistake / re-match) ────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/items/:id/unmatch-transaction',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get('_id')
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
// Capture the current match before clearing so we can mirror the
|
||||
// unmatch onto transactions.document_id below.
|
||||
const { data: existing } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const { data: updated, error: updateError } = await ctx.supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ matched_transaction_id: null })
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.select('id, matched_transaction_id')
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Clear the mirrored tx.document_id only when it currently points
|
||||
// at the same doc this inbox item brought in. Guards against
|
||||
// clobbering a doc that came from another source (paperclip
|
||||
// upload, SIE import, etc.).
|
||||
if (existing?.matched_transaction_id && existing.document_id) {
|
||||
const { error: txUpdateError } = await ctx.supabase
|
||||
.from('transactions')
|
||||
.update({ document_id: null })
|
||||
.eq('id', existing.matched_transaction_id)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('document_id', existing.document_id)
|
||||
if (txUpdateError) {
|
||||
console.error('[invoice-inbox/unmatch-transaction] tx.document_id clear failed:', txUpdateError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Retry extraction on a stored document ──────────────
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Tests for gnubok_get_agent_briefing — session-bootstrap context for the
|
||||
* specialized accountant agent over MCP.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
scopes: ['agent:read'],
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
/** Builds a supabase mock whose three relevant tables resolve to canned data. */
|
||||
function mockSupabase(opts: {
|
||||
profile?: {
|
||||
profile_summary: string | null
|
||||
horizontal_atoms: string[] | null
|
||||
vertical_atoms: string[] | null
|
||||
modifier_atoms: string[] | null
|
||||
} | null
|
||||
memoryRows?: Array<{
|
||||
id: string
|
||||
kind: string
|
||||
content: string
|
||||
relevance_score: number | null
|
||||
}>
|
||||
atomRows?: Array<{
|
||||
id: string
|
||||
tier: string
|
||||
title: string | null
|
||||
description: string
|
||||
}>
|
||||
errors?: { profile?: string; memory?: string; atoms?: string }
|
||||
}) {
|
||||
const profile = opts.profile === undefined ? null : opts.profile
|
||||
const memoryRows = opts.memoryRows ?? []
|
||||
const atomRows = opts.atomRows ?? []
|
||||
const errors = opts.errors ?? {}
|
||||
|
||||
return {
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'agent_profiles') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: profile,
|
||||
error: errors.profile ? new Error(errors.profile) : null,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (table === 'agent_memory') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
limit: vi.fn().mockResolvedValue({
|
||||
data: memoryRows,
|
||||
error: errors.memory ? new Error(errors.memory) : null,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (table === 'agent_atom_registry') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: atomRows,
|
||||
error: errors.atoms ? new Error(errors.atoms) : null,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected table in test mock: ${table}`)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('gnubok_get_agent_briefing tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is registered with readOnly + idempotent annotations', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool?.annotations.readOnlyHint).toBe(true)
|
||||
expect(tool?.annotations.idempotentHint).toBe(true)
|
||||
expect(tool?.annotations.destructiveHint).toBe(false)
|
||||
})
|
||||
|
||||
it('inputSchema declares no required args (no inputs)', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const input = tool.inputSchema as { properties?: Record<string, unknown>; required?: string[]; additionalProperties: boolean }
|
||||
expect(input.additionalProperties).toBe(false)
|
||||
expect(input.required).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns null summary, empty atoms, empty memory when no profile exists', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({ profile: null })
|
||||
const result = (await tool.execute(
|
||||
{},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as {
|
||||
profile_summary: string | null
|
||||
atoms: unknown[]
|
||||
memory: unknown[]
|
||||
}
|
||||
expect(result.profile_summary).toBeNull()
|
||||
expect(result.atoms).toEqual([])
|
||||
expect(result.memory).toEqual([])
|
||||
})
|
||||
|
||||
it('returns profile + atom metadata + memory when populated', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({
|
||||
profile: {
|
||||
profile_summary: 'Du driver Acme AB som IT-konsult …',
|
||||
horizontal_atoms: ['horizontal/swedish-vat'],
|
||||
vertical_atoms: ['vertical/konsult-it'],
|
||||
modifier_atoms: ['modifier/single-shareholder-ab-fmb'],
|
||||
},
|
||||
atomRows: [
|
||||
{ id: 'horizontal/swedish-vat', tier: 'horizontal', title: 'Swedish VAT', description: 'Moms compliance' },
|
||||
{ id: 'vertical/konsult-it', tier: 'vertical', title: 'IT-konsult', description: 'SNI 62' },
|
||||
{ id: 'modifier/single-shareholder-ab-fmb', tier: 'modifier', title: 'Fåmansbolag', description: 'AB single shareholder' },
|
||||
],
|
||||
memoryRows: [
|
||||
{ id: 'mem-1', kind: 'fact', content: 'Hyresavtal löper t.o.m. 2027', relevance_score: 0.9 },
|
||||
{ id: 'mem-2', kind: 'preference', content: 'Föredrar månadsslut den 25:e', relevance_score: 0.7 },
|
||||
],
|
||||
})
|
||||
const result = (await tool.execute(
|
||||
{},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as {
|
||||
profile_summary: string | null
|
||||
atoms: Array<{ id: string; tier: string; title: string; description: string }>
|
||||
memory: Array<{ id: string; kind: string; content: string; relevance_score: number | null }>
|
||||
}
|
||||
expect(result.profile_summary).toContain('Acme AB')
|
||||
expect(result.atoms).toHaveLength(3)
|
||||
expect(result.atoms.map((a) => a.id).sort()).toEqual([
|
||||
'horizontal/swedish-vat',
|
||||
'modifier/single-shareholder-ab-fmb',
|
||||
'vertical/konsult-it',
|
||||
])
|
||||
expect(result.memory).toHaveLength(2)
|
||||
expect(result.memory[0].id).toBe('mem-1') // top-ranked
|
||||
expect(result.memory[0].relevance_score).toBe(0.9)
|
||||
})
|
||||
|
||||
it('handles profile present but no atoms loaded yet', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({
|
||||
profile: {
|
||||
profile_summary: 'Composer ran but selected nothing.',
|
||||
horizontal_atoms: null,
|
||||
vertical_atoms: null,
|
||||
modifier_atoms: null,
|
||||
},
|
||||
})
|
||||
const result = (await tool.execute(
|
||||
{},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as {
|
||||
profile_summary: string | null
|
||||
atoms: unknown[]
|
||||
memory: unknown[]
|
||||
}
|
||||
expect(result.profile_summary).toBe('Composer ran but selected nothing.')
|
||||
expect(result.atoms).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces a structured error if the profile query fails', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({ profile: null, errors: { profile: 'pg-down' } })
|
||||
await expect(
|
||||
tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
|
||||
).rejects.toThrow(/Failed to load agent profile.*pg-down/)
|
||||
})
|
||||
|
||||
it('surfaces a structured error if the memory query fails', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({ profile: null, errors: { memory: 'rls-denied' } })
|
||||
await expect(
|
||||
tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
|
||||
).rejects.toThrow(/Failed to load agent memory.*rls-denied/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Stage-time validation for gnubok_import_sie.
|
||||
*
|
||||
* The tool now parses + validates the SIE file when it STAGES (not only at
|
||||
* commit), so the approver sees real content (company, fiscal year, voucher
|
||||
* count, balance) and a broken/unbalanced file is rejected before anyone
|
||||
* approves a blind byte count.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { tools } from '../server'
|
||||
|
||||
const importSie = tools.find((t) => t.name === 'gnubok_import_sie')!
|
||||
|
||||
const VALID_SIE = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Import AB"',
|
||||
'#ORGNR 5566778899',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#KONTO 2081 "Aktiekapital"',
|
||||
'#KONTO 6110 "Kontorsmaterial"',
|
||||
'#IB 0 1930 50000.00',
|
||||
'#IB 0 2081 -50000.00',
|
||||
'#VER A 1 20240115 "Inköp"',
|
||||
'{',
|
||||
'#TRANS 6110 {} 1000.00',
|
||||
'#TRANS 1930 {} -1000.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
// Same file but the verification does not balance (1000 vs -900).
|
||||
const UNBALANCED_SIE = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Trasig AB"',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#KONTO 6110 "Kontorsmaterial"',
|
||||
'#VER A 1 20240115 "Obalanserad"',
|
||||
'{',
|
||||
'#TRANS 6110 {} 1000.00',
|
||||
'#TRANS 1930 {} -900.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('gnubok_import_sie — stage-time validation', () => {
|
||||
it('stages a valid file with a parsed, content-rich preview', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-sie' }, error: null }) // pending_operations insert
|
||||
|
||||
const result = (await importSie.execute(
|
||||
{ file_content: VALID_SIE, filename: 'bok.se', mappings: [] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' },
|
||||
)) as { staged: boolean; operation_id?: string; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.operation_id).toBe('op-sie')
|
||||
// Preview now carries real parsed content, not just a byte count.
|
||||
expect(result.preview.company_name).toBe('Import AB')
|
||||
expect(result.preview.voucher_count).toBe(1)
|
||||
expect(result.preview.account_count).toBe(3)
|
||||
expect(result.preview.fiscal_year).toMatchObject({ start: '2024-01-01', end: '2024-12-31' })
|
||||
expect(result.preview.opening_balance).toMatchObject({ total: 0, is_balanced: true })
|
||||
})
|
||||
|
||||
it('rejects an unbalanced file at stage time (no blind staging)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
await expect(
|
||||
importSie.execute(
|
||||
{ file_content: UNBALANCED_SIE, filename: 'trasig.se', mappings: [] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' },
|
||||
),
|
||||
).rejects.toThrow(/ogiltig/i)
|
||||
})
|
||||
|
||||
it('rejects required-field gaps before parsing', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
importSie.execute(
|
||||
{ filename: 'x.se', mappings: [] },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' },
|
||||
),
|
||||
).rejects.toThrow(/file_content/)
|
||||
})
|
||||
})
|
||||
@@ -27,3 +27,11 @@ describe('outputSchema coverage', () => {
|
||||
expect(verbose.map((t) => t.name)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotation correctness', () => {
|
||||
it('gnubok_feedback is not read-only (it writes a telemetry event + mutates the rate-limit map)', () => {
|
||||
const feedback = tools.find((t) => t.name === 'gnubok_feedback')
|
||||
expect(feedback).toBeDefined()
|
||||
expect(feedback?.annotations?.readOnlyHint).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,11 +13,18 @@ describe('tools/list payload size guard', () => {
|
||||
}))
|
||||
const payload = JSON.stringify({ tools: projection })
|
||||
const approxTokens = Math.round(payload.length / 4)
|
||||
// Ceiling raised from 20K → 25K when item 8 of the agent-native API plan landed
|
||||
// (additionalProperties: false on all 67 inputSchemas + period_status in the staged
|
||||
// operation envelope). Long-term answer to growth is item 15 (Tool Search +
|
||||
// defer_loading) — not relaxing this guard further. If this fires, prefer trimming
|
||||
// descriptions or leaning on gnubok_search_tools before bumping again.
|
||||
expect(approxTokens).toBeLessThan(25_000)
|
||||
// Ceiling progression: 20K → 25K → 30K.
|
||||
// * 20K → 25K when item 8 of the agent-native API plan landed
|
||||
// (additionalProperties: false on all inputSchemas + period_status in the
|
||||
// staged operation envelope).
|
||||
// * 25K → 30K when the agentic branch merged with main: catalog grew from
|
||||
// ~75 to 83 tools (added gnubok_create_supplier, gnubok_list_pending_operations,
|
||||
// gnubok_approve_pending_operation, gnubok_reject_pending_operation,
|
||||
// gnubok_set_inbox_extracted_data from main + gnubok_get_agent_briefing,
|
||||
// _remember_fact, _forget_fact, _feedback from the agent branch).
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(30_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Safety tests for the salary MCP tools.
|
||||
*
|
||||
* After the staging refactor (Phase: ship readiness of the in-app agent),
|
||||
* gnubok_create_salary_run and gnubok_generate_agi STAGE a pending_operation
|
||||
* instead of writing directly. Approval (and the actual library call) goes
|
||||
* through lib/pending-operations/commit.ts — separately covered.
|
||||
*
|
||||
* gnubok_calculate_salary_run still calls the calculation lib synchronously
|
||||
* (no side effects to stage — pure compute against an existing draft run).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const mockRunSalaryCalculation = vi.fn()
|
||||
vi.mock('@/lib/salary/run-calculation', () => ({
|
||||
runSalaryCalculation: (...a: unknown[]) => mockRunSalaryCalculation(...a),
|
||||
}))
|
||||
|
||||
const mockGenerateAgi = vi.fn()
|
||||
vi.mock('@/lib/salary/agi/generate-declaration', () => ({
|
||||
generateAgiDeclaration: (...a: unknown[]) => mockGenerateAgi(...a),
|
||||
}))
|
||||
|
||||
const mockCreateRun = vi.fn()
|
||||
vi.mock('@/lib/salary/create-run', () => ({
|
||||
createSalaryRunWithEmployees: (...a: unknown[]) => mockCreateRun(...a),
|
||||
}))
|
||||
|
||||
import { tools } from '../server'
|
||||
|
||||
const createSalaryRun = tools.find((t) => t.name === 'gnubok_create_salary_run')!
|
||||
const calculateSalaryRun = tools.find((t) => t.name === 'gnubok_calculate_salary_run')!
|
||||
const generateAgi = tools.find((t) => t.name === 'gnubok_generate_agi')!
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('gnubok_calculate_salary_run', () => {
|
||||
it('calls runSalaryCalculation directly — never a self-fetch', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
mockRunSalaryCalculation.mockResolvedValue({ ok: true, run: { status: 'draft' }, warnings: ['w1'] })
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
|
||||
const result = (await calculateSalaryRun.execute(
|
||||
{ salary_run_id: 'run-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
)) as { salary_run_id: string; status: string; warnings: string[]; next: { tool: string } }
|
||||
|
||||
expect(result.salary_run_id).toBe('run-1')
|
||||
expect(result.warnings).toEqual(['w1'])
|
||||
expect(result.next.tool).toBe('gnubok_get_salary_run')
|
||||
expect(mockRunSalaryCalculation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ companyId: 'company-1', salaryRunId: 'run-1' }),
|
||||
)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('throws when the calculation lib returns not-ok', async () => {
|
||||
mockRunSalaryCalculation.mockResolvedValue({ ok: false, code: 'SALARY_RUN_NOT_FOUND' })
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
calculateSalaryRun.execute({ salary_run_id: 'run-x' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }),
|
||||
).rejects.toThrow(/SALARY_RUN_NOT_FOUND/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_generate_agi', () => {
|
||||
it('stages without calling generateAgiDeclaration', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// 1. salary_runs lookup → must be past draft
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'run-1', status: 'booked', period_year: 2026, period_month: 3, payment_date: '2026-03-25',
|
||||
},
|
||||
})
|
||||
// 2-3. resolvePeriodStatusForDate calls company_settings + fiscal_periods
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
// 4. pending_operations.insert
|
||||
enqueue({ data: { id: 'op-1' }, error: null })
|
||||
|
||||
const result = (await generateAgi.execute(
|
||||
{ salary_run_id: 'run-1' }, 'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
)) as { staged: boolean; risk_level: string; operation_id?: string }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('high')
|
||||
expect(mockGenerateAgi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when the run is still in draft', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'run-1', status: 'draft', period_year: 2026, period_month: 3, payment_date: '2026-03-25' },
|
||||
})
|
||||
await expect(
|
||||
generateAgi.execute({ salary_run_id: 'run-1' }, 'company-1', 'user-1', supabase as never, { type: 'agent_chat' }),
|
||||
).rejects.toThrow(/past draft/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_create_salary_run', () => {
|
||||
it('stages without calling the transactional helper', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// 1. employees count head
|
||||
enqueue({ data: null, count: 3 })
|
||||
// 2-3. resolvePeriodStatusForDate calls company_settings + fiscal_periods
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
// 4. pending_operations.insert
|
||||
enqueue({ data: { id: 'op-2' }, error: null })
|
||||
|
||||
const result = (await createSalaryRun.execute(
|
||||
{ period_year: 2026, period_month: 3, payment_date: '2026-03-25' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
)) as { staged: boolean; risk_level: string; preview: { period: string; employee_count: number } }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('medium')
|
||||
expect(result.preview.period).toBe('2026-03')
|
||||
expect(result.preview.employee_count).toBe(3)
|
||||
expect(mockCreateRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid period_month', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
createSalaryRun.execute(
|
||||
{ period_year: 2026, period_month: 0, payment_date: '2026-03-25' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/period_month must be 1-12/)
|
||||
})
|
||||
})
|
||||
@@ -66,6 +66,19 @@ describe('gnubok_search_tools', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('ranks by relevance — the most on-point tool comes first', async () => {
|
||||
// "vat" should surface the canonical report tool ahead of other vat-* tools,
|
||||
// not whichever is defined earliest by accident.
|
||||
const vat = await call({ query: 'vat' })
|
||||
expect(vat.tools[0].name).toBe('gnubok_get_vat_report')
|
||||
|
||||
// Multi-word query: every term must match (name or description), and the
|
||||
// tool whose NAME carries both terms ranks first.
|
||||
const paid = await call({ query: 'invoice paid' })
|
||||
expect(paid.tools.length).toBeGreaterThan(0)
|
||||
expect(paid.tools[0].name).toBe('gnubok_mark_invoice_as_paid')
|
||||
})
|
||||
|
||||
it('respects limit (1-50, default 20, clamps over-50)', async () => {
|
||||
const overLimit = await call({ limit: 100 })
|
||||
expect(overLimit.tools.length).toBeLessThanOrEqual(50)
|
||||
|
||||
@@ -3,13 +3,74 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { skills, findSkill, SKILL_URI_PREFIX, skillUri } from '../skills'
|
||||
import { workflowSkills, findSkill, SKILL_URI_PREFIX, skillUri, __resetAtomCache } from '../skills'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
/** Build a supabase mock that satisfies the queries gnubok_list_skills issues:
|
||||
* - agent_atom_registry (empty atom set, so only static workflows surface)
|
||||
* - company_settings (entity_type + vat_registered, used by applicability filter)
|
||||
* - employees (active count, used by applicability filter)
|
||||
*
|
||||
* All test queries resolve to the same defaults: entity_type='AB',
|
||||
* vat_registered=true, 1 active employee — so every applicability-filtered
|
||||
* skill is included by default. Individual tests can override via the
|
||||
* optional overrides parameter.
|
||||
*/
|
||||
function makeSupabaseWithEmptyAtomRegistry(
|
||||
rows: unknown[] = [],
|
||||
overrides: { entityType?: string | null; vatRegistered?: boolean; employeeCount?: number } = {},
|
||||
refRow: unknown = null,
|
||||
) {
|
||||
const entityType = overrides.entityType ?? 'AB'
|
||||
const vatRegistered = overrides.vatRegistered ?? true
|
||||
const employeeCount = overrides.employeeCount ?? 1
|
||||
|
||||
return {
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: entityType === null ? null : { entity_type: entityType, vat_registered: vatRegistered },
|
||||
error: null,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (table === 'employees') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
eq: vi.fn().mockResolvedValue({ count: employeeCount, data: null, error: null }),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
// Default: agent_atom_registry. The same chain serves two query shapes:
|
||||
// - loadAtomsAsSkills: .eq().eq().is('parent_atom_id', null).order() → resolves `rows`
|
||||
// - loadReferenceById: .eq('id').not('parent_atom_id','is',null).maybeSingle() → resolves `refRow`
|
||||
return {
|
||||
select: vi.fn(() => {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
eq: vi.fn(() => chain),
|
||||
is: vi.fn(() => chain),
|
||||
not: vi.fn(() => chain),
|
||||
order: vi.fn().mockResolvedValue({ data: rows, error: null }),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: refRow, error: null }),
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
@@ -18,15 +79,18 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
// Minimal scopes — skills tools should be available regardless.
|
||||
// Minimal scopes — list/load skill tools are intentionally unscoped.
|
||||
scopes: [],
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(() => makeSupabaseWithEmptyAtomRegistry()),
|
||||
}
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
/** Alias for the legacy workflow-only array. New code reads `workflowSkills`. */
|
||||
const skills = workflowSkills
|
||||
|
||||
function mcpRequest(method: string, params?: Record<string, unknown>, id: number | string = 1): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
||||
method: 'POST',
|
||||
@@ -43,9 +107,10 @@ async function parseResult(response: Response) {
|
||||
describe('Skills registry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
__resetAtomCache()
|
||||
})
|
||||
|
||||
it('exports a non-empty skills array', () => {
|
||||
it('exports a non-empty workflowSkills array', () => {
|
||||
expect(skills.length).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
|
||||
@@ -69,21 +134,28 @@ describe('Skills registry', () => {
|
||||
expect(s.summary.length).toBeLessThan(200)
|
||||
expect(Array.isArray(s.tags)).toBe(true)
|
||||
expect(s.tags.length).toBeGreaterThan(0)
|
||||
expect(s.tier).toBe('workflow')
|
||||
}
|
||||
})
|
||||
|
||||
it('findSkill returns the skill or null', () => {
|
||||
expect(findSkill('month-end-close')).toBeTruthy()
|
||||
expect(findSkill('does-not-exist')).toBeNull()
|
||||
it('findSkill resolves the workflow skill or null (sync workflow lookup)', async () => {
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry()
|
||||
expect(await findSkill('month-end-close', supabase as never)).toBeTruthy()
|
||||
expect(await findSkill('does-not-exist', supabase as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('skillUri uses the gnubok://skill/ prefix', () => {
|
||||
it('skillUri uses the gnubok://skill/ prefix; atom slugs are URL-encoded', () => {
|
||||
expect(skillUri('foo')).toBe('gnubok://skill/foo')
|
||||
expect(skillUri('vertical/konsult-it')).toBe('gnubok://skill/vertical%2Fkonsult-it')
|
||||
expect(SKILL_URI_PREFIX).toBe('gnubok://skill/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_list_skills tool', () => {
|
||||
beforeEach(() => {
|
||||
__resetAtomCache()
|
||||
})
|
||||
|
||||
it('is registered with correct annotations and no scope requirement', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')
|
||||
expect(tool).toBeDefined()
|
||||
@@ -91,21 +163,23 @@ describe('gnubok_list_skills tool', () => {
|
||||
expect(tool?.annotations.idempotentHint).toBe(true)
|
||||
})
|
||||
|
||||
it('returns all skills when called with no args', async () => {
|
||||
it('returns all workflow skills when called with no args (empty atom registry)', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const result = (await tool.execute({}, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ slug: string; name: string; summary: string; tags: string[] }>
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry()
|
||||
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ slug: string; name: string; summary: string; tags: string[]; tier: string }>
|
||||
count: number
|
||||
}
|
||||
expect(result.count).toBe(skills.length)
|
||||
expect(result.skills.every((s) => s.slug && s.name && s.summary)).toBe(true)
|
||||
expect(result.skills.every((s) => s.slug && s.name && s.summary && s.tier === 'workflow')).toBe(true)
|
||||
// Body should NOT be returned by list (token saving).
|
||||
expect((result.skills[0] as Record<string, unknown>).body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('filters by tag', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const result = (await tool.execute({ tag: 'vat' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as {
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry()
|
||||
const result = (await tool.execute({ tag: 'vat' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ slug: string; tags: string[] }>
|
||||
count: number
|
||||
}
|
||||
@@ -114,44 +188,256 @@ describe('gnubok_list_skills tool', () => {
|
||||
expect(s.tags.map((t) => t.toLowerCase())).toContain('vat')
|
||||
}
|
||||
})
|
||||
|
||||
it('filters by tier=workflow (excludes atoms when both present)', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry()
|
||||
const result = (await tool.execute({ tier: 'workflow' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ tier: string }>
|
||||
count: number
|
||||
}
|
||||
expect(result.count).toBeGreaterThan(0)
|
||||
for (const s of result.skills) {
|
||||
expect(s.tier).toBe('workflow')
|
||||
}
|
||||
})
|
||||
|
||||
it('filters by tier=horizontal/vertical/modifier (returns empty when no atoms)', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry()
|
||||
for (const tier of ['horizontal', 'vertical', 'modifier']) {
|
||||
const result = (await tool.execute({ tier }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
count: number
|
||||
}
|
||||
expect(result.count).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces registry atoms alongside workflow skills', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
// Body path points at a real SKILL.md on disk (seeded by Phase 3).
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry([
|
||||
{
|
||||
id: 'vertical/konsult-it',
|
||||
tier: 'vertical',
|
||||
title: 'IT-konsult & systemutvecklare (SNI 62)',
|
||||
description: 'Konsult-IT description',
|
||||
sni_prefixes: ['62.01'],
|
||||
body_path: '.claude/skills/industry/konsult-it/SKILL.md',
|
||||
},
|
||||
])
|
||||
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ slug: string; tier: string }>
|
||||
count: number
|
||||
}
|
||||
expect(result.count).toBe(skills.length + 1)
|
||||
expect(result.skills.find((s) => s.slug === 'vertical/konsult-it')?.tier).toBe('vertical')
|
||||
})
|
||||
|
||||
it('applicability filter hides AB-only skills for EF companies', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry([], { entityType: 'EF', employeeCount: 0, vatRegistered: true })
|
||||
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ slug: string }>
|
||||
hidden_count: number
|
||||
company_context: { entity_type: string | null; has_employees: boolean; vat_registered: boolean }
|
||||
}
|
||||
const slugs = result.skills.map((s) => s.slug)
|
||||
expect(slugs).not.toContain('year-end-close') // AB-only
|
||||
expect(slugs).not.toContain('payroll-monthly') // requires employees
|
||||
expect(slugs).toContain('month-end-close')
|
||||
expect(slugs).toContain('invoicing-rules')
|
||||
expect(slugs).toContain('quarterly-vat-review') // vat_registered=true
|
||||
expect(result.hidden_count).toBeGreaterThan(0)
|
||||
expect(result.company_context).toEqual({ entity_type: 'EF', has_employees: false, vat_registered: true })
|
||||
})
|
||||
|
||||
it('include_all=true bypasses applicability filter', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry([], { entityType: 'EF', employeeCount: 0, vatRegistered: false })
|
||||
const filtered = (await tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
count: number
|
||||
hidden_count: number
|
||||
}
|
||||
const unfiltered = (await tool.execute({ include_all: true }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
count: number
|
||||
hidden_count: number
|
||||
}
|
||||
expect(unfiltered.count).toBe(filtered.count + filtered.hidden_count)
|
||||
expect(unfiltered.hidden_count).toBe(0)
|
||||
})
|
||||
|
||||
it('tier=vertical filter returns only verticals', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry([
|
||||
{
|
||||
id: 'vertical/konsult-it',
|
||||
tier: 'vertical',
|
||||
title: 'Konsult-IT',
|
||||
description: 'desc',
|
||||
sni_prefixes: ['62.01'],
|
||||
body_path: '.claude/skills/industry/konsult-it/SKILL.md',
|
||||
},
|
||||
])
|
||||
const result = (await tool.execute({ tier: 'vertical' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ slug: string; tier: string }>
|
||||
count: number
|
||||
}
|
||||
expect(result.count).toBe(1)
|
||||
expect(result.skills[0].slug).toBe('vertical/konsult-it')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_load_skill tool', () => {
|
||||
beforeEach(() => {
|
||||
__resetAtomCache()
|
||||
})
|
||||
|
||||
it('is registered', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')
|
||||
expect(tool).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns full body for a valid slug', async () => {
|
||||
it('returns full body for a valid workflow slug', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
const result = (await tool.execute({ slug: 'month-end-close' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as {
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry()
|
||||
const result = (await tool.execute({ slug: 'month-end-close' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
slug: string
|
||||
name: string
|
||||
tier: string
|
||||
body: string
|
||||
}
|
||||
expect(result.slug).toBe('month-end-close')
|
||||
expect(result.tier).toBe('workflow')
|
||||
expect(result.body).toContain('# Month-End Close')
|
||||
expect(result.body).toContain('## Tools')
|
||||
})
|
||||
|
||||
it('throws structured error for unknown slug', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry()
|
||||
await expect(
|
||||
tool.execute({ slug: 'nonexistent-skill' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })
|
||||
tool.execute({ slug: 'nonexistent-skill' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
|
||||
).rejects.toThrow(/Skill not found.*Available skills/)
|
||||
})
|
||||
|
||||
it('throws when slug is missing or empty', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry()
|
||||
await expect(
|
||||
tool.execute({ slug: '' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })
|
||||
tool.execute({ slug: '' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
|
||||
).rejects.toThrow(/slug is required/)
|
||||
})
|
||||
|
||||
it('resolves an atom slug from the registry and returns its DB body', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry([
|
||||
{
|
||||
id: 'vertical/konsult-it',
|
||||
tier: 'vertical',
|
||||
title: 'Konsult-IT',
|
||||
description: 'desc',
|
||||
sni_prefixes: ['62.01'],
|
||||
// Body now comes from the DB column, not disk. The frontmatter must be
|
||||
// preserved verbatim (the composer/system-prompt rely on the `id:` line).
|
||||
body: '---\nid: vertical/konsult-it\ntier: vertical\n---\n\n# Konsult-IT (loaded from DB)',
|
||||
body_path: '.claude/skills/industry/konsult-it/SKILL.md',
|
||||
},
|
||||
])
|
||||
const result = (await tool.execute({ slug: 'vertical/konsult-it' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
|
||||
slug: string
|
||||
tier: string
|
||||
body: string
|
||||
}
|
||||
expect(result.slug).toBe('vertical/konsult-it')
|
||||
expect(result.tier).toBe('vertical')
|
||||
// Frontmatter preserved, and the body is the DB value (not the on-disk file).
|
||||
expect(result.body).toContain('id: vertical/konsult-it')
|
||||
expect(result.body).toContain('loaded from DB')
|
||||
})
|
||||
|
||||
it('resolves a reference child by id even though it is hidden from the listed atom set', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
// Listed atoms (loadAtomsAsSkills) is empty — the reference is only reachable
|
||||
// via loadReferenceById, which findSkill falls back to.
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry([], {}, {
|
||||
id: 'horizontal/swedish-vat/vat-compliance-reference',
|
||||
tier: 'horizontal',
|
||||
title: 'Swedish VAT (Moms) Complete Compliance Reference',
|
||||
description: 'desc',
|
||||
sni_prefixes: [],
|
||||
body: '# Swedish VAT (Moms) Complete Compliance Reference\n\nDeep reference body.',
|
||||
body_path: '.claude/skills/swedish-vat/references/vat-compliance-reference.md',
|
||||
is_active: true,
|
||||
mcp_exposed: true,
|
||||
parent_atom_id: 'horizontal/swedish-vat',
|
||||
})
|
||||
const result = (await tool.execute(
|
||||
{ slug: 'horizontal/swedish-vat/vat-compliance-reference' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
)) as { slug: string; tier: string; tags: string[]; body: string }
|
||||
expect(result.slug).toBe('horizontal/swedish-vat/vat-compliance-reference')
|
||||
expect(result.tier).toBe('horizontal')
|
||||
expect(result.tags).toContain('reference')
|
||||
expect(result.body).toContain('Deep reference body.')
|
||||
})
|
||||
|
||||
it('does not resolve a reference whose curation switch (mcp_exposed) is off', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry([], {}, {
|
||||
id: 'horizontal/swedish-vat/vat-compliance-reference',
|
||||
tier: 'horizontal',
|
||||
title: 'x',
|
||||
description: 'desc',
|
||||
sni_prefixes: [],
|
||||
body: '# body',
|
||||
body_path: '.claude/skills/swedish-vat/references/vat-compliance-reference.md',
|
||||
is_active: true,
|
||||
mcp_exposed: false,
|
||||
parent_atom_id: 'horizontal/swedish-vat',
|
||||
})
|
||||
await expect(
|
||||
tool.execute(
|
||||
{ slug: 'horizontal/swedish-vat/vat-compliance-reference' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
),
|
||||
).rejects.toThrow(/Skill not found/)
|
||||
})
|
||||
|
||||
it('skips an atom whose body is null in the DB (no on-disk fallback in prod)', async () => {
|
||||
const prev = process.env.NODE_ENV
|
||||
// Force the prod path so the dev disk-fallback is disabled.
|
||||
process.env.NODE_ENV = 'production'
|
||||
try {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
const supabase = makeSupabaseWithEmptyAtomRegistry([
|
||||
{
|
||||
id: 'vertical/konsult-it',
|
||||
tier: 'vertical',
|
||||
title: 'Konsult-IT',
|
||||
description: 'desc',
|
||||
sni_prefixes: ['62.01'],
|
||||
body: null,
|
||||
body_path: '.claude/skills/industry/konsult-it/SKILL.md',
|
||||
},
|
||||
])
|
||||
// The atom is skipped (empty body), so the slug is not found.
|
||||
await expect(
|
||||
tool.execute({ slug: 'vertical/konsult-it' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
|
||||
).rejects.toThrow()
|
||||
} finally {
|
||||
process.env.NODE_ENV = prev
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skills via MCP protocol', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Protocol tests use the createServiceClientNoCookies mock (empty registry)
|
||||
// — reset the module-level atom cache so we don't see stragglers from
|
||||
// earlier tests in the file that populated the cache via direct execute().
|
||||
__resetAtomCache()
|
||||
})
|
||||
|
||||
it('resources/list includes one entry per skill at gnubok://skill/<slug>', async () => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Guards for Phase 1A: the staged-operation envelope must declare a tight
|
||||
* shape for `next` (closed object with required `description`) so agents can
|
||||
* dispatch on it programmatically, and `toToolError` must propagate the
|
||||
* `retryable` flag from the structured-error registry so agents know which
|
||||
* failures are worth retrying.
|
||||
*
|
||||
* The schema-shape guard walks tools whose outputSchema is the staged
|
||||
* envelope and asserts the `next` property matches NEXT_ACTION_HINT_SCHEMA.
|
||||
* It doesn't try to test the runtime stagePendingOperation function — that
|
||||
* has a transactional supabase dependency exercised by the per-tool tests.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { tools } from '../server'
|
||||
import { toToolError } from '../tool-result'
|
||||
import { getStructuredError } from '@/lib/errors/get-structured-error'
|
||||
|
||||
function isStagedOperationSchema(schema: unknown): schema is { properties: { next?: Record<string, unknown> } } {
|
||||
if (!schema || typeof schema !== 'object') return false
|
||||
const s = schema as Record<string, unknown>
|
||||
if (s.type !== 'object') return false
|
||||
const props = s.properties as Record<string, unknown> | undefined
|
||||
return Boolean(props && 'staged' in props && 'risk_level' in props && 'preview' in props)
|
||||
}
|
||||
|
||||
describe('staged operation envelope: next field shape', () => {
|
||||
it('every tool returning the staged envelope declares a closed next field', () => {
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!isStagedOperationSchema(tool.outputSchema)) continue
|
||||
const next = tool.outputSchema.properties.next as Record<string, unknown> | undefined
|
||||
if (!next) {
|
||||
offenders.push(`${tool.name}: missing next property`)
|
||||
continue
|
||||
}
|
||||
if (next.type !== 'object') {
|
||||
offenders.push(`${tool.name}: next.type must be 'object'`)
|
||||
}
|
||||
if (next.additionalProperties !== false) {
|
||||
offenders.push(`${tool.name}: next must have additionalProperties: false`)
|
||||
}
|
||||
const nextProps = next.properties as Record<string, unknown> | undefined
|
||||
if (!nextProps || !nextProps.description) {
|
||||
offenders.push(`${tool.name}: next.properties.description missing`)
|
||||
}
|
||||
const required = next.required as string[] | undefined
|
||||
if (!required || !required.includes('description')) {
|
||||
offenders.push(`${tool.name}: next.required must include "description"`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('staged operation titles: must carry contextual data', () => {
|
||||
/**
|
||||
* The runtime stagePendingOperation receives a `title` string from each
|
||||
* tool's execute(). The approver scans these in the pending-ops list, so
|
||||
* generic stubs like "Kategorisering" or "Ny faktura" are useless. We can't
|
||||
* easily exercise every tool runtime here (each requires a custom supabase
|
||||
* mock), but we CAN guard the source: walk every stagePendingOperation
|
||||
* call and require the title literal to either be a template (${...}) or
|
||||
* contain enough characters that it must include context.
|
||||
*/
|
||||
it('every stagePendingOperation call uses a template literal title', () => {
|
||||
const source = readFileSync(
|
||||
resolve(__dirname, '../server.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Match `stagePendingOperation(..., <title>, ...)` where <title> is the
|
||||
// 5th argument. We use a forgiving regex: find each call site, then peel
|
||||
// the args. The shape is consistent enough across the file (supabase,
|
||||
// companyId, userId, opType, title, ...) that the 5th argument is always
|
||||
// the title-bearing position.
|
||||
const callPattern = /stagePendingOperation\(\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*['"][a-z_]+['"]\s*,\s*(`[^`]+`|'[^']+'|"[^"]+")/g
|
||||
|
||||
const titles: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = callPattern.exec(source)) !== null) {
|
||||
titles.push(match[1])
|
||||
}
|
||||
|
||||
// Sanity: we expect at least 20 staging calls in the file. If this drops
|
||||
// below that, the regex broke and the rest of the test is meaningless.
|
||||
expect(titles.length).toBeGreaterThanOrEqual(20)
|
||||
|
||||
const thin = titles.filter((t) => {
|
||||
// Backtick template literals carry ${...} interpolations → contextual.
|
||||
// Plain single/double quoted strings without ${} are static labels
|
||||
// and must clear a length bar that signals they include detail.
|
||||
if (t.startsWith('`') && t.includes('${')) return false
|
||||
// Static title — require enough characters that the author must have
|
||||
// included at least one piece of context (name, number, period etc.)
|
||||
const inner = t.slice(1, -1)
|
||||
return inner.length < 25
|
||||
})
|
||||
|
||||
expect(thin, `Thin (non-contextual) titles found: ${thin.join(', ')}`).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toToolError: retryable propagation', () => {
|
||||
it('marks transient bookkeeping DB errors as retryable', () => {
|
||||
const err = new Error('Bookkeeping database operation failed')
|
||||
// The structured-error registry is what carries the flag; reproduce via
|
||||
// the same dispatch path so the contract stays anchored to the registry.
|
||||
const structured = getStructuredError(err)
|
||||
// Sanity: the inferred code may be UNKNOWN here (no code on the throw).
|
||||
// Construct one with an explicit code on the thrown object instead.
|
||||
const tagged = Object.assign(new Error('database boom'), { code: 'BOOKKEEPING_DATABASE_ERROR' })
|
||||
const result = toToolError(tagged)
|
||||
expect(result.error.code).toBe('BOOKKEEPING_DATABASE_ERROR')
|
||||
expect(result.error.retryable).toBe(true)
|
||||
// Sanity for the prose-only path: don't crash.
|
||||
expect(structured.code).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks Riksbanken exchange-rate timeouts as retryable', () => {
|
||||
const tagged = Object.assign(new Error('rate fetch timed out'), { code: 'TX_EXCHANGE_RATE_UNAVAILABLE' })
|
||||
const result = toToolError(tagged)
|
||||
expect(result.error.code).toBe('TX_EXCHANGE_RATE_UNAVAILABLE')
|
||||
expect(result.error.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('marks rate-limit responses as retryable', () => {
|
||||
const tagged = Object.assign(new Error('over the cap'), { code: 'RATE_LIMITED' })
|
||||
const result = toToolError(tagged)
|
||||
expect(result.error.code).toBe('RATE_LIMITED')
|
||||
expect(result.error.retryable).toBe(true)
|
||||
})
|
||||
|
||||
it('omits retryable on permanent validation/period errors', () => {
|
||||
const periodLocked = toToolError(new Error('locked/closed fiscal period'))
|
||||
expect(periodLocked.error.code).toBe('PERIOD_LOCKED')
|
||||
expect(periodLocked.error.retryable).toBeUndefined()
|
||||
|
||||
const tagged = Object.assign(new Error('validation'), { code: 'VALIDATION_ERROR' })
|
||||
const validation = toToolError(tagged)
|
||||
expect(validation.error.retryable).toBeUndefined()
|
||||
})
|
||||
|
||||
it('PERIOD_LOCKED carries a remediation pointing at gnubok_unlock_period', () => {
|
||||
const result = toToolError(new Error('locked/closed fiscal period'))
|
||||
expect(result.error.code).toBe('PERIOD_LOCKED')
|
||||
expect(result.error.remediation?.tool).toBe('gnubok_unlock_period')
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,50 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Test Key',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
// Minimal supabase mock — agent_atom_registry resolves to empty so
|
||||
// gnubok_list_skills happy-path doesn't crash on its registry query.
|
||||
// company_settings + employees are also handled so the applicability
|
||||
// filter has data to work against.
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: { entity_type: 'AB', vat_registered: true },
|
||||
error: null,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (table === 'employees') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
eq: vi.fn().mockResolvedValue({ count: 1, data: null, error: null }),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return {
|
||||
select: vi.fn(() => {
|
||||
// Chainable: loadAtomsAsSkills filters .eq(is_active).eq(mcp_exposed)
|
||||
// .is(parent_atom_id, null).order(); loadReferenceById uses
|
||||
// .eq(id).not(parent_atom_id,is,null).maybeSingle().
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
eq: vi.fn(() => chain),
|
||||
is: vi.fn(() => chain),
|
||||
not: vi.fn(() => chain),
|
||||
order: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
companyId: 'company-1',
|
||||
scopes: ['reports:read'],
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
// Fully-chainable, awaitable proxy resolving to empty data — satisfies both
|
||||
// loadAtomsAsSkills (select→eq→eq→order) and computeVatReport (select→eq→in→
|
||||
// gte→lte→…) without hand-enumerating each chain.
|
||||
createServiceClientNoCookies: vi.fn(() => {
|
||||
const makeChain = (): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve({ data: [], error: null, count: 0 })
|
||||
}
|
||||
return () => makeChain()
|
||||
},
|
||||
},
|
||||
)
|
||||
return { from: () => makeChain() }
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -127,4 +144,37 @@ describe('VAT review widget', () => {
|
||||
expect(widgetTool._meta).toEqual({ ui: { resourceUri: 'ui://vat-review/app.html' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_get_vat_report — render_ui merge', () => {
|
||||
it('declares render_ui and points at the vat-review widget', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_vat_report')!
|
||||
expect((tool as { uiResourceUri?: string }).uiResourceUri).toBe('ui://vat-review/app.html')
|
||||
const props = (tool.inputSchema as { properties: Record<string, unknown> }).properties
|
||||
expect(props.render_ui).toMatchObject({ type: 'boolean' })
|
||||
})
|
||||
|
||||
it('emits result-level _meta only when render_ui=true', async () => {
|
||||
const withUi = await (
|
||||
await handleMcpRequest(
|
||||
mcpRequest('tools/call', {
|
||||
name: 'gnubok_get_vat_report',
|
||||
arguments: { period_type: 'monthly', year: 2026, period: 3, render_ui: true },
|
||||
}),
|
||||
)
|
||||
).json()
|
||||
expect(withUi.result.isError).toBeUndefined()
|
||||
expect(withUi.result._meta).toEqual({ ui: { resourceUri: 'ui://vat-review/app.html' } })
|
||||
|
||||
const withoutUi = await (
|
||||
await handleMcpRequest(
|
||||
mcpRequest('tools/call', {
|
||||
name: 'gnubok_get_vat_report',
|
||||
arguments: { period_type: 'monthly', year: 2026, period: 3 },
|
||||
}),
|
||||
)
|
||||
).json()
|
||||
expect(withoutUi.result.isError).toBeUndefined()
|
||||
expect(withoutUi.result._meta).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { handleMcpRequest } from './server'
|
||||
import { handleMcpRequest, tools as mcpTools } from './server'
|
||||
import { registerAgentTools } from '@/lib/agent/tools/registry'
|
||||
import type { AgentTool } from '@/lib/agent/tools/types'
|
||||
|
||||
// Make the same tool set available to the in-app chat agent. The chat loop
|
||||
// (lib/agent/chat/*) dispatches against the core agentToolRegistry so it can
|
||||
// stay decoupled from this extension's module path. Tools satisfy the
|
||||
// AgentTool contract structurally — see lib/agent/tools/types.ts.
|
||||
registerAgentTools(mcpTools as unknown as AgentTool[])
|
||||
|
||||
export const mcpServerExtension: Extension = {
|
||||
id: 'mcp-server',
|
||||
|
||||
+1169
-145
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Atom-registry → MCP skill adapter.
|
||||
*
|
||||
* The in-app composer (lib/agent/composer/) writes to `agent_atom_registry`;
|
||||
* each row points to a SKILL.md body on disk (under `.claude/skills/`). This
|
||||
* loader hydrates those rows into `Skill` objects so the existing
|
||||
* `gnubok_list_skills` / `gnubok_load_skill` tools can surface them to
|
||||
* Claude.ai users with no further work (plan §13 MCP parity).
|
||||
*
|
||||
* Atom slugs match their registry id verbatim: "horizontal/swedish-vat",
|
||||
* "vertical/konsult-it", "modifier/holding-ab". Workflow skills keep their
|
||||
* flat slugs ("month-end-close") and never collide with atom slugs.
|
||||
*
|
||||
* Cached per-process. Reset between tests via `__resetAtomCache`.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Skill, SkillTier } from './types'
|
||||
|
||||
interface AtomRegistryRow {
|
||||
id: string
|
||||
tier: 'horizontal' | 'vertical' | 'modifier'
|
||||
title: string | null
|
||||
description: string
|
||||
sni_prefixes: string[] | null
|
||||
body: string | null
|
||||
body_path: string
|
||||
}
|
||||
|
||||
let cache: Skill[] | null = null
|
||||
|
||||
export async function loadAtomsAsSkills(supabase: SupabaseClient): Promise<Skill[]> {
|
||||
if (cache) return cache
|
||||
|
||||
// `body` is read from the DB (seeded by scripts/generate-skill-bodies.ts) — not
|
||||
// from disk — so skills load identically on Vercel, Docker, and self-hosted.
|
||||
// `mcp_exposed` is the curation kill-switch: only atoms flagged for the MCP
|
||||
// surface reach Claude (swarm-* audit skills never become atoms in the first
|
||||
// place; this guards against any future row that shouldn't be end-user-loadable).
|
||||
const { data, error } = await supabase
|
||||
.from('agent_atom_registry')
|
||||
.select('id, tier, title, description, sni_prefixes, body, body_path')
|
||||
.eq('is_active', true)
|
||||
.eq('mcp_exposed', true)
|
||||
.is('parent_atom_id', null) // list top-level skills only; references resolve via loadReferenceById
|
||||
.order('id')
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to load atom registry: ${error.message}`)
|
||||
}
|
||||
if (!data) {
|
||||
cache = []
|
||||
return cache
|
||||
}
|
||||
|
||||
const rows = data as AtomRegistryRow[]
|
||||
const out: Skill[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
let body = row.body ?? ''
|
||||
if (!body) {
|
||||
// Dev convenience: before `npm run skills:generate` has populated the DB,
|
||||
// fall back to reading the SKILL.md from disk. In production the body is
|
||||
// always seeded by the generated migration, so this path is never taken.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
try {
|
||||
body = await readFile(join(process.cwd(), row.body_path), 'utf8')
|
||||
} catch {
|
||||
// fall through to the skip below
|
||||
}
|
||||
}
|
||||
if (!body) {
|
||||
console.warn(`[mcp-skills] atom ${row.id}: no body in DB and no on-disk fallback — skipping`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const sniRoot = row.sni_prefixes?.[0]?.split('.')[0]
|
||||
const tags = [row.tier, ...(sniRoot ? [`sni-${sniRoot}`] : [])]
|
||||
|
||||
out.push({
|
||||
slug: row.id,
|
||||
name: row.title ?? row.id,
|
||||
summary: row.description,
|
||||
tags,
|
||||
body,
|
||||
tier: row.tier as SkillTier,
|
||||
})
|
||||
}
|
||||
|
||||
cache = out
|
||||
return cache
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single reference child (parent_atom_id IS NOT NULL) by exact id —
|
||||
* e.g. "horizontal/swedish-vat/vat-compliance-reference". References are
|
||||
* deliberately excluded from `loadAtomsAsSkills` (the listed catalog), so this
|
||||
* is the only path that surfaces them; gnubok_load_skill falls back here when a
|
||||
* slug isn't a workflow or a top-level atom. Returns null for unknown ids,
|
||||
* inactive rows, or rows the curation switch (mcp_exposed) has turned off.
|
||||
*
|
||||
* Not cached: references load rarely and on demand, so a per-id query is cheaper
|
||||
* than holding ~90 reference bodies in the process between calls.
|
||||
*/
|
||||
export async function loadReferenceById(
|
||||
supabase: SupabaseClient,
|
||||
id: string,
|
||||
): Promise<Skill | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('agent_atom_registry')
|
||||
.select('id, tier, title, description, sni_prefixes, body, body_path, is_active, mcp_exposed, parent_atom_id')
|
||||
.eq('id', id)
|
||||
.not('parent_atom_id', 'is', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw new Error(`Failed to load reference ${id}: ${error.message}`)
|
||||
if (!data || data.is_active === false || data.mcp_exposed === false) return null
|
||||
|
||||
const row = data as AtomRegistryRow & { is_active: boolean; mcp_exposed: boolean }
|
||||
let body = row.body ?? ''
|
||||
if (!body) {
|
||||
// Same dev fallback as loadAtomsAsSkills: before the seed migration runs,
|
||||
// read the references/*.md straight off disk. Never taken in production.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
try {
|
||||
body = await readFile(join(process.cwd(), row.body_path), 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (!body) return null
|
||||
}
|
||||
|
||||
return {
|
||||
slug: row.id,
|
||||
name: row.title ?? row.id,
|
||||
summary: row.description,
|
||||
tags: [row.tier, 'reference'],
|
||||
body,
|
||||
tier: row.tier as SkillTier,
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the module-level cache so the next call re-queries. */
|
||||
export function __resetAtomCache(): void {
|
||||
cache = null
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Bank Reconciliation — gnubok
|
||||
|
||||
Reconcile the company's bank statements against the bookkeeping ledger so that the cash position in the books matches the bank. Run at month-end and before VAT close.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Stäm av banken"
|
||||
- "Reconcile the bank account"
|
||||
- "Why doesn't my 1930 balance match the bank?"
|
||||
- Mid-month spot-check before tax filings
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Pull the reconciliation status
|
||||
|
||||
\`gnubok_get_reconciliation_status\` returns the current state per bank account:
|
||||
matched count, unmatched count, latest bank balance, and ledger balance for the
|
||||
asset account (typically 1930). If \`unmatched_count = 0\` and the balances agree,
|
||||
the account is reconciled — skip to Step 5.
|
||||
|
||||
### Step 2 — Categorize the uncategorized side
|
||||
|
||||
Unmatched usually means *bank rows without journal entries* (incoming PSD2
|
||||
transactions) AND *journal rows without bank lines* (manually-posted entries
|
||||
the agent might already have created via \`gnubok_create_voucher\`).
|
||||
|
||||
For the bank-side gap:
|
||||
|
||||
1. \`gnubok_list_uncategorized_transactions(limit=20)\`. Page through if needed.
|
||||
2. For each, decide: is this an income payment for a known invoice, or an expense?
|
||||
3. **Income** that matches an open invoice: \`gnubok_match_transaction_to_invoice\` — keeps AR clean.
|
||||
4. **Income** without a matching invoice (refund, deposit, owner contribution): \`gnubok_categorize_transaction\` with the appropriate category.
|
||||
5. **Expense**: \`gnubok_suggest_categories\` first (uses counterparty templates + history) — accept the top suggestion if confidence is high. Otherwise pick from the category list manually.
|
||||
6. **Owner draw / private withdrawal** (EF only): category \`private\` posts to 2013.
|
||||
|
||||
Each call stages a pending operation — the user approves in the web app.
|
||||
|
||||
### Step 3 — Resolve duplicates / dead entries
|
||||
|
||||
If the bank shows a transaction that was already booked (e.g. manually entered via \`gnubok_create_voucher\`):
|
||||
|
||||
- \`gnubok_match_transaction_to_invoice\` won't help here.
|
||||
- The cleanest path: reverse the manual entry via \`gnubok_reverse_journal_entry\` (storno) and then categorize the bank transaction normally so the trail is "bank → ledger" rather than "ledger → bank → orphan".
|
||||
|
||||
If the ledger shows a phantom entry with no bank counterpart (a payment that
|
||||
was never actually sent), it must be reversed: \`gnubok_reverse_journal_entry\`.
|
||||
|
||||
### Step 4 — Re-check status
|
||||
|
||||
\`gnubok_get_reconciliation_status\` should now report \`unmatched_count = 0\`
|
||||
and matching balances. If the balances still disagree:
|
||||
|
||||
- A historical opening balance might be wrong. Compare \`gnubok_get_trial_balance\` for the period start against the bank's opening statement.
|
||||
- An entry might sit in a closed period that wasn't fully reconciled before locking. Check \`gnubok_list_fiscal_periods\` and walk backwards.
|
||||
- FX accounts (non-SEK) need revaluation before period close: \`gnubok_run_currency_revaluation\`.
|
||||
|
||||
### Step 5 — Document the reconciliation
|
||||
|
||||
For each fiscal period, gnubok stores the reconciliation state automatically.
|
||||
For a printed audit trail (BFL 8 kap), generate the supplier ledger and AR
|
||||
ledger after reconciliation:
|
||||
|
||||
- \`gnubok_get_ar_ledger\` — open customer balances should match unpaid invoices
|
||||
- \`gnubok_get_supplier_ledger\` — open supplier balances should match unpaid leverantörsfakturor
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Never delete a bank transaction** — even if it looks wrong. The PSD2 feed is the source of truth. If the bank made a mistake, the bank reverses it via a new transaction.
|
||||
- **Never edit a posted entry to "make it match"** — use \`gnubok_correct_entry\` or \`gnubok_reverse_journal_entry\`. BFL 5 kap 5 § forbids in-place edits.
|
||||
- **Reconcile BEFORE locking the period.** Lock-period sealed-off audit can't be re-opened cheaply (it requires \`gnubok_unlock_period\` + storno + relock).
|
||||
- **FX accounts must be revalued** at period close. Pre-revaluation reconciliation can show a phantom mismatch that disappears after revaluation — don't chase it.
|
||||
|
||||
## Common errors
|
||||
|
||||
- *"Balances disagree by exactly N"* — usually a single rounding entry at year-end (öresavrundning, 3741/7741) wasn't booked. Verify with \`gnubok_get_general_ledger\` filtered to the rounding accounts.
|
||||
- *"Unmatched count is 0 but balances disagree"* — opening balance issue. Check the previous period's UB matches this period's IB via \`gnubok_get_trial_balance(period=prev)\` vs \`gnubok_get_trial_balance(period=current, opening=true)\`.
|
||||
- *"Same transaction shows twice"* — either two PSD2 feeds (manual + Enable Banking) imported the same row, or the user manually created a voucher AND the bank imported the row. Reverse the duplicate via \`gnubok_reverse_journal_entry\`.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_get_reconciliation_status\` (single source of truth — call first and last)
|
||||
- \`gnubok_list_uncategorized_transactions\`
|
||||
- \`gnubok_suggest_categories\`
|
||||
- \`gnubok_categorize_transaction\`
|
||||
- \`gnubok_match_transaction_to_invoice\`
|
||||
- \`gnubok_auto_match_period\` (bulk matcher with confidence thresholds — use for big backlogs)
|
||||
- \`gnubok_reverse_journal_entry\` (storno)
|
||||
- \`gnubok_run_currency_revaluation\` (FX accounts only)
|
||||
- \`gnubok_get_trial_balance\`, \`gnubok_get_ar_ledger\`, \`gnubok_get_supplier_ledger\` (verification)
|
||||
`
|
||||
|
||||
export const bankReconciliationSkill: Skill = {
|
||||
slug: 'bank-reconciliation',
|
||||
name: 'Bank Reconciliation',
|
||||
summary: 'Stämma av banken: categorize incoming PSD2 rows, match against invoices, resolve duplicates, verify with ledger reports.',
|
||||
tags: ['monthly', 'reconciliation', 'bank', 'verification'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
applicability: { entity_type: 'both' },
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Customer Onboarding — gnubok
|
||||
|
||||
Adding a customer correctly the first time avoids a long tail of wrong-VAT
|
||||
invoices. This skill covers the decision tree for picking \`customer_type\`,
|
||||
when to run VIES validation, and which fields are required for each path.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Lägg till en ny kund / Add a customer"
|
||||
- "Skapa kund: Acme AB"
|
||||
- "How do I invoice an EU customer?"
|
||||
- Before \`gnubok_create_invoice\` whenever the customer isn't found in \`gnubok_list_customers\`
|
||||
|
||||
## Decision tree: which customer_type?
|
||||
|
||||
The customer_type drives **the VAT treatment** on every invoice you send them.
|
||||
Getting it wrong means re-issuing invoices and amending VAT declarations
|
||||
later — much cheaper to ask the customer once at onboarding.
|
||||
|
||||
| Customer | customer_type | Required fields | VAT treatment |
|
||||
|---|---|---|---|
|
||||
| Swedish private person | \`individual\` | name | 25/12/6 % standard, no reverse charge |
|
||||
| Swedish AB / HB / KB / EF | \`swedish_business\` | name, org_number | 25/12/6 % standard |
|
||||
| EU company (VAT-registered) | \`eu_business\` | name, vat_number (validated) | Reverse charge (varor + tjänster) — invoice has 0 % VAT, customer pays VAT in their country |
|
||||
| EU company (no VAT number) | \`eu_business\` | name, country | Treated as individual — 25 % charged, no reverse charge |
|
||||
| Non-EU company | \`non_eu_business\` | name, country | Export 0 % (varor) or reverse charge per service rules |
|
||||
| Non-EU private person | \`individual\` | name, country | 25 % standard for goods; export rules can apply for services |
|
||||
|
||||
**Special cases:**
|
||||
|
||||
- **B2G (Swedish municipality/agency)**: still \`swedish_business\`, but the invoice must be Peppol-formatted (legal requirement since 2019-04-01). gnubok handles Peppol when the customer record has a Peppol endpoint configured.
|
||||
- **Consumer customer in another EU country (B2C distance sale)**: \`eu_business\` does NOT apply. Charge Swedish VAT (25/12/6 %) below the OSS threshold; above the threshold the company must register for OSS. This is rare for sole traders — flag the user if turnover suggests they're approaching the threshold.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Check if the customer already exists
|
||||
|
||||
\`gnubok_list_customers\` returns all customers. Search by name client-side.
|
||||
If a customer with the same org_number or vat_number exists, USE THAT ONE.
|
||||
Duplicate customers fragment the AR ledger and confuse aging reports.
|
||||
|
||||
### Step 2 — Decide customer_type
|
||||
|
||||
Ask the user the minimum questions needed for the decision tree above. Don't
|
||||
ask "which customer_type?" — ask "is this a private person or a company?
|
||||
Swedish or EU? Do they have a VAT number?". Map the answers to a
|
||||
customer_type yourself.
|
||||
|
||||
### Step 3 — Validate VAT number (EU business only)
|
||||
|
||||
For \`eu_business\` customers, **VIES validation is critical**. If VIES says
|
||||
the VAT number is invalid (or unverifiable), reverse charge does NOT apply —
|
||||
you must charge Swedish VAT instead. The wrong call here means you under-collect
|
||||
VAT and Skatteverket charges interest + penalty later.
|
||||
|
||||
gnubok's \`gnubok_create_customer\` runs VIES automatically when \`vat_number\`
|
||||
is provided. The result is stored in \`vat_number_validated\` on the customer.
|
||||
Subsequent invoices read this field via \`getAvailableVatRates\` to determine
|
||||
which VAT rates are legal.
|
||||
|
||||
If VIES is temporarily down (intermittent), gnubok marks the customer as
|
||||
\`vat_number_validated = false\` and you'll see standard 25 % rates instead
|
||||
of reverse charge. Re-validate by updating the customer once VIES is back.
|
||||
|
||||
### Step 4 — Stage the customer
|
||||
|
||||
\`gnubok_create_customer({
|
||||
name: 'Acme GmbH',
|
||||
customer_type: 'eu_business',
|
||||
vat_number: 'DE123456789',
|
||||
email: 'invoicing@acme.de',
|
||||
country: 'Germany',
|
||||
payment_terms: 30,
|
||||
})\`
|
||||
|
||||
Stages a pending operation for user approval. After approval, the customer
|
||||
appears in \`gnubok_list_customers\` and can be invoiced via \`gnubok_create_invoice\`.
|
||||
|
||||
### Step 5 — Sanity-check before first invoice
|
||||
|
||||
\`gnubok_list_customers\` — confirm the customer appears, customer_type is right.
|
||||
Read \`vat_number_validated\` (for EU); if false, double-check the VAT number.
|
||||
|
||||
When you eventually call \`gnubok_create_invoice\`, gnubok uses the
|
||||
\`getAvailableVatRates\` helper to constrain the dropdown of legal rates per
|
||||
customer type. If you see "VAT rate X% is not allowed for customer type Y",
|
||||
the customer_type is wrong — re-onboard correctly via the agent flow, do NOT
|
||||
hand-pick a different rate.
|
||||
|
||||
## Common errors
|
||||
|
||||
- *"VAT rate 25% is not allowed for customer type eu_business"*: the customer's VAT number is validated → reverse charge required. Use 0 % on line items, and gnubok will add the "Omvänd betalningsskyldighet" notation automatically.
|
||||
- *"VAT rate 0% is not allowed for customer type swedish_business"*: domestic customers always get 25/12/6 %. Pick the right rate.
|
||||
- *"Customer not found"* on \`gnubok_create_invoice\`: the customer wasn't created (or its pending_operation wasn't approved). Run \`gnubok_list_customers\` to confirm.
|
||||
|
||||
## ROT/RUT-eligible customers (Swedish individuals)
|
||||
|
||||
ROT/RUT-avdrag applies to physical persons receiving home-services
|
||||
(byggtjänster, städ, snöskottning, etc.). The customer's personnummer is
|
||||
required on the invoice. Update the customer record with:
|
||||
|
||||
- \`fastighetsbeteckning\` (property identifier — for ROT)
|
||||
- \`personnummer\` (encrypted at rest)
|
||||
|
||||
Then create the invoice with ROT/RUT flag set. See the \`invoicing-rules\` skill
|
||||
for the booking details (BAS accounts 1513 + 3740).
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_list_customers\` — check for existing customers
|
||||
- \`gnubok_create_customer\` — stage the new customer
|
||||
- \`gnubok_create_invoice\` — uses the customer_type for VAT-rate validation
|
||||
- (Helper, not a tool but referenced) \`getAvailableVatRates(customer_type, vat_number_validated)\` — drives the legal-rates check
|
||||
`
|
||||
|
||||
export const customerOnboardingSkill: Skill = {
|
||||
slug: 'customer-onboarding',
|
||||
name: 'Customer Onboarding',
|
||||
summary: 'Decision tree for customer_type (individual/swedish_business/eu_business/non_eu_business), VIES validation, ROT/RUT setup.',
|
||||
tags: ['onboarding', 'customer', 'vat', 'eu', 'rot-rut'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
applicability: { entity_type: 'both' },
|
||||
}
|
||||
@@ -1,21 +1,56 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Skill } from './types'
|
||||
import { monthEndCloseSkill } from './month-end-close'
|
||||
import { quarterlyVatReviewSkill } from './quarterly-vat-review'
|
||||
import { yearEndCloseSkill } from './year-end-close'
|
||||
import { invoicingRulesSkill } from './invoicing-rules'
|
||||
import { payrollMonthlySkill } from './payroll-monthly'
|
||||
import { bankReconciliationSkill } from './bank-reconciliation'
|
||||
import { kreditfakturaProcessSkill } from './kreditfaktura-process'
|
||||
import { customerOnboardingSkill } from './customer-onboarding'
|
||||
import { loadAtomsAsSkills, loadReferenceById } from './atoms'
|
||||
|
||||
export const skills: Skill[] = [
|
||||
/** Static workflow skills the server ships with. Tier: 'workflow'. */
|
||||
export const workflowSkills: Skill[] = [
|
||||
monthEndCloseSkill,
|
||||
quarterlyVatReviewSkill,
|
||||
yearEndCloseSkill,
|
||||
invoicingRulesSkill,
|
||||
payrollMonthlySkill,
|
||||
bankReconciliationSkill,
|
||||
kreditfakturaProcessSkill,
|
||||
customerOnboardingSkill,
|
||||
]
|
||||
|
||||
export function findSkill(slug: string): Skill | null {
|
||||
return skills.find((s) => s.slug === slug) ?? null
|
||||
/** @deprecated Use `workflowSkills` for the static set, or `loadAllSkills(supabase)`
|
||||
* for the unified list (workflows + atoms). Kept for backwards compatibility
|
||||
* with prior imports. */
|
||||
export const skills = workflowSkills
|
||||
|
||||
/**
|
||||
* Resolve a skill by slug. Checks the static workflow array first (synchronous,
|
||||
* always available), then falls back to the registry-backed atom set
|
||||
* (asynchronous, supabase-bound).
|
||||
*/
|
||||
export async function findSkill(slug: string, supabase?: SupabaseClient): Promise<Skill | null> {
|
||||
const wf = workflowSkills.find((s) => s.slug === slug)
|
||||
if (wf) return wf
|
||||
if (!supabase) return null
|
||||
const atoms = await loadAtomsAsSkills(supabase)
|
||||
const atom = atoms.find((s) => s.slug === slug)
|
||||
if (atom) return atom
|
||||
// Reference children (e.g. "horizontal/swedish-vat/vat-compliance-reference")
|
||||
// are excluded from the listed atom set above, so resolve them directly. This
|
||||
// is what makes a SKILL.md footer's gnubok_load_skill(<reference id>) work.
|
||||
return loadReferenceById(supabase, slug)
|
||||
}
|
||||
|
||||
export type { Skill } from './types'
|
||||
/** Workflow skills + registry-loaded atoms in one list. */
|
||||
export async function loadAllSkills(supabase: SupabaseClient): Promise<Skill[]> {
|
||||
const atoms = await loadAtomsAsSkills(supabase)
|
||||
return [...workflowSkills, ...atoms]
|
||||
}
|
||||
|
||||
export type { Skill, SkillTier } from './types'
|
||||
export { SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './types'
|
||||
export { loadAtomsAsSkills, __resetAtomCache } from './atoms'
|
||||
|
||||
@@ -145,4 +145,7 @@ export const invoicingRulesSkill: Skill = {
|
||||
summary: 'Mandatory invoice fields (ML 17 kap. 24 §), VAT treatment per customer type, ROT/RUT, Peppol, kreditfaktura.',
|
||||
tags: ['invoicing', 'vat', 'compliance', 'eu', 'rot-rut'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
// Universal — both AB and EF send invoices.
|
||||
applicability: { entity_type: 'both' },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Kreditfaktura (Credit Note) Process — gnubok
|
||||
|
||||
When a sent invoice needs to be partially or fully reversed, Swedish law
|
||||
(BFL 5 kap. 6-7 §, ML 17 kap. 30 §) requires a kreditfaktura — not a delete,
|
||||
not an in-place edit. This skill covers when to issue one, how, and the
|
||||
common mistakes.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Kreditera fakturan / Refund the customer"
|
||||
- "The customer disputed the invoice"
|
||||
- "We invoiced the wrong amount / wrong items"
|
||||
- "The customer returned the goods"
|
||||
- "Make a credit note"
|
||||
|
||||
## Decision: kreditfaktura vs correct vs reverse
|
||||
|
||||
These three tools sound similar but solve different problems:
|
||||
|
||||
| Situation | Tool | Why |
|
||||
|---|---|---|
|
||||
| Invoice was sent to customer; needs partial/full reversal | \`gnubok_credit_invoice\` | Creates a KR-prefixed mirror invoice + reverses the original JE. Customer gets a kreditfaktura PDF. |
|
||||
| Internal verifikation that has wrong account/amount but no customer-facing invoice involved | \`gnubok_correct_entry\` | Storno + new posted entry. No invoice document changes. Stays inside BFL 5 kap. 5 §. |
|
||||
| Internal verifikation that should never have existed (duplicate, ghost) | \`gnubok_reverse_journal_entry\` | Pure storno — original stays visible, no replacement posted. |
|
||||
|
||||
**If a customer received an invoice, you must use credit_invoice.** The other
|
||||
two leave the original invoice document intact, which makes the books and
|
||||
the customer's records disagree.
|
||||
|
||||
## Legal framework
|
||||
|
||||
- **BFL 5 kap. 6-7 §**: rättelser must preserve audit trail (storno + new entry; never in-place edit; never gap in löpnummer).
|
||||
- **ML 17 kap. 30 §**: a kreditfaktura is a new invoice referencing the original. Both must be archived for 7 years.
|
||||
- **BFL 5 kap. 5 §**: löpnummer must be gap-free. The original invoice's number is *not reused* — the kreditfaktura gets its own KR-prefixed number from the credit series.
|
||||
- **VAT timing (faktureringsmetoden)**: the credit lands in the period the kreditfaktura is *issued*, not the period of the original invoice. This is correct — Skatteverket files VAT period-by-period and each filing stands alone. Don't try to "back-date" the credit into the original period.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Verify the original
|
||||
|
||||
\`gnubok_list_invoices(status='sent')\` or \`status='overdue'\` to find the invoice.
|
||||
The invoice must be in one of: \`sent\`, \`paid\`, or \`overdue\`. You cannot credit:
|
||||
|
||||
- A draft (just edit/delete the draft before sending)
|
||||
- An already-credited invoice (it has status \`credited\`)
|
||||
- A proforma (use \`gnubok_convert_invoice\` first if needed, but proformas don't post to VAT/AR — usually you just cancel them)
|
||||
|
||||
### Step 2 — Stage the kreditfaktura
|
||||
|
||||
\`gnubok_credit_invoice({ invoice_id, reason?: 'Felaktigt belopp' })\`.
|
||||
|
||||
The \`reason\` is optional but shows on the credit note PDF — fill it in for
|
||||
auditability and customer clarity. The tool will:
|
||||
|
||||
1. Generate a KR-prefixed invoice number (e.g. \`KR-2026-0042\`).
|
||||
2. Mirror the original line items with negative effects.
|
||||
3. Reverse the original JE via storno (so the AR balance returns to zero and VAT is reversed in the current period).
|
||||
4. Mark the original invoice as \`credited\` so it can't be credited again.
|
||||
|
||||
This stages a pending_operation — the user approves before any DB write.
|
||||
|
||||
### Step 3 — Refund the customer (if money already received)
|
||||
|
||||
If the original invoice was already paid:
|
||||
|
||||
- The credit posts to 1510 (kundfordring) — the customer balance is now negative (we owe them).
|
||||
- Issue an outbound bank payment for the refund amount. When the bank shows
|
||||
the outgoing transaction, categorize it via \`gnubok_categorize_transaction\`
|
||||
to debit 1510 and clear the customer balance.
|
||||
|
||||
If the original was unpaid, no refund — the credit just zeroes the AR balance.
|
||||
|
||||
### Step 4 — Send the credit note to the customer
|
||||
|
||||
\`gnubok_send_invoice({ invoice_id: <kreditfaktura_id> })\`.
|
||||
|
||||
The customer receives a PDF marked "KREDITFAKTURA" with reference to the
|
||||
original invoice number. ML 17 kap. 30 § requires the kreditfaktura to
|
||||
reference the document being credited — gnubok does this automatically.
|
||||
|
||||
### Step 5 — Verify
|
||||
|
||||
- \`gnubok_get_ar_ledger\` — customer balance should reflect the credit.
|
||||
- \`gnubok_get_vat_report(period_type='monthly', year, period)\` — the credit reduces ruta 05 + ruta 10/11/12 (output VAT) in the current period.
|
||||
- \`gnubok_get_general_ledger(account_number='1510')\` (or 3xxx revenue accounts) — confirm the storno hit.
|
||||
|
||||
## Partial credits
|
||||
|
||||
If only part of the invoice should be credited (e.g. one of three items was
|
||||
defective), the current \`gnubok_credit_invoice\` always credits the FULL
|
||||
original. For partial credits, the current workaround:
|
||||
|
||||
1. Credit the full invoice with \`gnubok_credit_invoice\`.
|
||||
2. Issue a new invoice for the items that ARE still owed via \`gnubok_create_invoice\`.
|
||||
|
||||
This produces a clean audit trail with three documents (original, kreditfaktura,
|
||||
correction-invoice) instead of a partial credit document. Skatteverket and
|
||||
auditors prefer this — partial credit documents are notoriously easy to
|
||||
misread.
|
||||
|
||||
## Common errors
|
||||
|
||||
- *"Fakturan har redan krediterats"* — the invoice is already in status \`credited\`. There's nothing to do; if you need to reverse the kreditfaktura itself, that's a separate (rare) operation.
|
||||
- *"Endast skickade, betalda eller förfallna fakturor kan krediteras"* — the invoice is a draft. Either send it first (then credit) or just edit the draft.
|
||||
- *"Credit notes can only be created from standard invoices"* — caller tried to credit a proforma. Proformas have no VAT/AR effect; cancel them instead.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_list_invoices\` — find the original
|
||||
- \`gnubok_credit_invoice\` — the main tool, stages the kreditfaktura
|
||||
- \`gnubok_send_invoice\` — deliver the credit note to the customer
|
||||
- \`gnubok_categorize_transaction\` / \`gnubok_match_transaction_to_invoice\` — book the refund payment (if any)
|
||||
- \`gnubok_get_ar_ledger\` / \`gnubok_get_vat_report\` — verification
|
||||
`
|
||||
|
||||
export const kreditfakturaProcessSkill: Skill = {
|
||||
slug: 'kreditfaktura-process',
|
||||
name: 'Kreditfaktura (Credit Note) Process',
|
||||
summary: 'When and how to credit a sent invoice: kreditfaktura vs correct vs reverse, BFL/ML rules, refund flow, partial credits.',
|
||||
tags: ['invoicing', 'kreditfaktura', 'vat', 'compliance'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
applicability: { entity_type: 'both' },
|
||||
}
|
||||
@@ -79,4 +79,8 @@ export const monthEndCloseSkill: Skill = {
|
||||
summary: 'End-of-month workflow: book transactions, reconcile bank, verify voucher gaps, file VAT (monthly filers), lock period.',
|
||||
tags: ['monthly', 'close', 'reconciliation', 'vat'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
// Universal — both AB and EF run a monthly close. VAT step is conditional
|
||||
// inside the body so non-VAT-registered companies aren't blocked.
|
||||
applicability: { entity_type: 'both' },
|
||||
}
|
||||
|
||||
@@ -115,4 +115,8 @@ export const payrollMonthlySkill: Skill = {
|
||||
summary: 'Monthly salary run + AGI: employee setup, calculation, sociala avgifter, semesterlöneskuld, booking, AGI XML.',
|
||||
tags: ['monthly', 'payroll', 'agi', 'compliance'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
// Only relevant when the company actually has employees. EF without payroll
|
||||
// (most sole traders) shouldn't see this in the discovery list.
|
||||
applicability: { entity_type: 'both', requires: ['employees'] },
|
||||
}
|
||||
|
||||
@@ -97,4 +97,8 @@ export const quarterlyVatReviewSkill: Skill = {
|
||||
summary: 'End-to-end momsdeklaration: deadlines, ruta-by-ruta map, reverse charge rules, common errors, drill-down via general ledger.',
|
||||
tags: ['vat', 'quarterly', 'monthly', 'compliance', 'skatteverket'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
// Only surfaces for VAT-registered companies. Most are; a hobby/below-tröskel
|
||||
// EF without VAT registration shouldn't see this in its skill list.
|
||||
applicability: { entity_type: 'both', requires: ['vat_registered'] },
|
||||
}
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
/**
|
||||
* Skills over MCP — domain-knowledge bodies the server ships alongside tools.
|
||||
*
|
||||
* A skill is a versioned Markdown document that documents *how* to compose
|
||||
* gnubok tools to accomplish a real-world workflow (month-end close, VAT
|
||||
* review, year-end, invoicing, payroll). Agents call gnubok_load_skill(slug)
|
||||
* to load only the skills they need for the current task — keeping context
|
||||
* lean while shipping deep domain knowledge alongside the protocol.
|
||||
* Two flavors live behind the same interface:
|
||||
*
|
||||
* - **workflow** skills are static Markdown documents the server ships
|
||||
* alongside tools (month-end close, VAT review, year-end, invoicing,
|
||||
* payroll). They document *how* to compose gnubok tools for a real-world
|
||||
* workflow.
|
||||
* - **horizontal / vertical / modifier** atoms are loaded dynamically from
|
||||
* `agent_atom_registry` — the same atoms the in-app composer assembles for
|
||||
* a company. Exposing them here gives Claude.ai parity with the in-app
|
||||
* surface (plan §13 MCP parity).
|
||||
*
|
||||
* Agents call `gnubok_list_skills` to discover, then `gnubok_load_skill(slug)`
|
||||
* to read a single body. Atom slugs match their registry id verbatim
|
||||
* (e.g. "vertical/konsult-it", "modifier/holding-ab").
|
||||
*
|
||||
* Forward-compatible: when MCP adds a native `skills/list` primitive, the
|
||||
* Skill interface and bodies migrate without changes.
|
||||
*/
|
||||
|
||||
export type SkillTier = 'workflow' | 'horizontal' | 'vertical' | 'modifier'
|
||||
|
||||
/**
|
||||
* Pre-conditions a skill needs to be relevant to a company. `gnubok_list_skills`
|
||||
* filters out skills whose applicability doesn't match the current company by
|
||||
* default — agents get a focused list. Pass `include_all: true` on the tool
|
||||
* call to bypass the filter when needed.
|
||||
*
|
||||
* Each condition is optional and ANDed:
|
||||
* - `entity_type: 'AB'` hides the skill for sole traders (EF)
|
||||
* - `entity_type: 'EF'` hides it for limited companies
|
||||
* - `requires: ['employees']` hides the skill until the company has ≥ 1 employee
|
||||
* - `requires: ['vat_registered']` hides it for non-VAT-registered companies
|
||||
*
|
||||
* Workflow skills that are universal (e.g. invoicing-rules) leave applicability
|
||||
* undefined and are always shown.
|
||||
*/
|
||||
export interface SkillApplicability {
|
||||
entity_type?: 'AB' | 'EF' | 'both'
|
||||
requires?: ('employees' | 'vat_registered')[]
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
/** URL-safe id, used in tool args and resource URIs (e.g. "month-end-close"). */
|
||||
/** URL-safe id, used in tool args and resource URIs. Workflow skills use a
|
||||
* flat slug ("month-end-close"); atoms use their tier-prefixed registry id
|
||||
* ("vertical/konsult-it"). */
|
||||
slug: string
|
||||
/** Display name (e.g. "Month-End Close"). */
|
||||
name: string
|
||||
@@ -21,6 +55,10 @@ export interface Skill {
|
||||
tags: string[]
|
||||
/** Full skill body as Markdown. */
|
||||
body: string
|
||||
/** Where the skill comes from — static workflow or a registry-loaded atom. */
|
||||
tier: SkillTier
|
||||
/** Optional company-context filter applied by gnubok_list_skills. */
|
||||
applicability?: SkillApplicability
|
||||
}
|
||||
|
||||
export const SKILL_MIME_TYPE = 'text/markdown' as const
|
||||
@@ -28,11 +66,19 @@ export const SKILL_MIME_TYPE = 'text/markdown' as const
|
||||
/** Resource URI prefix for skills exposed via resources/read. */
|
||||
export const SKILL_URI_PREFIX = 'gnubok://skill/' as const
|
||||
|
||||
/** Build a resource URI for a skill. Slugs with slashes (atom ids) are
|
||||
* URL-encoded so the URI remains well-formed. */
|
||||
export function skillUri(slug: string): string {
|
||||
return `${SKILL_URI_PREFIX}${slug}`
|
||||
return `${SKILL_URI_PREFIX}${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
export function skillSlugFromUri(uri: string): string | null {
|
||||
if (!uri.startsWith(SKILL_URI_PREFIX)) return null
|
||||
return uri.slice(SKILL_URI_PREFIX.length) || null
|
||||
const raw = uri.slice(SKILL_URI_PREFIX.length)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,4 +100,8 @@ export const yearEndCloseSkill: Skill = {
|
||||
summary: 'Annual close: bokslutstransaktioner, currency revaluation, lock → year-end → opening balances → close. Irreversible.',
|
||||
tags: ['yearly', 'close', 'bokslut', 'compliance'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
// AB-specific. Sole traders (EF) use a different year-end path (NE-bilaga)
|
||||
// covered by a separate skill that we'll add when bokslut for EF lands.
|
||||
applicability: { entity_type: 'AB' },
|
||||
}
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock the tic-client functions
|
||||
// Mock the tic-client functions. /lookup now only calls
|
||||
// searchCompanyByOrgNumber — the Phase 2 fetchers stay mocked because the
|
||||
// /profile handler shares the module, but we don't expect /lookup to invoke
|
||||
// them.
|
||||
vi.mock('../lib/tic-client', () => ({
|
||||
searchCompanyByOrgNumber: vi.fn(),
|
||||
getBankAccounts: vi.fn(),
|
||||
getSNICodes: vi.fn(),
|
||||
getIndustryCodes: vi.fn(),
|
||||
getEmails: vi.fn(),
|
||||
getPhones: vi.fn(),
|
||||
getFiscalYears: vi.fn(),
|
||||
}))
|
||||
|
||||
import { ticExtension } from '../index'
|
||||
import {
|
||||
searchCompanyByOrgNumber,
|
||||
getBankAccounts,
|
||||
getSNICodes,
|
||||
getIndustryCodes,
|
||||
getEmails,
|
||||
getPhones,
|
||||
getFiscalYears,
|
||||
} from '../lib/tic-client'
|
||||
import { TICAPIError } from '../lib/tic-types'
|
||||
import type { TICCompanyDocument } from '../lib/tic-types'
|
||||
|
||||
const mockSearch = vi.mocked(searchCompanyByOrgNumber)
|
||||
const mockBank = vi.mocked(getBankAccounts)
|
||||
const mockSNI = vi.mocked(getSNICodes)
|
||||
const mockIndustries = vi.mocked(getIndustryCodes)
|
||||
const mockEmails = vi.mocked(getEmails)
|
||||
const mockPhones = vi.mocked(getPhones)
|
||||
const mockFiscalYears = vi.mocked(getFiscalYears)
|
||||
|
||||
function makeRequest(orgNumber?: string): Request {
|
||||
const url = orgNumber
|
||||
@@ -35,6 +41,9 @@ function makeRequest(orgNumber?: string): Request {
|
||||
|
||||
const lookupHandler = ticExtension.apiRoutes![0].handler
|
||||
|
||||
// Search-public document now carries everything /lookup needs at the top
|
||||
// level — sniCodes, bankAccounts, emailAddresses, phoneNumbers,
|
||||
// mostRecentFinancialSummary. The previous Phase 2 fan-out duplicated these.
|
||||
const mockDoc: TICCompanyDocument = {
|
||||
companyId: 42,
|
||||
registrationNumber: '5560360793',
|
||||
@@ -45,13 +54,25 @@ const mockDoc: TICCompanyDocument = {
|
||||
legalEntityType: 'AB',
|
||||
registrationDate: 0,
|
||||
mostRecentRegisteredAddress: {
|
||||
street: 'Storgatan 1',
|
||||
streetAddress: 'Storgatan 1',
|
||||
postalCode: '111 22',
|
||||
city: 'Stockholm',
|
||||
},
|
||||
isRegisteredForFTax: true,
|
||||
isRegisteredForVAT: true,
|
||||
activityStatus: 'active',
|
||||
isCeased: false,
|
||||
activityStatus: 'isActive',
|
||||
sniCodes: [
|
||||
{ rank: 1, sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering', sni_2007Section: 'J' },
|
||||
],
|
||||
bankAccounts: [{ accountNumber: '1234567', bankAccountType: 'bankgiro' }],
|
||||
emailAddresses: [{ emailAddress: 'info@test.se', emailAddressType: 'general' }],
|
||||
phoneNumbers: [{ phoneNumberFormatted: '08-1234567', e164PhoneNumber: '+4681234567' }],
|
||||
mostRecentFinancialSummary: {
|
||||
// 2024-01-01 → 2024-12-31 (Unix seconds, UTC)
|
||||
periodStart: Math.floor(Date.UTC(2024, 0, 1) / 1000),
|
||||
periodEnd: Math.floor(Date.UTC(2024, 11, 31) / 1000),
|
||||
},
|
||||
}
|
||||
|
||||
describe('TIC lookup route', () => {
|
||||
@@ -71,16 +92,8 @@ describe('TIC lookup route', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns full lookup result on happy path', async () => {
|
||||
it('returns full lookup result from the search doc alone', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockBank.mockResolvedValue([
|
||||
{ bankAccountType: 1, accountNumber: '123-456', swift_BIC: undefined },
|
||||
])
|
||||
mockSNI.mockResolvedValue([
|
||||
{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' },
|
||||
])
|
||||
mockEmails.mockResolvedValue([{ emailAddress: 'info@test.se' }])
|
||||
mockPhones.mockResolvedValue([{ phoneNumber: '08-1234567' }])
|
||||
|
||||
const res = await lookupHandler(makeRequest('556036-0793'))
|
||||
expect(res.status).toBe(200)
|
||||
@@ -95,31 +108,90 @@ describe('TIC lookup route', () => {
|
||||
})
|
||||
expect(data.registration).toEqual({ fTax: true, vat: true })
|
||||
expect(data.bankAccounts).toEqual([
|
||||
{ type: 'bankgiro', accountNumber: '123-456', bic: null },
|
||||
{ type: 'bankgiro', accountNumber: '1234567', bic: null },
|
||||
])
|
||||
expect(data.sniCodes).toEqual([{ code: '62010', name: 'Dataprogrammering' }])
|
||||
expect(data.email).toBe('info@test.se')
|
||||
expect(data.phone).toBe('08-1234567')
|
||||
expect(data.fiscalYear).toEqual({ startMonthDay: '01-01', endMonthDay: '12-31' })
|
||||
})
|
||||
|
||||
it('does NOT fan out to Phase 2 endpoints', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
await lookupHandler(makeRequest('556036-0793'))
|
||||
expect(mockBank).not.toHaveBeenCalled()
|
||||
expect(mockIndustries).not.toHaveBeenCalled()
|
||||
expect(mockEmails).not.toHaveBeenCalled()
|
||||
expect(mockPhones).not.toHaveBeenCalled()
|
||||
expect(mockFiscalYears).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('derives fiscal year MM-DD from mostRecentFinancialSummary', async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
...mockDoc,
|
||||
mostRecentFinancialSummary: {
|
||||
// 2024-07-01 → 2025-06-30 (broken fiscal year)
|
||||
periodStart: Math.floor(Date.UTC(2024, 6, 1) / 1000),
|
||||
periodEnd: Math.floor(Date.UTC(2025, 5, 30) / 1000),
|
||||
},
|
||||
})
|
||||
|
||||
const res = await lookupHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
expect(data.fiscalYear).toEqual({ startMonthDay: '07-01', endMonthDay: '06-30' })
|
||||
})
|
||||
|
||||
it('returns fiscalYear null when the company has no closed period yet', async () => {
|
||||
mockSearch.mockResolvedValue({ ...mockDoc, mostRecentFinancialSummary: undefined })
|
||||
|
||||
const res = await lookupHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
expect(data.fiscalYear).toBeNull()
|
||||
})
|
||||
|
||||
it('filters non-bankgiro entries from doc.bankAccounts', async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
...mockDoc,
|
||||
bankAccounts: [
|
||||
{ accountNumber: '1234567', bankAccountType: 'bankgiro' },
|
||||
{ accountNumber: 'SE45 5000', bankAccountType: 'iban' },
|
||||
],
|
||||
})
|
||||
|
||||
const res = await lookupHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
expect(data.bankAccounts).toEqual([
|
||||
{ type: 'bankgiro', accountNumber: '1234567', bic: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to e164 when phoneNumberFormatted is missing', async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
...mockDoc,
|
||||
phoneNumbers: [{ e164PhoneNumber: '+4681234567' }],
|
||||
})
|
||||
|
||||
const res = await lookupHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
expect(data.phone).toBe('+4681234567')
|
||||
})
|
||||
|
||||
it('prefers name type over other naming types', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockBank.mockResolvedValue(null)
|
||||
mockSNI.mockResolvedValue(null)
|
||||
mockEmails.mockResolvedValue(null)
|
||||
mockPhones.mockResolvedValue(null)
|
||||
|
||||
const res = await lookupHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
expect(data.companyName).toBe('Test AB')
|
||||
})
|
||||
|
||||
it('handles partial Phase 2 failures gracefully', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockBank.mockRejectedValue(new Error('timeout'))
|
||||
mockSNI.mockResolvedValue([{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' }])
|
||||
mockEmails.mockRejectedValue(new Error('timeout'))
|
||||
mockPhones.mockResolvedValue(null)
|
||||
it('handles missing optional fields gracefully', async () => {
|
||||
mockSearch.mockResolvedValue({
|
||||
...mockDoc,
|
||||
sniCodes: undefined,
|
||||
bankAccounts: undefined,
|
||||
emailAddresses: undefined,
|
||||
phoneNumbers: undefined,
|
||||
})
|
||||
|
||||
const res = await lookupHandler(makeRequest('556036-0793'))
|
||||
expect(res.status).toBe(200)
|
||||
@@ -127,17 +199,13 @@ describe('TIC lookup route', () => {
|
||||
const { data } = await res.json()
|
||||
expect(data.companyName).toBe('Test AB')
|
||||
expect(data.bankAccounts).toEqual([])
|
||||
expect(data.sniCodes).toHaveLength(1)
|
||||
expect(data.sniCodes).toEqual([])
|
||||
expect(data.email).toBeNull()
|
||||
expect(data.phone).toBeNull()
|
||||
})
|
||||
|
||||
it('detects ceased companies', async () => {
|
||||
mockSearch.mockResolvedValue({ ...mockDoc, activityStatus: 'ceased' })
|
||||
mockBank.mockResolvedValue(null)
|
||||
mockSNI.mockResolvedValue(null)
|
||||
mockEmails.mockResolvedValue(null)
|
||||
mockPhones.mockResolvedValue(null)
|
||||
it('detects ceased companies via isCeased boolean', async () => {
|
||||
mockSearch.mockResolvedValue({ ...mockDoc, isCeased: true, activityStatus: 'isNoLongerActive' })
|
||||
|
||||
const res = await lookupHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
|
||||
@@ -2,33 +2,53 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../lib/tic-client', () => ({
|
||||
searchCompanyByOrgNumber: vi.fn(),
|
||||
// The handler no longer calls these — kept mocked so the import doesn't
|
||||
// throw and so we can assert below that they're NOT invoked.
|
||||
getBankAccounts: vi.fn(),
|
||||
getSNICodes: vi.fn(),
|
||||
getIndustryCodes: vi.fn(),
|
||||
getEmails: vi.fn(),
|
||||
getPhones: vi.fn(),
|
||||
getCompanyPurpose: vi.fn(),
|
||||
getFinancialReportSummaries: vi.fn(),
|
||||
getSignatory: vi.fn(),
|
||||
getCompanyDocuments: vi.fn(),
|
||||
getFiscalYears: vi.fn(),
|
||||
getPayrolls: vi.fn(),
|
||||
getRepresentatives: vi.fn(),
|
||||
getCompanyStatus: vi.fn(),
|
||||
getBeneficialOwners: vi.fn(),
|
||||
}))
|
||||
|
||||
import { ticExtension } from '../index'
|
||||
import {
|
||||
searchCompanyByOrgNumber,
|
||||
getBankAccounts,
|
||||
getSNICodes,
|
||||
getIndustryCodes,
|
||||
getEmails,
|
||||
getPhones,
|
||||
getCompanyPurpose,
|
||||
getFinancialReportSummaries,
|
||||
getCompanyDocuments,
|
||||
getFiscalYears,
|
||||
getPayrolls,
|
||||
getSignatory,
|
||||
getRepresentatives,
|
||||
getCompanyStatus,
|
||||
getBeneficialOwners,
|
||||
} from '../lib/tic-client'
|
||||
import type { TICCompanyDocument } from '../lib/tic-types'
|
||||
|
||||
const mockSearch = vi.mocked(searchCompanyByOrgNumber)
|
||||
const mockBank = vi.mocked(getBankAccounts)
|
||||
const mockSNI = vi.mocked(getSNICodes)
|
||||
const mockIndustries = vi.mocked(getIndustryCodes)
|
||||
const mockEmails = vi.mocked(getEmails)
|
||||
const mockPhones = vi.mocked(getPhones)
|
||||
const mockPurpose = vi.mocked(getCompanyPurpose)
|
||||
const mockReports = vi.mocked(getFinancialReportSummaries)
|
||||
const mockSignatory = vi.mocked(getSignatory)
|
||||
const mockDocuments = vi.mocked(getCompanyDocuments)
|
||||
const mockFiscalYears = vi.mocked(getFiscalYears)
|
||||
const mockPayrolls = vi.mocked(getPayrolls)
|
||||
const mockRepresentatives = vi.mocked(getRepresentatives)
|
||||
const mockStatus = vi.mocked(getCompanyStatus)
|
||||
const mockBeneficialOwners = vi.mocked(getBeneficialOwners)
|
||||
|
||||
function makeRequest(orgNumber?: string): Request {
|
||||
const url = orgNumber
|
||||
@@ -39,6 +59,9 @@ function makeRequest(orgNumber?: string): Request {
|
||||
|
||||
const profileHandler = ticExtension.apiRoutes![1].handler
|
||||
|
||||
// The search doc now carries everything we used to pull from dedicated v2
|
||||
// endpoints — sniCodes, bankAccounts, emailAddresses, phoneNumbers,
|
||||
// mostRecentSignatory — at the top level. /profile reads those directly.
|
||||
const mockDoc: TICCompanyDocument = {
|
||||
companyId: 42,
|
||||
registrationNumber: '5560360793',
|
||||
@@ -50,14 +73,15 @@ const mockDoc: TICCompanyDocument = {
|
||||
registrationDate: 946684800000,
|
||||
mostRecentPurpose: 'Software development',
|
||||
mostRecentRegisteredAddress: {
|
||||
street: 'Storgatan 1',
|
||||
streetAddress: 'Storgatan 1',
|
||||
postalCode: '111 22',
|
||||
city: 'Stockholm',
|
||||
},
|
||||
isRegisteredForFTax: true,
|
||||
isRegisteredForVAT: true,
|
||||
isRegisteredForPayroll: false,
|
||||
activityStatus: 'active',
|
||||
isCeased: false,
|
||||
activityStatus: 'isActive',
|
||||
cSector: { categoryCode: 1, categoryCodeDescription: 'Privat sektor' },
|
||||
cNbrEmployeesInterval: { categoryCode: 3, categoryCodeDescription: '10-49' },
|
||||
cTurnoverInterval: { categoryCode: 5, categoryCodeDescription: '10-50 MSEK' },
|
||||
@@ -73,29 +97,108 @@ const mockDoc: TICCompanyDocument = {
|
||||
km_NetProfitMargin: 12.3,
|
||||
km_EquityAssetsRatio: 45.2,
|
||||
},
|
||||
sniCodes: [
|
||||
{ rank: 1, sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering', sni_2007Section: 'J' },
|
||||
],
|
||||
bankAccounts: [{ accountNumber: '1234567', bankAccountType: 'bankgiro' }],
|
||||
emailAddresses: [{ emailAddress: 'info@test.se', emailAddressType: 'general' }],
|
||||
phoneNumbers: [{ phoneNumberFormatted: '08-1234567' }],
|
||||
mostRecentSignatory: {
|
||||
signatureDescription: 'Firman tecknas av styrelsen.',
|
||||
firstSeenAt: 1700000000,
|
||||
},
|
||||
}
|
||||
|
||||
function mockAllSupplementary() {
|
||||
mockBank.mockResolvedValue([
|
||||
{ bankAccountType: 1, accountNumber: '123-456', swift_BIC: undefined },
|
||||
])
|
||||
mockSNI.mockResolvedValue([
|
||||
{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' },
|
||||
])
|
||||
mockEmails.mockResolvedValue([{ emailAddress: 'info@test.se' }])
|
||||
mockPhones.mockResolvedValue([{ phoneNumber: '08-1234567' }])
|
||||
mockPurpose.mockResolvedValue([
|
||||
{ companyPurposeId: 1, purpose: 'Försäljning av drycker' },
|
||||
])
|
||||
mockReports.mockResolvedValue([
|
||||
function mockKeptSupplementary() {
|
||||
mockBeneficialOwners.mockResolvedValue([])
|
||||
mockDocuments.mockResolvedValue([
|
||||
{
|
||||
financialReportSummaryId: 1,
|
||||
title: 'Årsredovisning 2023',
|
||||
arrivalDate: '2024-06-15',
|
||||
periodStart: '2023-01-01',
|
||||
periodEnd: '2023-12-31',
|
||||
isAudited: true,
|
||||
auditOpinion: 'Ren',
|
||||
id: 'FRF_abc123',
|
||||
type: 'annualReport',
|
||||
financialReportMetadata: {
|
||||
arrivalDate: '2024-06-15',
|
||||
registrationDate: '2024-07-01',
|
||||
periodStart: '2023-01-01',
|
||||
periodEnd: '2023-12-31',
|
||||
isInterimReport: false,
|
||||
isConsolidatedAccounts: false,
|
||||
auditor: 'Jane Auditor',
|
||||
auditorFullName: 'Jane Auditor (FAR)',
|
||||
auditCompanyName: 'Big Audit AB',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'FRF_xyz999',
|
||||
type: 'minutes',
|
||||
financialReportMetadata: {},
|
||||
},
|
||||
])
|
||||
mockFiscalYears.mockResolvedValue([
|
||||
{
|
||||
startMonthDay: '01-01',
|
||||
endMonthDay: '12-31',
|
||||
startEndDescription: 'Jan–Dec',
|
||||
lastUpdatedAtUtc: '2024-06-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
startMonthDay: '07-01',
|
||||
endMonthDay: '06-30',
|
||||
startEndDescription: 'Jul–Jun',
|
||||
lastUpdatedAtUtc: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
])
|
||||
mockPayrolls.mockResolvedValue({
|
||||
payroll2: [
|
||||
{
|
||||
companyPayroll2Id: 1,
|
||||
periodStart: '2023-01-01',
|
||||
periodEnd: '2023-12-31',
|
||||
numberOfEmployees: 12,
|
||||
sumPayrollTax: 250_000,
|
||||
calculatedPersonnelCosts: 6_000_000,
|
||||
personnelCostsInAnnualReport: 5_950_000,
|
||||
deviation: 0.008,
|
||||
numberOfLateFeesForPeriod: 0,
|
||||
},
|
||||
],
|
||||
payrolls: [],
|
||||
})
|
||||
mockRepresentatives.mockResolvedValue({
|
||||
representativeInformation: [
|
||||
{
|
||||
companyRepresentativeInformationId: 1,
|
||||
numberOfBoardMembers: 3,
|
||||
numberOfDeputyBoardMembers: 1,
|
||||
hasVacancy: false,
|
||||
lastUpdatedAtUtc: '2024-06-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
representatives: [
|
||||
{
|
||||
companyPersonId: 100,
|
||||
positionType: 'ceo',
|
||||
positionDescription: 'Verkställande direktör',
|
||||
positionStart: '2022-01-01T00:00:00Z',
|
||||
positionEnd: null,
|
||||
roleByPersonName: 'Anna Andersson',
|
||||
},
|
||||
{
|
||||
companyPersonId: 101,
|
||||
positionType: 'boardMember',
|
||||
positionDescription: 'Styrelseledamot',
|
||||
positionStart: '2020-06-01T00:00:00Z',
|
||||
positionEnd: '2021-12-31T00:00:00Z',
|
||||
roleByPersonName: 'Old Member',
|
||||
},
|
||||
],
|
||||
})
|
||||
mockStatus.mockResolvedValue([
|
||||
{
|
||||
companyStatusId: 1,
|
||||
companyStatusType: 'isActive',
|
||||
companyStatusDescription: { code: 'isActive', name_SE: 'Aktivt', isCeased: false },
|
||||
statusDate: '2023-01-01T00:00:00Z',
|
||||
statusColor: 'green',
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -118,7 +221,7 @@ describe('TIC profile route', () => {
|
||||
|
||||
it('returns full profile on happy path', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockAllSupplementary()
|
||||
mockKeptSupplementary()
|
||||
|
||||
const res = await profileHandler(makeRequest('556036-0793'))
|
||||
expect(res.status).toBe(200)
|
||||
@@ -128,8 +231,8 @@ describe('TIC profile route', () => {
|
||||
expect(data.orgNumber).toBe('5560360793')
|
||||
expect(data.companyName).toBe('Test AB')
|
||||
expect(data.legalEntityType).toBe('AB')
|
||||
expect(data.activityStatus).toBe('active')
|
||||
expect(data.purpose).toBe('Försäljning av drycker')
|
||||
expect(data.activityStatus).toBe('isActive')
|
||||
expect(data.purpose).toBe('Software development')
|
||||
expect(data.address).toEqual({
|
||||
street: 'Storgatan 1',
|
||||
postalCode: '111 22',
|
||||
@@ -143,14 +246,37 @@ describe('TIC profile route', () => {
|
||||
expect(data.phone).toBe('08-1234567')
|
||||
expect(data.sniCodes).toEqual([{ code: '62010', name: 'Dataprogrammering' }])
|
||||
expect(data.bankAccounts).toEqual([
|
||||
{ type: 'bankgiro', accountNumber: '123-456', bic: null },
|
||||
{ type: 'bankgiro', accountNumber: '1234567', bic: null },
|
||||
])
|
||||
expect(data.fetchedAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('does NOT fan out to bank/industries/emails/phones/purposes/signatory anymore', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockKeptSupplementary()
|
||||
|
||||
await profileHandler(makeRequest('556036-0793'))
|
||||
|
||||
expect(mockBank).not.toHaveBeenCalled()
|
||||
expect(mockIndustries).not.toHaveBeenCalled()
|
||||
expect(mockEmails).not.toHaveBeenCalled()
|
||||
expect(mockPhones).not.toHaveBeenCalled()
|
||||
expect(mockPurpose).not.toHaveBeenCalled()
|
||||
expect(mockSignatory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps activityStatus to "ceased" when isCeased is true', async () => {
|
||||
mockSearch.mockResolvedValue({ ...mockDoc, isCeased: true, activityStatus: 'isNoLongerActive' })
|
||||
mockKeptSupplementary()
|
||||
|
||||
const res = await profileHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
expect(data.activityStatus).toBe('ceased')
|
||||
})
|
||||
|
||||
it('includes financial summary from company document', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockAllSupplementary()
|
||||
mockKeptSupplementary()
|
||||
|
||||
const res = await profileHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
@@ -168,25 +294,28 @@ describe('TIC profile route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('includes financial report summaries', async () => {
|
||||
it('maps v2 documents (annualReport only) to financial-report summaries', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockAllSupplementary()
|
||||
mockKeptSupplementary()
|
||||
|
||||
const res = await profileHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
|
||||
expect(data.financialReports).toHaveLength(1)
|
||||
expect(data.financialReports[0]).toMatchObject({
|
||||
title: 'Årsredovisning 2023',
|
||||
title: 'Årsredovisning',
|
||||
periodStart: '2023-01-01',
|
||||
periodEnd: '2023-12-31',
|
||||
arrivalDate: '2024-06-15',
|
||||
isInterimReport: false,
|
||||
isAudited: true,
|
||||
auditOpinion: 'Ren',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles missing financial summary gracefully', async () => {
|
||||
const docWithoutFinancials = { ...mockDoc, mostRecentFinancialSummary: undefined }
|
||||
mockSearch.mockResolvedValue(docWithoutFinancials)
|
||||
mockAllSupplementary()
|
||||
mockKeptSupplementary()
|
||||
|
||||
const res = await profileHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
@@ -194,26 +323,85 @@ describe('TIC profile route', () => {
|
||||
expect(data.financials).toBeNull()
|
||||
})
|
||||
|
||||
it('handles partial Phase 2 failures gracefully', async () => {
|
||||
it('degrades gracefully when remaining Phase 2 calls fail', async () => {
|
||||
// Doc still provides bankAccounts/sniCodes/email/phone/purpose/signatory
|
||||
// — only the kept Phase 2 endpoints can now fail.
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockBank.mockRejectedValue(new Error('timeout'))
|
||||
mockSNI.mockResolvedValue([{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' }])
|
||||
mockEmails.mockRejectedValue(new Error('timeout'))
|
||||
mockPhones.mockResolvedValue(null)
|
||||
mockPurpose.mockRejectedValue(new Error('timeout'))
|
||||
mockReports.mockRejectedValue(new Error('timeout'))
|
||||
mockDocuments.mockRejectedValue(new Error('timeout'))
|
||||
mockFiscalYears.mockRejectedValue(new Error('timeout'))
|
||||
mockPayrolls.mockRejectedValue(new Error('timeout'))
|
||||
mockRepresentatives.mockRejectedValue(new Error('timeout'))
|
||||
mockStatus.mockRejectedValue(new Error('timeout'))
|
||||
mockBeneficialOwners.mockRejectedValue(new Error('timeout'))
|
||||
|
||||
const res = await profileHandler(makeRequest('556036-0793'))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const { data } = await res.json()
|
||||
expect(data.companyName).toBe('Test AB')
|
||||
expect(data.bankAccounts).toEqual([])
|
||||
// These come from the search doc, so they survive Phase 2 failures
|
||||
expect(data.bankAccounts).toHaveLength(1)
|
||||
expect(data.sniCodes).toHaveLength(1)
|
||||
expect(data.email).toBeNull()
|
||||
expect(data.phone).toBeNull()
|
||||
expect(data.purpose).toBe('Software development') // falls back to mostRecentPurpose
|
||||
expect(data.email).toBe('info@test.se')
|
||||
expect(data.phone).toBe('08-1234567')
|
||||
expect(data.purpose).toBe('Software development')
|
||||
expect(data.signatory).toEqual([{ description: 'Firman tecknas av styrelsen.' }])
|
||||
// These come from Phase 2, so they degrade to empty
|
||||
expect(data.financialReports).toEqual([])
|
||||
expect(data.fiscalYear).toBeNull()
|
||||
expect(data.fiscalYearHistory).toEqual([])
|
||||
expect(data.board).toBeNull()
|
||||
expect(data.representatives).toEqual([])
|
||||
expect(data.payrolls).toEqual([])
|
||||
expect(data.statuses).toEqual([])
|
||||
})
|
||||
|
||||
it('exposes fiscal year, payroll history, signatory, board, representatives and status', async () => {
|
||||
mockSearch.mockResolvedValue(mockDoc)
|
||||
mockKeptSupplementary()
|
||||
|
||||
const res = await profileHandler(makeRequest('556036-0793'))
|
||||
const { data } = await res.json()
|
||||
|
||||
expect(data.fiscalYear).toEqual({
|
||||
startMonthDay: '01-01',
|
||||
endMonthDay: '12-31',
|
||||
description: 'Jan–Dec',
|
||||
})
|
||||
expect(data.fiscalYearHistory).toHaveLength(2)
|
||||
expect(data.fiscalYearHistory[0]).toMatchObject({ startMonthDay: '01-01', endMonthDay: '12-31' })
|
||||
|
||||
expect(data.signatory).toEqual([{ description: 'Firman tecknas av styrelsen.' }])
|
||||
|
||||
expect(data.board).toMatchObject({
|
||||
numberOfBoardMembers: 3,
|
||||
numberOfDeputyBoardMembers: 1,
|
||||
hasVacancy: false,
|
||||
})
|
||||
|
||||
expect(data.representatives).toHaveLength(1)
|
||||
expect(data.representatives[0]).toMatchObject({
|
||||
name: 'Anna Andersson',
|
||||
positionType: 'ceo',
|
||||
positionDescription: 'Verkställande direktör',
|
||||
})
|
||||
|
||||
expect(data.payrolls).toHaveLength(1)
|
||||
expect(data.payrolls[0]).toMatchObject({
|
||||
numberOfEmployees: 12,
|
||||
sumPayrollTax: 250_000,
|
||||
deviation: 0.008,
|
||||
})
|
||||
|
||||
expect(data.statuses).toEqual([
|
||||
{
|
||||
code: 'isActive',
|
||||
description: 'Aktivt',
|
||||
color: 'green',
|
||||
statusDate: '2023-01-01T00:00:00Z',
|
||||
isCeased: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('sets financials to null when no optional fields present', async () => {
|
||||
@@ -225,12 +413,12 @@ describe('TIC profile route', () => {
|
||||
registrationDate: 0,
|
||||
}
|
||||
mockSearch.mockResolvedValue(minimalDoc)
|
||||
mockBank.mockResolvedValue(null)
|
||||
mockSNI.mockResolvedValue(null)
|
||||
mockEmails.mockResolvedValue(null)
|
||||
mockPhones.mockResolvedValue(null)
|
||||
mockPurpose.mockResolvedValue(null)
|
||||
mockReports.mockResolvedValue(null)
|
||||
mockDocuments.mockResolvedValue(null)
|
||||
mockFiscalYears.mockResolvedValue(null)
|
||||
mockPayrolls.mockResolvedValue(null)
|
||||
mockRepresentatives.mockResolvedValue(null)
|
||||
mockStatus.mockResolvedValue(null)
|
||||
mockBeneficialOwners.mockResolvedValue(null)
|
||||
|
||||
const res = await profileHandler(makeRequest('1234567890'))
|
||||
const { data } = await res.json()
|
||||
@@ -244,5 +432,12 @@ describe('TIC profile route', () => {
|
||||
expect(data.turnoverRange).toBeNull()
|
||||
expect(data.financials).toBeNull()
|
||||
expect(data.financialReports).toEqual([])
|
||||
expect(data.fiscalYear).toBeNull()
|
||||
expect(data.fiscalYearHistory).toEqual([])
|
||||
expect(data.signatory).toEqual([])
|
||||
expect(data.board).toBeNull()
|
||||
expect(data.representatives).toEqual([])
|
||||
expect(data.payrolls).toEqual([])
|
||||
expect(data.statuses).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ticApiFetch, searchCompanyByOrgNumber, getBankAccounts, getSNICodes } from '../lib/tic-client'
|
||||
import {
|
||||
ticApiFetch,
|
||||
searchCompanyByOrgNumber,
|
||||
getBankAccounts,
|
||||
getIndustryCodes,
|
||||
getFiscalYears,
|
||||
getPayrolls,
|
||||
getSignatory,
|
||||
getRepresentatives,
|
||||
getCompanyStatus,
|
||||
__resetTicCacheForTest,
|
||||
} from '../lib/tic-client'
|
||||
import { TICAPIError } from '../lib/tic-types'
|
||||
|
||||
const PROXY_URL = 'https://proxy.example.com/api/tic/proxy'
|
||||
|
||||
describe('tic-client', () => {
|
||||
beforeEach(() => {
|
||||
__resetTicCacheForTest()
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
vi.stubEnv('TIC_API_PROXY_URL', PROXY_URL)
|
||||
})
|
||||
@@ -21,10 +33,10 @@ describe('tic-client', () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ data: 'test' }), { status: 200 }))
|
||||
|
||||
await ticApiFetch('/search/companies?q=5560360793&query_by=registrationNumber')
|
||||
await ticApiFetch('/search-public/companies?q=5560360793&query_by=registrationNumber')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`${PROXY_URL}?endpoint=${encodeURIComponent('/search/companies?q=5560360793&query_by=registrationNumber')}`,
|
||||
`${PROXY_URL}?endpoint=${encodeURIComponent('/search-public/companies?q=5560360793&query_by=registrationNumber')}`,
|
||||
expect.objectContaining({
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
@@ -80,6 +92,7 @@ describe('tic-client', () => {
|
||||
names: [{ nameOrIdentifier: 'Test AB', companyNamingType: 'name' }],
|
||||
legalEntityType: 'AB',
|
||||
registrationDate: 0,
|
||||
isCeased: false,
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify({ found: 1, hits: [{ document: doc }], facet_counts: [] }))
|
||||
@@ -89,6 +102,16 @@ describe('tic-client', () => {
|
||||
expect(result).toEqual(doc)
|
||||
})
|
||||
|
||||
it('hits the v2 /search-public/companies path', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ found: 0, hits: [] })))
|
||||
|
||||
await searchCompanyByOrgNumber('556036-0793')
|
||||
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(encodeURIComponent('/search-public/companies'))
|
||||
})
|
||||
|
||||
it('strips dashes and spaces from org number', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ found: 0, hits: [] })))
|
||||
@@ -110,22 +133,150 @@ describe('tic-client', () => {
|
||||
})
|
||||
|
||||
describe('getBankAccounts', () => {
|
||||
it('fetches bank accounts for company ID', async () => {
|
||||
const accounts = [{ bankAccountType: 1, accountNumber: '123-456' }]
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(accounts)))
|
||||
it('fetches bankgiro numbers for company ID via /companies/{id}/bank-accounts', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
const accounts = [{ bankgironumber: 1234567, terminated: false, name: 'Test AB' }]
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(accounts)))
|
||||
|
||||
const result = await getBankAccounts(123)
|
||||
expect(result).toEqual(accounts)
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(encodeURIComponent('/companies/123/bank-accounts'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSNICodes', () => {
|
||||
it('fetches SNI codes for company ID', async () => {
|
||||
const sni = [{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' }]
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(sni)))
|
||||
describe('getIndustryCodes', () => {
|
||||
it('fetches industry codes for company ID via /companies/{id}/industries', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
const codes = [
|
||||
{ companyIndustryCodeType: 'sni2007', industryCode: '62010', description: 'Dataprogrammering' },
|
||||
]
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(codes)))
|
||||
|
||||
const result = await getSNICodes(123)
|
||||
expect(result).toEqual(sni)
|
||||
const result = await getIndustryCodes(123)
|
||||
expect(result).toEqual(codes)
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(encodeURIComponent('/companies/123/industries'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFiscalYears', () => {
|
||||
it('hits /companies/{id}/fiscal-years', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
const rows = [{ startMonthDay: '01-01', endMonthDay: '12-31' }]
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(rows)))
|
||||
|
||||
const result = await getFiscalYears(123)
|
||||
expect(result).toEqual(rows)
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(encodeURIComponent('/companies/123/fiscal-years'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPayrolls', () => {
|
||||
it('hits /companies/{id}/payrolls and returns the wrapper object', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
const payload = { payroll2: [], payrolls: [] }
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(payload)))
|
||||
|
||||
const result = await getPayrolls(123)
|
||||
expect(result).toEqual(payload)
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(encodeURIComponent('/companies/123/payrolls'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSignatory', () => {
|
||||
it('hits /companies/{id}/signatory (singular per v2)', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify([])))
|
||||
|
||||
await getSignatory(123)
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(encodeURIComponent('/companies/123/signatory'))
|
||||
// Guard against v1's plural path sneaking back in
|
||||
expect(calledUrl).not.toContain(encodeURIComponent('/signatories'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRepresentatives', () => {
|
||||
it('hits /companies/{id}/representatives', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ representativeInformation: [], representatives: [] }))
|
||||
)
|
||||
|
||||
await getRepresentatives(123)
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(encodeURIComponent('/companies/123/representatives'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompanyStatus', () => {
|
||||
it('hits /companies/{id}/status', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify([])))
|
||||
|
||||
await getCompanyStatus(123)
|
||||
const calledUrl = mockFetch.mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(encodeURIComponent('/companies/123/status'))
|
||||
})
|
||||
})
|
||||
|
||||
// The in-process cache is the single most impactful change for TIC budget
|
||||
// hygiene. Onboarding fires the same orgnr lookup 2-3 times in <2 s
|
||||
// (server prefetch + client useEffect + duplicate-check). Without the
|
||||
// cache, that's 3x Lens spend per signup; with it, 1x. Test pins the
|
||||
// behavior so it can't regress silently.
|
||||
describe('in-process cache', () => {
|
||||
it('returns cached result for identical endpoint within TTL — fetch fires once', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
const body = { facet_counts: [], found: 1, hits: [{ document: { id: 1 } }] }
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify(body), { status: 200 }))
|
||||
|
||||
const first = await ticApiFetch('/search-public/companies?q=5560125790&query_by=registrationNumber')
|
||||
const second = await ticApiFetch('/search-public/companies?q=5560125790&query_by=registrationNumber')
|
||||
|
||||
expect(first).toEqual(body)
|
||||
expect(second).toEqual(body)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('caches 404 responses so org-number typos do not re-spend a call per keystroke', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValue(new Response('Not found', { status: 404 }))
|
||||
|
||||
const first = await ticApiFetch('/search-public/companies?q=000000-0000&query_by=registrationNumber')
|
||||
const second = await ticApiFetch('/search-public/companies?q=000000-0000&query_by=registrationNumber')
|
||||
|
||||
expect(first).toBeNull()
|
||||
expect(second).toBeNull()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does NOT cache 429 rate-limit responses (allows recovery after window resets)', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValueOnce(new Response('Rate limit', { status: 429 }))
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ found: 0, hits: [] }), { status: 200 }))
|
||||
|
||||
await expect(ticApiFetch('/search-public/companies?q=5560125790')).rejects.toMatchObject({
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
})
|
||||
// Second call should re-fetch (cache must not poison after a 429)
|
||||
const second = await ticApiFetch('/search-public/companies?q=5560125790')
|
||||
expect(second).toEqual({ found: 0, hits: [] })
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('different endpoints have independent cache entries', async () => {
|
||||
const mockFetch = vi.mocked(fetch)
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ found: 1 }), { status: 200 }))
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify([{ accountNumber: '123' }]), { status: 200 }))
|
||||
|
||||
await ticApiFetch('/search-public/companies?q=A')
|
||||
await ticApiFetch('/companies/42/bank-accounts')
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+340
-105
@@ -3,11 +3,17 @@ import { NextResponse } from 'next/server'
|
||||
import {
|
||||
searchCompanyByOrgNumber,
|
||||
getBankAccounts,
|
||||
getSNICodes,
|
||||
getIndustryCodes,
|
||||
getEmails,
|
||||
getPhones,
|
||||
getCompanyPurpose,
|
||||
getFinancialReportSummaries,
|
||||
getCompanyDocuments,
|
||||
getFiscalYears,
|
||||
getPayrolls,
|
||||
getSignatory,
|
||||
getRepresentatives,
|
||||
getCompanyStatus,
|
||||
getBeneficialOwners,
|
||||
} from './lib/tic-client'
|
||||
import {
|
||||
startBankIdAuth,
|
||||
@@ -18,7 +24,7 @@ import {
|
||||
fetchEnrichmentData,
|
||||
} from './lib/bankid-client'
|
||||
import { TICAPIError } from './lib/tic-types'
|
||||
import type { TICCompanyProfile } from './lib/tic-types'
|
||||
import type { TICCompanyProfile, TICFinancialReportSummary } from './lib/tic-types'
|
||||
import type { BankIdCompleteRequest } from './lib/bankid-types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid'
|
||||
@@ -159,14 +165,28 @@ async function fetchAndStoreEnrichment(
|
||||
const bankIdStartCooldowns = new Map<string, number>()
|
||||
const BANKID_START_COOLDOWN_MS = 5_000
|
||||
|
||||
/** Map TIC bankAccountType enum to human-readable string */
|
||||
function bankAccountTypeLabel(type?: number): string {
|
||||
switch (type) {
|
||||
case 0: return 'bankkonto'
|
||||
case 1: return 'bankgiro'
|
||||
case 2: return 'plusgiro'
|
||||
case 3: return 'iban'
|
||||
default: return 'bankkonto'
|
||||
/**
|
||||
* Map a v2 `CompanyDocument` (financial-report subset) into the legacy
|
||||
* `TICFinancialReportSummary` shape consumed by TicWorkspace. v2 nests
|
||||
* the metadata under `financialReportMetadata` and replaces v1's
|
||||
* `isAudited` boolean / `auditOpinion` string with auditor identity
|
||||
* fields — we derive `isAudited` from the presence of an auditor.
|
||||
*/
|
||||
function toFinancialReportSummary(
|
||||
doc: import('./lib/tic-types').TICDocument
|
||||
): TICFinancialReportSummary {
|
||||
const meta = doc.financialReportMetadata ?? {}
|
||||
const hasAuditor = Boolean(meta.auditor || meta.auditorFullName || meta.auditCompanyName)
|
||||
return {
|
||||
title: doc.type === 'annualReport' ? 'Årsredovisning' : doc.type,
|
||||
arrivalDate: meta.arrivalDate ?? undefined,
|
||||
registrationDate: meta.registrationDate ?? undefined,
|
||||
periodStart: meta.periodStart ?? undefined,
|
||||
periodEnd: meta.periodEnd ?? undefined,
|
||||
isInterimReport: meta.isInterimReport ?? undefined,
|
||||
isConsolidatedAccounts: meta.isConsolidatedAccounts ?? undefined,
|
||||
isAudited: hasAuditor ? true : undefined,
|
||||
auditOpinion: meta.auditorFullName ?? meta.auditCompanyName ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +204,28 @@ function bankAccountTypeLabel(type?: number): string {
|
||||
* Always logs the cleaned org number so we can correlate failures with input
|
||||
* in Vercel logs.
|
||||
*/
|
||||
// Derive `{ startMonthDay, endMonthDay }` (e.g. "01-01" / "12-31") from the
|
||||
// search doc's mostRecentFinancialSummary. periodStart/periodEnd are Unix
|
||||
// timestamps in seconds. Returns null when the company has no closed period
|
||||
// yet — the client's deriveFirstYearDefaults handles newly-registered
|
||||
// companies from registrationDate instead.
|
||||
function deriveFiscalYearMonthDay(
|
||||
fin: { periodStart?: number; periodEnd?: number } | undefined,
|
||||
): { startMonthDay: string | null; endMonthDay: string | null } | null {
|
||||
if (!fin?.periodStart || !fin?.periodEnd) return null
|
||||
const toMonthDay = (unixSeconds: number): string | null => {
|
||||
const d = new Date(unixSeconds * 1000)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getUTCDate()).padStart(2, '0')
|
||||
return `${mm}-${dd}`
|
||||
}
|
||||
const startMonthDay = toMonthDay(fin.periodStart)
|
||||
const endMonthDay = toMonthDay(fin.periodEnd)
|
||||
if (!startMonthDay && !endMonthDay) return null
|
||||
return { startMonthDay, endMonthDay }
|
||||
}
|
||||
|
||||
function handleTicError(
|
||||
error: unknown,
|
||||
log: { error: (msg: string, meta?: unknown) => void } | Console,
|
||||
@@ -281,7 +323,16 @@ export const ticExtension: Extension = {
|
||||
const cleanedOrgNumber = orgNumber.replace(/[\s-]/g, '')
|
||||
|
||||
try {
|
||||
// Phase 1: Search — returns name, address, registration flags
|
||||
// Single Lens call: the search-public document already includes
|
||||
// sniCodes, bankAccounts, emailAddresses, phoneNumbers, and the
|
||||
// registration flags at the top level — we used to fan out to
|
||||
// five dedicated endpoints for the same data (6 calls total) and
|
||||
// the 3 000/mo TIC budget couldn't sustain it. This endpoint now
|
||||
// costs ~1 call. Fiscal-year MM-DD is derived from
|
||||
// mostRecentFinancialSummary.periodStart/periodEnd when present;
|
||||
// newly-registered companies without a financial summary return
|
||||
// fiscalYear: null and the client-side first-year derivation
|
||||
// takes over (see deriveFirstYearDefaults).
|
||||
const doc = await searchCompanyByOrgNumber(orgNumber)
|
||||
|
||||
if (!doc) {
|
||||
@@ -291,18 +342,15 @@ export const ticExtension: Extension = {
|
||||
)
|
||||
}
|
||||
|
||||
// Extract company name (prefer 'name' type over other naming types)
|
||||
const nameEntry =
|
||||
doc.names.find((n) => n.companyNamingType === 'name') ?? doc.names[0]
|
||||
const companyName = nameEntry?.nameOrIdentifier ?? ''
|
||||
|
||||
const isCeased = doc.activityStatus === 'ceased'
|
||||
const isCeased = doc.isCeased ?? doc.activityStatus === 'isNoLongerActive'
|
||||
|
||||
const address = doc.mostRecentRegisteredAddress
|
||||
? {
|
||||
street: doc.mostRecentRegisteredAddress.streetAddress
|
||||
?? doc.mostRecentRegisteredAddress.street
|
||||
?? null,
|
||||
street: doc.mostRecentRegisteredAddress.streetAddress ?? null,
|
||||
postalCode: doc.mostRecentRegisteredAddress.postalCode ?? null,
|
||||
city: doc.mostRecentRegisteredAddress.city ?? null,
|
||||
}
|
||||
@@ -313,50 +361,31 @@ export const ticExtension: Extension = {
|
||||
vat: doc.isRegisteredForVAT ?? false,
|
||||
}
|
||||
|
||||
// Phase 2: Supplementary data (non-blocking)
|
||||
const companyId = doc.companyId
|
||||
const [bankResult, sniResult, emailResult, phoneResult] =
|
||||
await Promise.allSettled([
|
||||
getBankAccounts(companyId),
|
||||
getSNICodes(companyId),
|
||||
getEmails(companyId),
|
||||
getPhones(companyId),
|
||||
])
|
||||
const bankAccounts = (doc.bankAccounts ?? [])
|
||||
.filter((ba) => ba.accountNumber != null && ba.bankAccountType === 'bankgiro')
|
||||
.map((ba) => ({
|
||||
type: 'bankgiro',
|
||||
accountNumber: String(ba.accountNumber),
|
||||
bic: null,
|
||||
}))
|
||||
|
||||
const bankAccounts =
|
||||
bankResult.status === 'fulfilled' && bankResult.value
|
||||
? bankResult.value.map((ba) => ({
|
||||
type: bankAccountTypeLabel(ba.bankAccountType),
|
||||
accountNumber: ba.accountNumber ?? '',
|
||||
bic: ba.swift_BIC ?? null,
|
||||
}))
|
||||
: []
|
||||
// Search-doc shape is `{ rank, sni_2007Code, sni_2007Name, ... }`;
|
||||
// map to the canonical { code, name } the rest of the app expects.
|
||||
const sniCodes = (doc.sniCodes ?? [])
|
||||
.filter((s) => s.sni_2007Code)
|
||||
.map((s) => ({
|
||||
code: s.sni_2007Code ?? '',
|
||||
name: s.sni_2007Name ?? '',
|
||||
}))
|
||||
|
||||
const sniCodes =
|
||||
sniResult.status === 'fulfilled' && sniResult.value
|
||||
? sniResult.value.map((s) => ({
|
||||
code: s.sni_2007Code ?? '',
|
||||
name: s.sni_2007Name ?? '',
|
||||
}))
|
||||
: []
|
||||
|
||||
const email =
|
||||
emailResult.status === 'fulfilled' && emailResult.value?.[0]?.emailAddress
|
||||
? emailResult.value[0].emailAddress
|
||||
: null
|
||||
const email = doc.emailAddresses?.[0]?.emailAddress ?? null
|
||||
|
||||
const phone =
|
||||
phoneResult.status === 'fulfilled' && phoneResult.value?.[0]?.phoneNumber
|
||||
? phoneResult.value[0].phoneNumber
|
||||
: null
|
||||
doc.phoneNumbers?.[0]?.phoneNumberFormatted
|
||||
?? doc.phoneNumbers?.[0]?.e164PhoneNumber
|
||||
?? null
|
||||
|
||||
// Log Phase 2 failures for debugging
|
||||
if (bankResult.status === 'rejected') {
|
||||
log.warn('[tic] bank accounts fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(bankResult.reason) })
|
||||
}
|
||||
if (sniResult.status === 'rejected') {
|
||||
log.warn('[tic] SNI codes fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(sniResult.reason) })
|
||||
}
|
||||
const fiscalYear = deriveFiscalYearMonthDay(doc.mostRecentFinancialSummary)
|
||||
|
||||
const result: CompanyLookupResult = {
|
||||
companyName,
|
||||
@@ -367,6 +396,9 @@ export const ticExtension: Extension = {
|
||||
email,
|
||||
phone,
|
||||
sniCodes,
|
||||
fiscalYear,
|
||||
legalEntityType: doc.legalEntityType ?? null,
|
||||
registrationDate: doc.registrationDate ?? null,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
@@ -410,64 +442,256 @@ export const ticExtension: Extension = {
|
||||
const companyName = nameEntry?.nameOrIdentifier ?? ''
|
||||
const companyId = doc.companyId
|
||||
|
||||
// Phase 2: Supplementary data (non-blocking)
|
||||
const [bankResult, sniResult, emailResult, phoneResult, purposeResult, reportsResult] =
|
||||
await Promise.allSettled([
|
||||
getBankAccounts(companyId),
|
||||
getSNICodes(companyId),
|
||||
getEmails(companyId),
|
||||
getPhones(companyId),
|
||||
getCompanyPurpose(companyId),
|
||||
getFinancialReportSummaries(companyId),
|
||||
])
|
||||
// Phase 2: ONLY data not already present at the top level of the
|
||||
// search doc. We dropped bank-accounts, industries, email-addresses,
|
||||
// phone-numbers, purposes, and signatory — those duplicate the
|
||||
// search-doc fields and were burning 6 Lens calls per /profile
|
||||
// for nothing. Per-/profile cost: 13 → 7 calls.
|
||||
const [
|
||||
documentsResult,
|
||||
fiscalYearResult,
|
||||
payrollsResult,
|
||||
representativesResult,
|
||||
statusResult,
|
||||
beneficialOwnersResult,
|
||||
] = await Promise.allSettled([
|
||||
getCompanyDocuments(companyId),
|
||||
getFiscalYears(companyId),
|
||||
getPayrolls(companyId),
|
||||
getRepresentatives(companyId),
|
||||
getCompanyStatus(companyId),
|
||||
getBeneficialOwners(companyId),
|
||||
])
|
||||
|
||||
const bankAccounts =
|
||||
bankResult.status === 'fulfilled' && bankResult.value
|
||||
? bankResult.value.map((ba) => ({
|
||||
type: bankAccountTypeLabel(ba.bankAccountType),
|
||||
accountNumber: ba.accountNumber ?? '',
|
||||
bic: ba.swift_BIC ?? null,
|
||||
}))
|
||||
: []
|
||||
// Bankgiro list from search-doc (v2 `bankAccounts` array with
|
||||
// `{accountNumber, bankAccountType}`).
|
||||
const bankAccounts = (doc.bankAccounts ?? [])
|
||||
.filter((ba) => ba.accountNumber != null && ba.bankAccountType === 'bankgiro')
|
||||
.map((ba) => ({
|
||||
type: 'bankgiro',
|
||||
accountNumber: String(ba.accountNumber),
|
||||
bic: null,
|
||||
}))
|
||||
|
||||
const sniCodes =
|
||||
sniResult.status === 'fulfilled' && sniResult.value
|
||||
? sniResult.value.map((s) => ({
|
||||
code: s.sni_2007Code ?? '',
|
||||
name: s.sni_2007Name ?? '',
|
||||
}))
|
||||
: []
|
||||
// SNI codes from search-doc (`sniCodes` array with `sni_2007Code`).
|
||||
const sniCodes = (doc.sniCodes ?? [])
|
||||
.filter((s) => s.sni_2007Code)
|
||||
.map((s) => ({
|
||||
code: s.sni_2007Code ?? '',
|
||||
name: s.sni_2007Name ?? '',
|
||||
}))
|
||||
|
||||
const email =
|
||||
emailResult.status === 'fulfilled' && emailResult.value?.[0]?.emailAddress
|
||||
? emailResult.value[0].emailAddress
|
||||
: null
|
||||
const email = doc.emailAddresses?.[0]?.emailAddress ?? null
|
||||
|
||||
const phone =
|
||||
phoneResult.status === 'fulfilled' && phoneResult.value?.[0]?.phoneNumber
|
||||
? phoneResult.value[0].phoneNumber
|
||||
: null
|
||||
doc.phoneNumbers?.[0]?.phoneNumberFormatted
|
||||
?? doc.phoneNumbers?.[0]?.e164PhoneNumber
|
||||
?? null
|
||||
|
||||
// v2 `/companies/{id}/documents` returns every document the
|
||||
// company has filed; filter to annualReport rows and map into
|
||||
// the legacy summary shape the workspace expects. Kept as a
|
||||
// dedicated call: doc.documents has a different shape
|
||||
// (`companyDocumentType` vs `type`) so direct substitution
|
||||
// would silently drop annualReport detection.
|
||||
const financialReports =
|
||||
reportsResult.status === 'fulfilled' && reportsResult.value
|
||||
? reportsResult.value
|
||||
documentsResult.status === 'fulfilled' && documentsResult.value
|
||||
? documentsResult.value
|
||||
.filter((d) => d.type === 'annualReport')
|
||||
.map(toFinancialReportSummary)
|
||||
: []
|
||||
|
||||
// Use dedicated purpose endpoint, fall back to search result
|
||||
const purpose =
|
||||
purposeResult.status === 'fulfilled' && purposeResult.value?.[0]?.purpose
|
||||
? purposeResult.value[0].purpose
|
||||
: doc.mostRecentPurpose ?? null
|
||||
// Purpose: take `doc.mostRecentPurpose` directly. Dropped the
|
||||
// /purposes call (which sorted history descending and picked
|
||||
// the latest non-empty entry) — the search doc's
|
||||
// mostRecentPurpose is the same most-recently-registered string.
|
||||
const purpose = doc.mostRecentPurpose ?? null
|
||||
|
||||
// ── New v2 sections ─────────────────────────────────────────
|
||||
// Fiscal years: pick most-recently-updated row with both
|
||||
// start and end populated, plus a deduped history of distinct
|
||||
// start/end pairs sorted newest first.
|
||||
const fiscalYearRows =
|
||||
fiscalYearResult.status === 'fulfilled' && fiscalYearResult.value
|
||||
? [...fiscalYearResult.value].sort((a, b) =>
|
||||
(b.lastUpdatedAtUtc ?? '').localeCompare(a.lastUpdatedAtUtc ?? '')
|
||||
)
|
||||
: []
|
||||
const fiscalYearCurrent = fiscalYearRows.find(
|
||||
(fy) => fy.startMonthDay && fy.endMonthDay
|
||||
)
|
||||
const fiscalYear = fiscalYearCurrent
|
||||
? {
|
||||
startMonthDay: fiscalYearCurrent.startMonthDay ?? null,
|
||||
endMonthDay: fiscalYearCurrent.endMonthDay ?? null,
|
||||
description: fiscalYearCurrent.startEndDescription ?? null,
|
||||
}
|
||||
: null
|
||||
|
||||
const fiscalYearHistorySeen = new Set<string>()
|
||||
const fiscalYearHistory: import('./lib/tic-types').TICProfileFiscalYear[] = []
|
||||
for (const fy of fiscalYearRows) {
|
||||
if (!fy.startMonthDay && !fy.endMonthDay) continue
|
||||
const key = `${fy.startMonthDay ?? ''}|${fy.endMonthDay ?? ''}`
|
||||
if (fiscalYearHistorySeen.has(key)) continue
|
||||
fiscalYearHistorySeen.add(key)
|
||||
fiscalYearHistory.push({
|
||||
startMonthDay: fy.startMonthDay ?? null,
|
||||
endMonthDay: fy.endMonthDay ?? null,
|
||||
description: fy.startEndDescription ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
// Signatory: free-form firmateckning descriptions. v2 search doc
|
||||
// exposes `mostRecentSignatory` directly (single entry). Drops the
|
||||
// dedicated /signatory call — historical signatory entries are rare
|
||||
// to use in onboarding and the current entry is what the review
|
||||
// card surfaces.
|
||||
const signatory = (() => {
|
||||
const desc = doc.mostRecentSignatory?.signatureDescription?.trim()
|
||||
return desc ? [{ description: desc }] : []
|
||||
})()
|
||||
|
||||
// Board summary: most recently updated row from
|
||||
// representativeInformation
|
||||
const reprInfoRows =
|
||||
representativesResult.status === 'fulfilled' && representativesResult.value?.representativeInformation
|
||||
? [...representativesResult.value.representativeInformation].sort((a, b) =>
|
||||
(b.lastUpdatedAtUtc ?? '').localeCompare(a.lastUpdatedAtUtc ?? '')
|
||||
)
|
||||
: []
|
||||
const reprInfo = reprInfoRows[0]
|
||||
const board = reprInfo
|
||||
? {
|
||||
numberOfBoardMembers: reprInfo.numberOfBoardMembers ?? null,
|
||||
numberOfDeputyBoardMembers: reprInfo.numberOfDeputyBoardMembers ?? null,
|
||||
hasVacancy: reprInfo.hasVacancy ?? null,
|
||||
missingCEODate: reprInfo.missingCEODate ?? null,
|
||||
missingAuditor: reprInfo.missingAuditor ?? null,
|
||||
lastChangeDate: reprInfo.lastChangeDate ?? null,
|
||||
}
|
||||
: null
|
||||
|
||||
// Representatives: filter to currently-active positions
|
||||
// (positionEnd null or in the future) sorted by start date desc
|
||||
const nowIso = new Date().toISOString()
|
||||
const representatives =
|
||||
representativesResult.status === 'fulfilled' && representativesResult.value?.representatives
|
||||
? representativesResult.value.representatives
|
||||
.filter((p) => !p.positionEnd || p.positionEnd > nowIso)
|
||||
.sort((a, b) => (b.positionStart ?? '').localeCompare(a.positionStart ?? ''))
|
||||
.map((p) => ({
|
||||
name: p.roleByPersonName ?? null,
|
||||
positionType: p.positionType ?? null,
|
||||
positionDescription: p.positionDescription ?? null,
|
||||
positionStart: p.positionStart ?? null,
|
||||
positionEnd: p.positionEnd ?? null,
|
||||
}))
|
||||
: []
|
||||
|
||||
// Payrolls: map the modern payroll2 array, newest first
|
||||
const payrolls =
|
||||
payrollsResult.status === 'fulfilled' && payrollsResult.value?.payroll2
|
||||
? [...payrollsResult.value.payroll2]
|
||||
.sort((a, b) => (b.periodEnd ?? '').localeCompare(a.periodEnd ?? ''))
|
||||
.map((p) => ({
|
||||
periodStart: p.periodStart ?? null,
|
||||
periodEnd: p.periodEnd ?? null,
|
||||
numberOfEmployees: p.numberOfEmployees ?? null,
|
||||
sumPayrollTax: p.sumPayrollTax ?? null,
|
||||
calculatedPersonnelCosts: p.calculatedPersonnelCosts ?? null,
|
||||
personnelCostsInAnnualReport: p.personnelCostsInAnnualReport ?? null,
|
||||
deviation: p.deviation ?? null,
|
||||
numberOfLateFeesForPeriod: p.numberOfLateFeesForPeriod ?? null,
|
||||
}))
|
||||
: []
|
||||
|
||||
// Statuses: most recent first; map traffic-light color through
|
||||
// unchanged so the UI can render a badge.
|
||||
const statuses: import('./lib/tic-types').TICProfileStatus[] =
|
||||
statusResult.status === 'fulfilled' && statusResult.value
|
||||
? [...statusResult.value]
|
||||
.sort((a, b) => (b.statusDate ?? '').localeCompare(a.statusDate ?? ''))
|
||||
.map((s) => {
|
||||
const color = s.statusColor
|
||||
const validColor: 'red' | 'yellow' | 'green' | 'neutral' | null =
|
||||
color === 'red' || color === 'yellow' || color === 'green' || color === 'neutral'
|
||||
? color
|
||||
: null
|
||||
return {
|
||||
code: s.companyStatusDescription?.code ?? null,
|
||||
description:
|
||||
s.companyStatusDescription?.name_SE
|
||||
?? s.statusDescription
|
||||
?? s.companyStatusDescription?.name_EN
|
||||
?? null,
|
||||
color: validColor,
|
||||
statusDate: s.statusDate ?? null,
|
||||
isCeased: s.companyStatusDescription?.isCeased ?? null,
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
// Log Phase 2 failures
|
||||
if (bankResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: bank accounts fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(bankResult.reason) })
|
||||
if (documentsResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: documents fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(documentsResult.reason) })
|
||||
}
|
||||
if (sniResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: SNI codes fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(sniResult.reason) })
|
||||
if (fiscalYearResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: fiscal years fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(fiscalYearResult.reason) })
|
||||
}
|
||||
if (reportsResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: financial reports fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(reportsResult.reason) })
|
||||
if (payrollsResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: payrolls fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(payrollsResult.reason) })
|
||||
}
|
||||
if (representativesResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: representatives fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(representativesResult.reason) })
|
||||
}
|
||||
if (statusResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: status fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(statusResult.reason) })
|
||||
}
|
||||
if (beneficialOwnersResult.status === 'rejected') {
|
||||
log.warn('[tic] profile: beneficial-owners fetch failed', { orgNumber: cleanedOrgNumber, companyId, reason: String(beneficialOwnersResult.reason) })
|
||||
}
|
||||
|
||||
// Flatten the beneficial-owners response. Bolagsverket returns one
|
||||
// notification per registration event; the latest active
|
||||
// notification's owners are the current ones. v2 returns the
|
||||
// notifications as a top-level array (v1's wrapper with
|
||||
// `.notifications` / `.exempts` is gone). Personnummer is
|
||||
// intentionally excluded — PII we don't need cached and we
|
||||
// don't want it persisted on `companies.tic_snapshot`.
|
||||
let beneficialOwners: TICCompanyProfile['beneficialOwners'] = []
|
||||
if (beneficialOwnersResult.status === 'fulfilled' && beneficialOwnersResult.value) {
|
||||
const notifications = Array.isArray(beneficialOwnersResult.value)
|
||||
? beneficialOwnersResult.value
|
||||
: []
|
||||
// Prefer the latest notification by notificationDate (already
|
||||
// sorted descending in practice, but we sort defensively).
|
||||
const latest = [...notifications]
|
||||
.filter((n) => n && Array.isArray(n.bolagsverket_BeneficialOwner))
|
||||
.sort((a, b) => {
|
||||
const ad = a.notificationDate ?? ''
|
||||
const bd = b.notificationDate ?? ''
|
||||
return bd.localeCompare(ad)
|
||||
})[0]
|
||||
if (latest?.bolagsverket_BeneficialOwner) {
|
||||
beneficialOwners = latest.bolagsverket_BeneficialOwner
|
||||
.map((o) => {
|
||||
const nameParts = [o.firstName, o.middleName, o.lastName]
|
||||
.map((p) => (p ?? '').trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const name = nameParts.length > 0 ? nameParts.join(' ') : (o.fallbackName ?? '').trim()
|
||||
if (!name) return null
|
||||
return {
|
||||
name,
|
||||
extentCode: o.extentCode ?? null,
|
||||
extentDescription: o.extentDescription ?? null,
|
||||
citizenshipCountryCode: o.citizenshipCountryCode ?? null,
|
||||
countryOfResidenceCode: o.countryOfResidenceCode ?? null,
|
||||
registeredAt: latest.fromDate ?? latest.notificationDate ?? null,
|
||||
}
|
||||
})
|
||||
.filter((o): o is NonNullable<typeof o> => o !== null)
|
||||
}
|
||||
}
|
||||
|
||||
const fin = doc.mostRecentFinancialSummary
|
||||
@@ -485,19 +709,22 @@ export const ticExtension: Extension = {
|
||||
}
|
||||
: null
|
||||
|
||||
// Translate v2's activityStatus enum into the v1 string the
|
||||
// TicWorkspace `!== 'ceased'` check still compares against, so
|
||||
// the UI keeps showing "Avregistrerat" for deregistered
|
||||
// companies without UI changes.
|
||||
const isCeasedProfile = doc.isCeased ?? doc.activityStatus === 'isNoLongerActive'
|
||||
const profile: TICCompanyProfile = {
|
||||
companyId,
|
||||
orgNumber: doc.registrationNumber,
|
||||
companyName,
|
||||
legalEntityType: doc.legalEntityType,
|
||||
registrationDate: doc.registrationDate,
|
||||
activityStatus: doc.activityStatus ?? null,
|
||||
activityStatus: isCeasedProfile ? 'ceased' : (doc.activityStatus ?? null),
|
||||
purpose,
|
||||
address: doc.mostRecentRegisteredAddress
|
||||
? {
|
||||
street: doc.mostRecentRegisteredAddress.streetAddress
|
||||
?? doc.mostRecentRegisteredAddress.street
|
||||
?? null,
|
||||
street: doc.mostRecentRegisteredAddress.streetAddress ?? null,
|
||||
postalCode: doc.mostRecentRegisteredAddress.postalCode ?? null,
|
||||
city: doc.mostRecentRegisteredAddress.city ?? null,
|
||||
}
|
||||
@@ -516,8 +743,16 @@ export const ticExtension: Extension = {
|
||||
phone,
|
||||
sniCodes,
|
||||
bankAccounts,
|
||||
beneficialOwners,
|
||||
financials,
|
||||
financialReports,
|
||||
fiscalYear,
|
||||
fiscalYearHistory,
|
||||
signatory,
|
||||
board,
|
||||
representatives,
|
||||
payrolls,
|
||||
statuses,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,72 @@
|
||||
import type {
|
||||
TICCompanyResponse,
|
||||
TICCompanyDocument,
|
||||
TICBankAccount,
|
||||
TICSNICode,
|
||||
TICBankgirot,
|
||||
TICIndustryCode,
|
||||
TICEmail,
|
||||
TICPhone,
|
||||
TICCompanyPurpose,
|
||||
TICFinancialReportSummary,
|
||||
TICDocument,
|
||||
TICFiscalYear,
|
||||
TICAccountingPeriod,
|
||||
TICPayrollSummary,
|
||||
TICSignatory,
|
||||
TICRepresentatives,
|
||||
TICCompanyStatusEntry,
|
||||
TICBeneficialOwnerResponse,
|
||||
} from './tic-types'
|
||||
import { TICAPIError } from './tic-types'
|
||||
|
||||
const TIC_API_TIMEOUT = 15_000
|
||||
|
||||
// In-process TTL cache for proxy responses. Onboarding currently fires the
|
||||
// same org-number lookup 2–3 times in <2 s (server prefetch + client
|
||||
// useEffect + duplicate-check) and the agent build re-fetches /profile data
|
||||
// minutes later. The TIC budget (3000/mo Lens calls) can't absorb the
|
||||
// duplication. 5 min is long enough to collapse a full onboarding flow into
|
||||
// one upstream hit and short enough that stale data never matters (TIC
|
||||
// data changes on Bolagsverket filing cycles measured in days).
|
||||
const CACHE_TTL_MS = 5 * 60_000
|
||||
const CACHE_MAX_ENTRIES = 500
|
||||
interface CacheEntry {
|
||||
expiresAt: number
|
||||
value: unknown
|
||||
}
|
||||
const proxyCache = new Map<string, CacheEntry>()
|
||||
|
||||
function cacheGet<T>(key: string): T | undefined {
|
||||
const entry = proxyCache.get(key)
|
||||
if (!entry) return undefined
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
proxyCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
return entry.value as T
|
||||
}
|
||||
|
||||
function cacheSet(key: string, value: unknown): void {
|
||||
if (proxyCache.size >= CACHE_MAX_ENTRIES) {
|
||||
// LRU-ish eviction: drop the oldest 10% so we never grow unbounded.
|
||||
const drop = Math.max(1, Math.floor(CACHE_MAX_ENTRIES / 10))
|
||||
const keys = Array.from(proxyCache.keys()).slice(0, drop)
|
||||
for (const k of keys) proxyCache.delete(k)
|
||||
}
|
||||
proxyCache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value })
|
||||
}
|
||||
|
||||
// Test-only: flush the in-process cache so per-test fixtures don't bleed
|
||||
// across cases. Not used by production code.
|
||||
export function __resetTicCacheForTest(): void {
|
||||
proxyCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic TIC API fetch helper.
|
||||
* Routes through the proxy at TIC_API_PROXY_URL (no API key needed).
|
||||
*
|
||||
* Routes through the proxy at TIC_API_PROXY_URL (no API key needed in this
|
||||
* codebase). The proxy targets `lens-api.tic.io` (v2 "Lens API") and adds
|
||||
* `x-api-key` server-side. v1 (`api.tic.io`) is retired — all paths below
|
||||
* are Lens paths (no `/datasets/` prefix, `id` instead of `companyId`).
|
||||
*/
|
||||
export async function ticApiFetch<T>(endpoint: string): Promise<T | null> {
|
||||
const proxyUrl = process.env.TIC_API_PROXY_URL
|
||||
@@ -22,6 +74,15 @@ export async function ticApiFetch<T>(endpoint: string): Promise<T | null> {
|
||||
throw new TICAPIError('TIC_API_PROXY_URL is not configured', undefined, 'NOT_CONFIGURED')
|
||||
}
|
||||
|
||||
// Check the in-process cache first. The cache key is the endpoint (which
|
||||
// includes the org-number / company-id) so each unique upstream call is
|
||||
// memoized; 404 responses are cached as `null` deliberately so a typo'd
|
||||
// org-number doesn't re-spend a call on every keystroke.
|
||||
const cached = cacheGet<T | null>(endpoint)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const url = `${proxyUrl}?endpoint=${encodeURIComponent(endpoint)}`
|
||||
|
||||
try {
|
||||
@@ -31,10 +92,13 @@ export async function ticApiFetch<T>(endpoint: string): Promise<T | null> {
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
cacheSet(endpoint, null)
|
||||
return null
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
// Do NOT cache rate-limit responses — the next call after the window
|
||||
// resets should be allowed through.
|
||||
throw new TICAPIError('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
@@ -42,7 +106,9 @@ export async function ticApiFetch<T>(endpoint: string): Promise<T | null> {
|
||||
throw new TICAPIError(`TIC API error: ${response.statusText}`, response.status)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
const body = await response.json()
|
||||
cacheSet(endpoint, body)
|
||||
return body
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
|
||||
throw new TICAPIError('Request timeout', undefined, 'TIMEOUT')
|
||||
@@ -61,7 +127,7 @@ export async function searchCompanyByOrgNumber(
|
||||
): Promise<TICCompanyDocument | null> {
|
||||
const cleaned = orgNumber.replace(/[\s-]/g, '')
|
||||
const data = await ticApiFetch<TICCompanyResponse>(
|
||||
`/search/companies?q=${cleaned}&query_by=registrationNumber`
|
||||
`/search-public/companies?q=${cleaned}&query_by=registrationNumber`
|
||||
)
|
||||
|
||||
if (!data || data.found === 0 || !data.hits?.[0]) {
|
||||
@@ -71,32 +137,127 @@ export async function searchCompanyByOrgNumber(
|
||||
return data.hits[0].document
|
||||
}
|
||||
|
||||
/** Get bank accounts for a company. */
|
||||
export async function getBankAccounts(companyId: number): Promise<TICBankAccount[] | null> {
|
||||
return ticApiFetch<TICBankAccount[]>(`/datasets/companies/${companyId}/bank-accounts`)
|
||||
/**
|
||||
* Get bank accounts for a company. v2 narrows this endpoint to Bankgirot
|
||||
* numbers only (returns `Bankgironumber_Dto[]`); v1's IBAN / plusgiro
|
||||
* coverage is no longer available from this path.
|
||||
*/
|
||||
export async function getBankAccounts(companyId: number): Promise<TICBankgirot[] | null> {
|
||||
return ticApiFetch<TICBankgirot[]>(`/companies/${companyId}/bank-accounts`)
|
||||
}
|
||||
|
||||
/** Get SNI codes for a company. */
|
||||
export async function getSNICodes(companyId: number): Promise<TICSNICode[] | null> {
|
||||
return ticApiFetch<TICSNICode[]>(`/datasets/companies/${companyId}/se/sni`)
|
||||
/**
|
||||
* Get industry codes for a company. v2 returns a discriminated array
|
||||
* (`CompanyIndustryCode_Dto[]`) covering both SNI 2007 and SNI 2025;
|
||||
* callers filter by `companyIndustryCodeType` for the version they want.
|
||||
*/
|
||||
export async function getIndustryCodes(companyId: number): Promise<TICIndustryCode[] | null> {
|
||||
return ticApiFetch<TICIndustryCode[]>(`/companies/${companyId}/industries`)
|
||||
}
|
||||
|
||||
/** Get email addresses for a company. */
|
||||
export async function getEmails(companyId: number): Promise<TICEmail[] | null> {
|
||||
return ticApiFetch<TICEmail[]>(`/datasets/companies/${companyId}/email-addresses`)
|
||||
return ticApiFetch<TICEmail[]>(`/companies/${companyId}/email-addresses`)
|
||||
}
|
||||
|
||||
/** Get phone numbers for a company. */
|
||||
export async function getPhones(companyId: number): Promise<TICPhone[] | null> {
|
||||
return ticApiFetch<TICPhone[]>(`/datasets/companies/${companyId}/phone-numbers`)
|
||||
return ticApiFetch<TICPhone[]>(`/companies/${companyId}/phone-numbers`)
|
||||
}
|
||||
|
||||
/** Get company purpose / verksamhetsbeskrivning. */
|
||||
export async function getCompanyPurpose(companyId: number): Promise<TICCompanyPurpose[] | null> {
|
||||
return ticApiFetch<TICCompanyPurpose[]>(`/datasets/companies/${companyId}/purpose`)
|
||||
return ticApiFetch<TICCompanyPurpose[]>(`/companies/${companyId}/purposes`)
|
||||
}
|
||||
|
||||
/** Get financial report summaries for a company. */
|
||||
export async function getFinancialReportSummaries(companyId: number): Promise<TICFinancialReportSummary[] | null> {
|
||||
return ticApiFetch<TICFinancialReportSummary[]>(`/datasets/companies/${companyId}/financial-report-summaries`)
|
||||
/**
|
||||
* List all documents filed by the company (annual reports, audit reports,
|
||||
* articles of association, minutes, etc.). v2 replaces v1's
|
||||
* `/financial-report-summaries` with this broader endpoint. Filter the
|
||||
* result by `type === 'annualReport'` to recover the financial-report
|
||||
* subset.
|
||||
*/
|
||||
export async function getCompanyDocuments(companyId: number): Promise<TICDocument[] | null> {
|
||||
return ticApiFetch<TICDocument[]>(`/companies/${companyId}/documents`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fiscal-year configurations for a company. v2 endpoint with no v1
|
||||
* equivalent — used to auto-fill fiscal-year selection during gnubok
|
||||
* onboarding so the user doesn't have to enter MM-DD manually.
|
||||
*/
|
||||
export async function getFiscalYears(companyId: number): Promise<TICFiscalYear[] | null> {
|
||||
return ticApiFetch<TICFiscalYear[]>(`/companies/${companyId}/fiscal-years`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounting-period change history for a company. v2 endpoint with
|
||||
* no v1 equivalent — surfaces "this company has shifted its year-end"
|
||||
* during onboarding/customer-setup.
|
||||
*/
|
||||
export async function getAccountingPeriods(
|
||||
companyId: number
|
||||
): Promise<TICAccountingPeriod[] | null> {
|
||||
return ticApiFetch<TICAccountingPeriod[]>(`/companies/${companyId}/accounting-periods`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payroll summary for a company. v2 endpoint — restructured from
|
||||
* v1's `/se/payroll`, returns `{ payroll2, payrolls }` where `payroll2`
|
||||
* is the modern per-period breakdown and `payrolls` is the legacy
|
||||
* Skatteverket MOMS/AG totals.
|
||||
*/
|
||||
export async function getPayrolls(companyId: number): Promise<TICPayrollSummary | null> {
|
||||
return ticApiFetch<TICPayrollSummary>(`/companies/${companyId}/payrolls`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get firmateckning (signatory) rules for a company. v2 endpoint
|
||||
* (renamed from v1 `/signatories`). Free-form Swedish descriptions of
|
||||
* who can sign for the company; consumed by the AB invoice/årsredovisning
|
||||
* signer-pick flows.
|
||||
*/
|
||||
export async function getSignatory(companyId: number): Promise<TICSignatory[] | null> {
|
||||
return ticApiFetch<TICSignatory[]>(`/companies/${companyId}/signatory`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get representatives (board / CEO / auditor) for a company. v2 splits
|
||||
* what v1 called `/parties` into `/representatives` (this endpoint) and
|
||||
* `/beneficial-owners` (separate). Returns a wrapper with board-summary
|
||||
* counts plus the per-person list.
|
||||
*/
|
||||
export async function getRepresentatives(
|
||||
companyId: number
|
||||
): Promise<TICRepresentatives | null> {
|
||||
return ticApiFetch<TICRepresentatives>(`/companies/${companyId}/representatives`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current and historical status entries for a company (active, in
|
||||
* liquidation, struck off, bankruptcy, etc.). v2 endpoint. Each entry
|
||||
* carries a traffic-light `statusColor` (red/yellow/green/neutral) and
|
||||
* an `isCeased` flag inside `companyStatusDescription`.
|
||||
*/
|
||||
export async function getCompanyStatus(
|
||||
companyId: number
|
||||
): Promise<TICCompanyStatusEntry[] | null> {
|
||||
return ticApiFetch<TICCompanyStatusEntry[]>(`/companies/${companyId}/status`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current + historic beneficial owner records from Bolagsverket
|
||||
* (verklig huvudman per Lag 2017:631). Returns notifications and any
|
||||
* exempt-from-registration flags. Used to answer ownership questions
|
||||
* authoritatively rather than asking the user to confirm.
|
||||
*
|
||||
* v2 endpoint — split out from what v1 grouped under `/parties`.
|
||||
* Representatives (board/CEO/auditor) live at `/representatives` instead.
|
||||
*/
|
||||
export async function getBeneficialOwners(
|
||||
companyId: number,
|
||||
): Promise<TICBeneficialOwnerResponse | null> {
|
||||
return ticApiFetch<TICBeneficialOwnerResponse>(
|
||||
`/companies/${companyId}/beneficial-owners`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Search response wrapper */
|
||||
/** Search response wrapper (Typesense). v2 keeps the same hits/found shape. */
|
||||
export interface TICCompanyResponse {
|
||||
facet_counts: unknown[]
|
||||
found: number
|
||||
@@ -7,7 +7,14 @@ export interface TICCompanyResponse {
|
||||
}>
|
||||
}
|
||||
|
||||
/** Full company document from TIC search */
|
||||
/**
|
||||
* Company document from the Typesense `/search-public/companies` index.
|
||||
*
|
||||
* v2 (Lens) added `isCeased: boolean` as a top-level boolean and changed
|
||||
* `activityStatus` from a free-form string to an enum
|
||||
* (`hasNeverBeenActive | isActive | isNoLongerActive | unknown`). Existing
|
||||
* fields we read are unchanged.
|
||||
*/
|
||||
export interface TICCompanyDocument {
|
||||
companyId: number
|
||||
registrationNumber: string
|
||||
@@ -21,7 +28,6 @@ export interface TICCompanyDocument {
|
||||
registrationDate: number
|
||||
mostRecentPurpose?: string
|
||||
mostRecentRegisteredAddress?: {
|
||||
street?: string
|
||||
streetAddress?: string
|
||||
postalCode?: string
|
||||
city?: string
|
||||
@@ -30,6 +36,7 @@ export interface TICCompanyDocument {
|
||||
isRegisteredForVAT?: boolean
|
||||
isRegisteredForFTax?: boolean
|
||||
isRegisteredForPayroll?: boolean
|
||||
isCeased?: boolean
|
||||
activityStatus?: string
|
||||
cSector?: {
|
||||
categoryCode: number
|
||||
@@ -59,40 +66,71 @@ export interface TICCompanyDocument {
|
||||
km_NetProfitMargin?: number
|
||||
km_EquityAssetsRatio?: number
|
||||
}
|
||||
// Top-level fields the Lens search index already returns alongside the
|
||||
// company core. /lookup reads these directly instead of fanning out to
|
||||
// dedicated v2 endpoints — saves 5 calls per invocation.
|
||||
sniCodes?: Array<{
|
||||
rank?: number
|
||||
sni_2007Code?: string
|
||||
sni_2007Name?: string
|
||||
sni_2007Section?: string
|
||||
}>
|
||||
bankAccounts?: Array<{
|
||||
accountNumber?: string | number
|
||||
bankAccountType?: string
|
||||
}>
|
||||
emailAddresses?: Array<{
|
||||
emailAddress?: string
|
||||
emailAddressType?: string
|
||||
}>
|
||||
phoneNumbers?: Array<{
|
||||
phoneNumberFormatted?: string
|
||||
e164PhoneNumber?: string
|
||||
phoneNumberType?: string
|
||||
}>
|
||||
mostRecentSignatory?: {
|
||||
signatureDescription?: string
|
||||
firstSeenAt?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Bank account from /bank-accounts endpoint */
|
||||
export interface TICBankAccount {
|
||||
bankAccountType?: number // 0=Unknown, 1=Bankgiro, 2=Plusgiro, 3=IBAN, etc.
|
||||
accountNumber?: string
|
||||
swift_BIC?: string
|
||||
firstSeenAtUtc?: string
|
||||
lastSeenAtUtc?: string
|
||||
/**
|
||||
* v2 `/companies/{id}/bank-accounts` returns only Bankgirot numbers
|
||||
* (`Bankgironumber_Dto[]`), not full bank accounts. v1's IBAN / plusgiro
|
||||
* / generic bank-account coverage is gone from this endpoint.
|
||||
*/
|
||||
export interface TICBankgirot {
|
||||
bankgironumber?: number | null
|
||||
terminated?: boolean | null
|
||||
name?: string | null
|
||||
isTaxBankgironumber?: boolean | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
/** SNI code from /se/sni endpoint */
|
||||
export interface TICSNICode {
|
||||
sni_2007Code?: string
|
||||
sni_2007Name?: string
|
||||
sni_2007Section?: string
|
||||
isPrimary?: boolean
|
||||
/** v2 `/companies/{id}/industries` returns `CompanyIndustryCode_Dto[]`. */
|
||||
export interface TICIndustryCode {
|
||||
companyIndustryCodeType?: 'sni2007' | 'sni2025' | 'other' | string
|
||||
industryCode?: string | null
|
||||
description?: string | null
|
||||
rank?: number | null
|
||||
}
|
||||
|
||||
/** Email address from /email-addresses endpoint */
|
||||
/** v2 `/companies/{id}/email-addresses` returns `View_CompanyEmail[]`. */
|
||||
export interface TICEmail {
|
||||
emailAddress?: string
|
||||
firstSeenAtUtc?: string
|
||||
lastSeenAtUtc?: string
|
||||
emailAddress?: string | null
|
||||
firstSeenAtUtc?: string | null
|
||||
lastSeenAtUtc?: string | null
|
||||
}
|
||||
|
||||
/** Phone number from /phone-numbers endpoint */
|
||||
/** v2 `/companies/{id}/phone-numbers` returns `CompanyPhoneNumber_Dto[]`. */
|
||||
export interface TICPhone {
|
||||
phoneNumber?: string
|
||||
firstSeenAtUtc?: string
|
||||
lastSeenAtUtc?: string
|
||||
phoneNumberFormatted?: string | null
|
||||
e164PhoneNumber?: string | null
|
||||
firstSeenAtUtc?: string | null
|
||||
lastSeenAtUtc?: string | null
|
||||
}
|
||||
|
||||
/** Company purpose from /purpose endpoint */
|
||||
/** v2 `/companies/{id}/purposes` returns `CompanyPurpose_Dto[]`. */
|
||||
export interface TICCompanyPurpose {
|
||||
companyPurposeId?: number
|
||||
purpose?: string
|
||||
@@ -100,7 +138,88 @@ export interface TICCompanyPurpose {
|
||||
lastUpdatedAtUtc?: string
|
||||
}
|
||||
|
||||
/** Financial report summary from /financial-report-summaries endpoint */
|
||||
/**
|
||||
* Raw Bolagsverket beneficial-owner notification record — one per
|
||||
* registration event. The latest active notification is what we care
|
||||
* about; older ones describe ownership changes over time.
|
||||
*
|
||||
* v2 shape: matches `BeneficialOwnerNotification_Dto`. Personnummer
|
||||
* (`personalIdentityNumber`) is omitted from this interface — we never
|
||||
* cache it, and the rest of the codebase has no need for it.
|
||||
*/
|
||||
export interface TICBeneficialOwnerNotificationRaw {
|
||||
fromDate?: string | null
|
||||
notificationDate?: string | null
|
||||
statusCode?: string | null
|
||||
statusDescription?: string | null
|
||||
bolagsverket_BeneficialOwner?: {
|
||||
firstName?: string | null
|
||||
middleName?: string | null
|
||||
lastName?: string | null
|
||||
fallbackName?: string | null
|
||||
citizenshipCountryCode?: string | null
|
||||
countryOfResidenceCode?: string | null
|
||||
extentCode?: string | null
|
||||
extentDescription?: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/beneficial-owners` returns
|
||||
* `BeneficialOwnerNotification_Dto[]` directly — there is no wrapper.
|
||||
* v1's wrapper carried an `exempts` array; v2 dropped it (no equivalent
|
||||
* endpoint exists in the Lens spec), so the cached profile no longer
|
||||
* surfaces an exempt flag.
|
||||
*/
|
||||
export type TICBeneficialOwnerResponse = TICBeneficialOwnerNotificationRaw[]
|
||||
|
||||
/**
|
||||
* Document type enum from v2 `/companies/{id}/documents`. The endpoint
|
||||
* returns every document the company has filed (annual reports, audit
|
||||
* reports, articles of association, minutes, etc.); we filter on this
|
||||
* field to extract the financial-report subset that TicWorkspace shows.
|
||||
*/
|
||||
export type TICDocumentType =
|
||||
| 'annualReport'
|
||||
| 'interimReport'
|
||||
| 'auditReport'
|
||||
| 'articlesOfAssociation'
|
||||
| 'economicPlan'
|
||||
| 'certificateOfApproval'
|
||||
| 'minutes'
|
||||
| 'statutes'
|
||||
| 'receivedButNotRegistered'
|
||||
| 'receivedButTerminated'
|
||||
| 'other'
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/documents` row. The metadata that used to live as
|
||||
* flat fields on v1's `/financial-report-summaries` rows now lives nested
|
||||
* under `financialReportMetadata`. Files are fetched separately via
|
||||
* `/documents/{id}` using the FRF_-prefixed `id`.
|
||||
*/
|
||||
export interface TICDocument {
|
||||
id?: string | null
|
||||
type?: TICDocumentType | string
|
||||
financialReportMetadata?: {
|
||||
arrivalDate?: string | null
|
||||
registrationDate?: string | null
|
||||
periodStart?: string | null
|
||||
periodEnd?: string | null
|
||||
isInterimReport?: boolean | null
|
||||
isConsolidatedAccounts?: boolean | null
|
||||
auditor?: string | null
|
||||
auditorFullName?: string | null
|
||||
auditCompanyName?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized financial-report row consumed by TicWorkspace. v1's TIC
|
||||
* endpoint returned this shape directly; in v2 we derive it from
|
||||
* `TICDocument` (filtered to `type === 'annualReport'`). Keeping the
|
||||
* shape stable means TicWorkspace doesn't need to change.
|
||||
*/
|
||||
export interface TICFinancialReportSummary {
|
||||
financialReportSummaryId?: number
|
||||
title?: string
|
||||
@@ -114,6 +233,222 @@ export interface TICFinancialReportSummary {
|
||||
auditOpinion?: string
|
||||
}
|
||||
|
||||
/** Flattened beneficial owner record — verklig huvudman per
|
||||
* Lag (2017:631). Personnummer intentionally omitted to keep PII out of the
|
||||
* cached profile; we only need name + ownership extent for downstream use
|
||||
* (e.g. dropping "are you the sole owner?" verification questions). */
|
||||
export interface TICBeneficialOwner {
|
||||
name: string
|
||||
// Bolagsverket extent codes describe the share of ownership / control,
|
||||
// e.g. "OWNS_25_TO_50_PERCENT", "OWNS_OVER_50_PERCENT". Kept verbatim so
|
||||
// downstream Swedish-language formatting can map them.
|
||||
extentCode: string | null
|
||||
extentDescription: string | null
|
||||
citizenshipCountryCode: string | null
|
||||
countryOfResidenceCode: string | null
|
||||
registeredAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/fiscal-years` returns `CompanyFiscalYear_Dto[]`.
|
||||
* Each row records a fiscal-year configuration the company has used.
|
||||
* `startMonthDay` / `endMonthDay` are strings like "01-01" / "12-31".
|
||||
*/
|
||||
export interface TICFiscalYear {
|
||||
companyFiscalYearId?: number
|
||||
companyId?: number
|
||||
startMonthDay?: string | null
|
||||
endMonthDay?: string | null
|
||||
startEndDescription?: string | null
|
||||
firstSeenAtUtc?: string | null
|
||||
lastUpdatedAtUtc?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/accounting-periods` returns
|
||||
* `CompanyAccountingPeriod_Dto[]` — history of period-end changes
|
||||
* (e.g. shifted year-end). Useful as a "this company changed its books"
|
||||
* indicator during onboarding.
|
||||
*/
|
||||
export interface TICAccountingPeriod {
|
||||
companyAccountingPeriodId?: number
|
||||
companyId?: number
|
||||
endingDatePriorToChange?: string | null
|
||||
endingDateAfterChange?: string | null
|
||||
firstSeenAtUtc?: string | null
|
||||
lastUpdatedAtUtc?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/payrolls` returns a wrapper with two arrays.
|
||||
* `payroll2` is the modern per-period breakdown with deviation vs the
|
||||
* annual-report personnel-cost line; `payrolls` is the legacy
|
||||
* Skatteverket MOMS/AG period totals.
|
||||
*/
|
||||
export interface TICPayroll2 {
|
||||
companyPayroll2Id?: number
|
||||
periodStart?: string | null
|
||||
periodEnd?: string | null
|
||||
payrollPeriods?: number | null
|
||||
sumPayrollTax?: number | null
|
||||
numberOfPeriods?: number | null
|
||||
numberOfPeriodsWithZero?: number | null
|
||||
personnelCostsInAnnualReport?: number | null
|
||||
calculatedPersonnelCosts?: number | null
|
||||
deviationInCosts?: number | null
|
||||
deviationInCostsChange?: number | null
|
||||
deviation?: number | null
|
||||
numberOfEmployees?: number | null
|
||||
numberOfLateFeesForPeriod?: number | null
|
||||
taxSurchangeAmountForPeriod?: number | null
|
||||
lastUpdatedAtUtc?: string | null
|
||||
}
|
||||
|
||||
export interface TICPayrollMomsAg {
|
||||
skatteverket_MOMS_AGId?: number
|
||||
period?: number | null
|
||||
belopp?: number | null
|
||||
externtid?: number | null
|
||||
forandring?: number | null
|
||||
forandringProcent?: number | null
|
||||
}
|
||||
|
||||
export interface TICPayrollSummary {
|
||||
payroll2?: TICPayroll2[]
|
||||
payrolls?: TICPayrollMomsAg[]
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/signatory` returns `CompanySignatory_Dto[]`.
|
||||
* Each entry's `signatureDescription` is free-form Swedish text
|
||||
* describing firmateckning rules ("Firman tecknas av styrelsen.
|
||||
* Firman tecknas två i förening av ledamöterna.").
|
||||
*/
|
||||
export interface TICSignatory {
|
||||
companySignatoryId?: number
|
||||
companyId?: number
|
||||
signatureDescription?: string | null
|
||||
firstSeenAtUtc?: string | null
|
||||
lastSeenAtUtc?: string | null
|
||||
lastUpdatedAtUtc?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/representatives` returns a wrapper with two
|
||||
* arrays. `representativeInformation` is board-composition summary
|
||||
* (counts, vacancies); `representatives` is the per-person list with
|
||||
* positionType, dates, and (optionally) the person's name.
|
||||
*/
|
||||
export interface TICRepresentativeInfo {
|
||||
companyRepresentativeInformationId?: number
|
||||
numberOfBoardMembers?: number | null
|
||||
numberOfDeputyBoardMembers?: number | null
|
||||
hasVacancy?: boolean | null
|
||||
boardFromDate?: string | null
|
||||
missingCEODate?: string | null
|
||||
missingAuditor?: string | null
|
||||
boardNotFullyDate?: string | null
|
||||
lastChangeDate?: string | null
|
||||
lastUpdatedAtUtc?: string | null
|
||||
}
|
||||
|
||||
export interface TICCompanyPerson {
|
||||
companyPersonId?: number
|
||||
positionType?: string | null
|
||||
positionDescription?: string | null
|
||||
positionStart?: string | null
|
||||
positionEnd?: string | null
|
||||
roleByPersonName?: string | null
|
||||
roleByPersonalIdentityNumber?: string | null
|
||||
roleByCompanyName?: string | null
|
||||
roleByCompanyRegistrationNumber?: string | null
|
||||
auditorTypeDescription?: string | null
|
||||
residenceLocationTypeDescription?: string | null
|
||||
}
|
||||
|
||||
export interface TICRepresentatives {
|
||||
representativeInformation?: TICRepresentativeInfo[]
|
||||
representatives?: TICCompanyPerson[]
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/status` returns `CompanyStatus_Dto[]` — current
|
||||
* and historical status entries (active, in liquidation, struck off,
|
||||
* etc.). Each entry has a `statusColor` (red/yellow/green/neutral) and
|
||||
* a human-readable `statusDescription` we can surface directly.
|
||||
*/
|
||||
export interface TICCompanyStatusEntry {
|
||||
companyStatusId?: number
|
||||
companyId?: number
|
||||
companyStatusType?: string
|
||||
companyStatusDescription?: {
|
||||
code?: string
|
||||
name_EN?: string | null
|
||||
name_SE?: string | null
|
||||
isCeased?: boolean | null
|
||||
}
|
||||
statusDate?: string | null
|
||||
statusDescription?: string | null
|
||||
statusData?: string | null
|
||||
statusDataDescription?: string | null
|
||||
firstSeenAtUtc?: string | null
|
||||
lastSeenAtUtc?: string | null
|
||||
lastUpdatedAtUtc?: string | null
|
||||
statusColor?: 'red' | 'yellow' | 'green' | 'neutral' | string
|
||||
}
|
||||
|
||||
/** Normalized fiscal-year entry surfaced by /profile and /lookup. */
|
||||
export interface TICProfileFiscalYear {
|
||||
startMonthDay: string | null
|
||||
endMonthDay: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
/** Normalized signatory row surfaced by /profile. */
|
||||
export interface TICProfileSignatory {
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Normalized representative row surfaced by /profile. */
|
||||
export interface TICProfileRepresentative {
|
||||
name: string | null
|
||||
positionType: string | null
|
||||
positionDescription: string | null
|
||||
positionStart: string | null
|
||||
positionEnd: string | null
|
||||
}
|
||||
|
||||
/** Normalized board-composition summary surfaced by /profile. */
|
||||
export interface TICProfileBoardSummary {
|
||||
numberOfBoardMembers: number | null
|
||||
numberOfDeputyBoardMembers: number | null
|
||||
hasVacancy: boolean | null
|
||||
missingCEODate: string | null
|
||||
missingAuditor: string | null
|
||||
lastChangeDate: string | null
|
||||
}
|
||||
|
||||
/** Normalized payroll period surfaced by /profile. */
|
||||
export interface TICProfilePayrollPeriod {
|
||||
periodStart: string | null
|
||||
periodEnd: string | null
|
||||
numberOfEmployees: number | null
|
||||
sumPayrollTax: number | null
|
||||
calculatedPersonnelCosts: number | null
|
||||
personnelCostsInAnnualReport: number | null
|
||||
deviation: number | null
|
||||
numberOfLateFeesForPeriod: number | null
|
||||
}
|
||||
|
||||
/** Normalized status entry surfaced by /profile and /lookup. */
|
||||
export interface TICProfileStatus {
|
||||
code: string | null
|
||||
description: string | null
|
||||
color: 'red' | 'yellow' | 'green' | 'neutral' | null
|
||||
statusDate: string | null
|
||||
isCeased: boolean | null
|
||||
}
|
||||
|
||||
/** Normalized company profile for workspace display */
|
||||
export interface TICCompanyProfile {
|
||||
companyId: number
|
||||
@@ -132,6 +467,12 @@ export interface TICCompanyProfile {
|
||||
phone: string | null
|
||||
sniCodes: { code: string; name: string }[]
|
||||
bankAccounts: { type: string; accountNumber: string; bic: string | null }[]
|
||||
// Owners registered as verklig huvudman. Empty when the company has none
|
||||
// (e.g. listed companies are exempt) or when the dataset returned nothing.
|
||||
// v1 used to expose an explicit `exempts` array distinguishing the two;
|
||||
// v2 dropped that, so we infer "no owners and no error" === "exempt or none"
|
||||
// without surfacing the distinction.
|
||||
beneficialOwners: TICBeneficialOwner[]
|
||||
financials: {
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
@@ -144,6 +485,13 @@ export interface TICCompanyProfile {
|
||||
equityAssetsRatio: number | null
|
||||
} | null
|
||||
financialReports: TICFinancialReportSummary[]
|
||||
fiscalYear: TICProfileFiscalYear | null
|
||||
fiscalYearHistory: TICProfileFiscalYear[]
|
||||
signatory: TICProfileSignatory[]
|
||||
board: TICProfileBoardSummary | null
|
||||
representatives: TICProfileRepresentative[]
|
||||
payrolls: TICProfilePayrollPeriod[]
|
||||
statuses: TICProfileStatus[]
|
||||
fetchedAt: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user