f53725b20a
* 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>
2761 lines
102 KiB
TypeScript
2761 lines
102 KiB
TypeScript
/**
|
||
* Unified entry point for executing a pending_operation.
|
||
*
|
||
* Used by:
|
||
* - The web UI commit route (app/api/pending-operations/[id]/commit/route.ts)
|
||
* when a human clicks "Approve"
|
||
* - The MCP server (extensions/general/mcp-server/server.ts) when a trusted
|
||
* agent stages a low-risk op that the company has opted in to auto-commit
|
||
*
|
||
* Both paths converge here so the same audit trail, event emission, error
|
||
* handling, and status transition logic apply.
|
||
*
|
||
* The executor functions previously lived in the commit route. They are kept
|
||
* private to this module — call `commitPendingOperation()` to invoke them.
|
||
*/
|
||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { eventBus } from '@/lib/events'
|
||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||
import {
|
||
createInvoicePaymentJournalEntry,
|
||
createInvoiceCashEntry,
|
||
createInvoiceJournalEntry,
|
||
createCreditNoteJournalEntry,
|
||
} from '@/lib/bookkeeping/invoice-entries'
|
||
import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
||
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
|
||
import { closePeriod, lockPeriod, unlockPeriod, resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||
import {
|
||
executeYearEndClosing,
|
||
generateOpeningBalances,
|
||
} from '@/lib/core/bookkeeping/year-end-service'
|
||
import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation'
|
||
import {
|
||
createSupplierCreditNoteEntry,
|
||
createSupplierInvoiceRegistrationEntry,
|
||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||
import { parseSIEFile } from '@/lib/import/sie-parser'
|
||
import { executeSIEImport } from '@/lib/import/sie-import'
|
||
import type { AccountMapping } from '@/lib/import/types'
|
||
import { AccountsNotInChartError, isBookkeepingError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
|
||
import { getEmailService } from '@/lib/email/service'
|
||
import {
|
||
generateInvoiceEmailHtml,
|
||
generateInvoiceEmailText,
|
||
generateInvoiceEmailSubject,
|
||
} from '@/lib/email/invoice-templates'
|
||
import { uploadDocument, linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||
import { renderToBuffer } from '@react-pdf/renderer'
|
||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
|
||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||
import { createLogger } from '@/lib/logger'
|
||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||
import { z } from 'zod'
|
||
import type {
|
||
Transaction,
|
||
TransactionCategory,
|
||
EntityType,
|
||
VatTreatment,
|
||
Currency,
|
||
Invoice,
|
||
Customer,
|
||
Supplier,
|
||
SupplierInvoice,
|
||
SupplierInvoiceItem,
|
||
PendingOperation,
|
||
CompanySettings,
|
||
InvoiceItem,
|
||
AccountingMethod,
|
||
CreditNote,
|
||
CreateJournalEntryLineInput,
|
||
JournalEntrySourceType,
|
||
} from '@/types'
|
||
|
||
const log = createLogger('pending-operations/commit')
|
||
|
||
export interface CommitResult {
|
||
status: 'committed' | 'rejected' | 'failed'
|
||
data?: Record<string, unknown>
|
||
error?: string
|
||
http_status?: number
|
||
auto_rejected?: boolean
|
||
// Set when the commit failed because the booking posts to BAS accounts not
|
||
// active in the company chart. Recoverable — the op is left 'pending' so the
|
||
// caller can activate the accounts and retry. Lets the route rebuild the
|
||
// structured ACCOUNTS_NOT_IN_CHART envelope (code + account_numbers).
|
||
code?: string
|
||
account_numbers?: string[]
|
||
}
|
||
|
||
export interface CommitOptions {
|
||
/** Email address used as cc on send_invoice (typically the human user's email). */
|
||
userEmail?: string
|
||
/**
|
||
* commit_method recorded on any journal_entries created by this operation.
|
||
* Must match the CHECK constraint on journal_entries.commit_method:
|
||
* 'user_accept' | 'bulk_accept' | 'timing_ceiling' | 'migration' | 'legacy'.
|
||
* Single-approval route passes 'user_accept' (default); bulk-approval passes
|
||
* 'bulk_accept'. Defaults to 'user_accept' since the dispatcher is only
|
||
* invoked from human-approval paths after agent auto-commit was removed
|
||
* (migration 20260505190027_drop_agent_auto_commit).
|
||
*/
|
||
commitMethod?: 'user_accept' | 'bulk_accept'
|
||
}
|
||
|
||
// ── Helper: ensure fiscal period covers the date ──────────────────
|
||
|
||
async function ensureFiscalPeriod(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
date: string,
|
||
fiscalYearStartMonth: number = 1
|
||
): Promise<boolean> {
|
||
const { data: existing } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.lte('period_start', date)
|
||
.gte('period_end', date)
|
||
.eq('is_closed', false)
|
||
.limit(1)
|
||
|
||
if (existing && existing.length > 0) return true
|
||
|
||
const txDate = new Date(date)
|
||
const txMonth = txDate.getMonth() + 1
|
||
const txYear = txDate.getFullYear()
|
||
|
||
let periodStartYear: number
|
||
if (fiscalYearStartMonth === 1) {
|
||
periodStartYear = txYear
|
||
} else if (txMonth >= fiscalYearStartMonth) {
|
||
periodStartYear = txYear
|
||
} else {
|
||
periodStartYear = txYear - 1
|
||
}
|
||
|
||
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
|
||
const periodStart = `${periodStartYear}-${startMonth}-01`
|
||
|
||
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
|
||
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
|
||
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
||
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
||
|
||
const periodName = fiscalYearStartMonth === 1
|
||
? `Räkenskapsår ${periodStartYear}`
|
||
: `Räkenskapsår ${periodStartYear}/${endYear}`
|
||
|
||
const { error } = await supabase
|
||
.from('fiscal_periods')
|
||
.upsert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: periodName,
|
||
period_start: periodStart,
|
||
period_end: periodEnd,
|
||
}, { onConflict: 'user_id,period_start,period_end' })
|
||
|
||
if (error) {
|
||
log.error('Failed to create fiscal period:', error)
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function recordSkippedInvoiceJournalEntry(
|
||
invoiceId: string,
|
||
companyId: string,
|
||
userId: string,
|
||
operation: 'send_invoice' | 'mark_invoice_sent',
|
||
err: unknown
|
||
): Promise<void> {
|
||
try {
|
||
const reasonCode = err instanceof AccountsNotInChartError
|
||
? 'accounts_not_in_chart'
|
||
: 'journal_entry_error'
|
||
const accountNumbers = err instanceof AccountsNotInChartError ? err.accountNumbers : undefined
|
||
await appendProcessingHistory({
|
||
companyId,
|
||
correlationId: invoiceId,
|
||
aggregateType: 'System',
|
||
aggregateId: invoiceId,
|
||
eventType: 'InvoiceJournalEntrySkipped',
|
||
payload: {
|
||
invoice_id: invoiceId,
|
||
operation,
|
||
reason_code: reasonCode,
|
||
...(accountNumbers ? { account_numbers: accountNumbers } : {}),
|
||
},
|
||
actor: { type: 'user', id: userId },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (historyErr) {
|
||
log.warn('Failed to append InvoiceJournalEntrySkipped to processing_history', historyErr)
|
||
}
|
||
}
|
||
|
||
// ── Executors ────────────────────────────────────────────────────
|
||
|
||
type ExecutorResult = { data?: Record<string, unknown>; error?: string; status?: number }
|
||
|
||
async function commitCategorizeTransaction(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const txId = params.transaction_id as string
|
||
const category = params.category as TransactionCategory
|
||
const vatTreatment = params.vat_treatment as VatTreatment | undefined
|
||
// Optional audit-trail text the agent passed alongside the categorization.
|
||
// For representation bookings the agent captures deltagare + syfte and
|
||
// funnels them in here so the verifikation's description carries the
|
||
// context an external auditor needs (SKV's representationsregler).
|
||
const notes =
|
||
typeof params.notes === 'string' && params.notes.trim().length > 0
|
||
? (params.notes as string)
|
||
: undefined
|
||
|
||
const { data: transaction, error: fetchError } = await supabase
|
||
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
|
||
|
||
if (fetchError || !transaction) {
|
||
return { error: 'Transaction not found — it may have been deleted.', status: 404 }
|
||
}
|
||
if (transaction.journal_entry_id) {
|
||
return { error: 'Transaction already has a journal entry — it was categorized in the meantime.', status: 409 }
|
||
}
|
||
|
||
const isBusiness = category !== 'private'
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings').select('entity_type, fiscal_year_start_month').eq('company_id', companyId).single()
|
||
|
||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
|
||
|
||
const mappingResult = buildMappingResultFromCategory(
|
||
category, transaction as Transaction, isBusiness, entityType, vatTreatment
|
||
)
|
||
|
||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||
return { error: `No account mapping for category "${category}" with entity type "${entityType}".`, status: 400 }
|
||
}
|
||
|
||
await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
|
||
|
||
let journalEntryId: string | null = null
|
||
try {
|
||
const journalEntry = await createTransactionJournalEntry(
|
||
supabase, companyId, userId, transaction as Transaction, mappingResult, notes,
|
||
)
|
||
if (journalEntry) journalEntryId = journalEntry.id
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
log.error('Failed to create journal entry:', err)
|
||
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
|
||
}
|
||
|
||
const { error: updateError } = await supabase
|
||
.from('transactions')
|
||
.update({ is_business: isBusiness, category, journal_entry_id: journalEntryId })
|
||
.eq('id', txId)
|
||
|
||
if (updateError) {
|
||
log.error('Failed to update transaction:', updateError)
|
||
return { error: 'Failed to update transaction', status: 500 }
|
||
}
|
||
|
||
// Propagate the underlag from a matched invoice-inbox item onto the new
|
||
// verifikation. Without this, BFL 7 kap is violated: a verifikation
|
||
// exists with no underlag attached even though the user has explicitly
|
||
// linked an inbox item (with a document) to this transaction in the
|
||
// inbox workspace. We:
|
||
// 1. find the inbox item(s) where matched_transaction_id = txId
|
||
// 2. for each item with a document_id, set
|
||
// document_attachments.journal_entry_id = journalEntryId
|
||
// (idempotent — re-linking the same doc is a no-op write).
|
||
// 3. stamp invoice_inbox_items.created_journal_entry_id so the inbox
|
||
// row visibly moves to "Bearbetade" and shows "Öppna verifikation".
|
||
// Errors are logged but don't fail the commit — the verifikation itself
|
||
// is already posted, and the link can be repaired by re-running this
|
||
// step. A future PR can move this into a single transaction with the
|
||
// journal entry creation.
|
||
if (journalEntryId) {
|
||
try {
|
||
const { data: matchedInboxItems } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, document_id')
|
||
.eq('company_id', companyId)
|
||
.eq('matched_transaction_id', txId)
|
||
.is('created_journal_entry_id', null)
|
||
for (const inbox of (matchedInboxItems ?? []) as Array<{
|
||
id: string
|
||
document_id: string | null
|
||
}>) {
|
||
if (inbox.document_id) {
|
||
try {
|
||
await linkToJournalEntry(supabase, companyId, inbox.document_id, journalEntryId)
|
||
} catch (err) {
|
||
log.error('Failed to link inbox document to journal entry', {
|
||
inbox_item_id: inbox.id,
|
||
document_id: inbox.document_id,
|
||
journal_entry_id: journalEntryId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
const { error: stampError } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ created_journal_entry_id: journalEntryId })
|
||
.eq('id', inbox.id)
|
||
.eq('company_id', companyId)
|
||
if (stampError) {
|
||
log.error('Failed to stamp inbox item created_journal_entry_id', {
|
||
inbox_item_id: inbox.id,
|
||
journal_entry_id: journalEntryId,
|
||
error: stampError.message,
|
||
})
|
||
}
|
||
}
|
||
} catch (err) {
|
||
log.error('Failed to propagate underlag from matched inbox items', err)
|
||
}
|
||
}
|
||
|
||
try {
|
||
await upsertCounterpartyTemplate(
|
||
supabase, userId, transaction as Transaction, mappingResult, 'user_approved'
|
||
)
|
||
} catch { /* non-critical */ }
|
||
|
||
await eventBus.emit({
|
||
type: 'transaction.categorized',
|
||
payload: {
|
||
transaction: transaction as Transaction,
|
||
account: mappingResult.debit_account,
|
||
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
||
userId,
|
||
companyId,
|
||
},
|
||
})
|
||
|
||
return { data: { journal_entry_id: journalEntryId, category } }
|
||
}
|
||
|
||
async function commitCreateCustomer(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const { data, error } = await supabase
|
||
.from('customers')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: params.name as string,
|
||
customer_type: params.customer_type as string,
|
||
email: (params.email as string) || null,
|
||
org_number: (params.org_number as string) || null,
|
||
vat_number: (params.vat_number as string) || null,
|
||
default_payment_terms: (params.payment_terms as number) || 30,
|
||
address_line1: (params.address as string) || null,
|
||
postal_code: (params.postal_code as string) || null,
|
||
city: (params.city as string) || null,
|
||
country: (params.country as string) || 'Sweden',
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (error) return { error: error.message, status: 500 }
|
||
|
||
if (params.customer_type === 'eu_business' && params.vat_number) {
|
||
try {
|
||
const vatResult = await validateVatNumber(params.vat_number as string)
|
||
if (vatResult.valid) {
|
||
await supabase
|
||
.from('customers')
|
||
.update({ vat_number_validated: true, vat_number_validated_at: new Date().toISOString() })
|
||
.eq('id', data.id)
|
||
.eq('company_id', companyId)
|
||
}
|
||
} catch (err) {
|
||
log.warn('Auto-VIES validation failed:', err)
|
||
}
|
||
}
|
||
|
||
await eventBus.emit({ type: 'customer.created', payload: { customer: data as Customer, userId, companyId } })
|
||
|
||
return { data: { customer_id: data.id } }
|
||
}
|
||
|
||
async function commitCreateSupplier(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
// Defense in depth: re-validate the staged params at the commit boundary so a
|
||
// tampered pending_operations row cannot inject unexpected fields or
|
||
// malformed payment-routing data into the suppliers table (ASVS V4.5).
|
||
let validated
|
||
try {
|
||
validated = CreateSupplierParamsSchema.parse(params)
|
||
} catch (err) {
|
||
if (err instanceof z.ZodError) {
|
||
const issue = err.issues[0]
|
||
const path = issue?.path?.join('.') ?? 'params'
|
||
return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 }
|
||
}
|
||
throw err
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('suppliers')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
name: validated.name,
|
||
supplier_type: validated.supplier_type,
|
||
email: validated.email ?? null,
|
||
phone: validated.phone ?? null,
|
||
org_number: validated.org_number ?? null,
|
||
vat_number: validated.vat_number ?? null,
|
||
address_line1: validated.address_line1 ?? null,
|
||
address_line2: validated.address_line2 ?? null,
|
||
postal_code: validated.postal_code ?? null,
|
||
city: validated.city ?? null,
|
||
country: validated.country ?? 'SE',
|
||
bankgiro: validated.bankgiro ?? null,
|
||
plusgiro: validated.plusgiro ?? null,
|
||
bank_account: validated.bank_account ?? null,
|
||
iban: validated.iban ?? null,
|
||
bic: validated.bic ?? null,
|
||
default_expense_account: validated.default_expense_account ?? null,
|
||
default_payment_terms: validated.default_payment_terms,
|
||
default_currency: validated.default_currency ?? 'SEK',
|
||
notes: validated.notes ?? null,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (error) return { error: error.message, status: 500 }
|
||
|
||
await eventBus.emit({ type: 'supplier.created', payload: { supplier: data as Supplier, userId, companyId } })
|
||
|
||
return { data: { supplier_id: data.id } }
|
||
}
|
||
|
||
async function commitCreateTransaction(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const date = params.date as string
|
||
const amount = Number(params.amount)
|
||
const description = (params.description as string) ?? ''
|
||
const currency = ((params.currency as string) || 'SEK') as Currency
|
||
const bankConnectionId = (params.bank_connection_id as string) || null
|
||
const externalId = (params.external_id as string) || null
|
||
|
||
if (!date || !description.trim() || !Number.isFinite(amount)) {
|
||
return { error: 'date, description, and amount are required', status: 400 }
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('transactions')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
bank_connection_id: bankConnectionId,
|
||
external_id: externalId,
|
||
date,
|
||
description: description.trim(),
|
||
amount,
|
||
currency,
|
||
import_source: 'mcp',
|
||
})
|
||
.select('id')
|
||
.single()
|
||
|
||
if (error) {
|
||
const isDuplicate = error.code === '23505'
|
||
return {
|
||
error: isDuplicate
|
||
? `A transaction with external_id "${externalId}" already exists.`
|
||
: error.message,
|
||
status: isDuplicate ? 409 : 500,
|
||
}
|
||
}
|
||
|
||
return { data: { transaction_id: data.id } }
|
||
}
|
||
|
||
async function commitCreateInvoice(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const customerId = params.customer_id as string
|
||
const items = params.items as Array<{
|
||
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
|
||
}>
|
||
|
||
const { data: customer, error: customerError } = await supabase
|
||
.from('customers').select('*').eq('id', customerId).eq('company_id', companyId).single()
|
||
|
||
if (customerError || !customer) {
|
||
return { error: 'Customer not found — they may have been deleted.', status: 404 }
|
||
}
|
||
|
||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||
|
||
const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||
|
||
let vatAmount = 0
|
||
for (const item of items) {
|
||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||
if (!allowedRates.has(itemRate)) {
|
||
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 }
|
||
}
|
||
const lineTotal = item.quantity * item.unit_price
|
||
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||
}
|
||
|
||
const total = subtotal + vatAmount
|
||
const currency = ((params.currency as string) || 'SEK') as Currency
|
||
|
||
let exchangeRate: number | null = null
|
||
let exchangeRateDate: string | null = null
|
||
let subtotalSek: number | null = null
|
||
let vatAmountSek: number | null = null
|
||
let totalSek: number | null = null
|
||
|
||
if (currency !== 'SEK') {
|
||
const rateData = await fetchExchangeRate(currency)
|
||
if (rateData) {
|
||
exchangeRate = rateData.rate
|
||
exchangeRateDate = rateData.date
|
||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||
totalSek = convertToSEK(total, exchangeRate)
|
||
}
|
||
}
|
||
|
||
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
|
||
const isMixedRate = uniqueRates.size > 1
|
||
|
||
const { data: invoice, error: invoiceError } = await supabase
|
||
.from('invoices')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
customer_id: customerId,
|
||
invoice_number: null,
|
||
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
|
||
due_date: (params.due_date as string) || null,
|
||
currency,
|
||
exchange_rate: exchangeRate,
|
||
exchange_rate_date: exchangeRateDate,
|
||
subtotal,
|
||
subtotal_sek: subtotalSek,
|
||
vat_amount: vatAmount,
|
||
vat_amount_sek: vatAmountSek,
|
||
total,
|
||
total_sek: totalSek,
|
||
vat_treatment: vatRules.treatment,
|
||
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
||
moms_ruta: vatRules.momsRuta,
|
||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||
our_reference: (params.our_reference as string) || null,
|
||
your_reference: (params.your_reference as string) || null,
|
||
notes: (params.notes as string) || null,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (invoiceError) return { error: invoiceError.message, status: 500 }
|
||
|
||
const invoiceItems = items.map((item, index) => {
|
||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||
const lineTotal = item.quantity * item.unit_price
|
||
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||
return {
|
||
invoice_id: invoice.id,
|
||
sort_order: index,
|
||
description: item.description,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
unit_price: item.unit_price,
|
||
line_total: lineTotal,
|
||
vat_rate: itemRate,
|
||
vat_amount: itemVat,
|
||
}
|
||
})
|
||
|
||
const { error: itemsError } = await supabase.from('invoice_items').insert(invoiceItems)
|
||
|
||
if (itemsError) {
|
||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||
return { error: itemsError.message, status: 500 }
|
||
}
|
||
|
||
const { data: completeInvoice } = await supabase
|
||
.from('invoices')
|
||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||
.eq('id', invoice.id)
|
||
.single()
|
||
|
||
if (completeInvoice) {
|
||
await eventBus.emit({
|
||
type: 'invoice.created',
|
||
payload: { invoice: completeInvoice as Invoice, userId, companyId },
|
||
})
|
||
}
|
||
|
||
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
|
||
}
|
||
|
||
async function commitMarkInvoicePaid(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const invoiceId = params.invoice_id as string
|
||
const paymentDate = (params.payment_date as string) || new Date().toISOString().split('T')[0]
|
||
|
||
const { data: invoice, error: invoiceError } = await supabase
|
||
.from('invoices')
|
||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||
.eq('id', invoiceId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
|
||
}
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
||
|
||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||
let journalEntryId: string | null = null
|
||
|
||
if (isRealInvoice) {
|
||
if (accountingMethod === 'accrual') {
|
||
const je = await createInvoicePaymentJournalEntry(
|
||
supabase, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
|
||
)
|
||
journalEntryId = je?.id ?? null
|
||
} else {
|
||
const je = await createInvoiceCashEntry(
|
||
supabase, companyId, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
|
||
)
|
||
journalEntryId = je?.id ?? null
|
||
}
|
||
}
|
||
|
||
const now = new Date().toISOString()
|
||
const { error: updateError } = await supabase
|
||
.from('invoices')
|
||
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
|
||
.eq('id', invoiceId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
||
|
||
return { data: { status: 'paid', journal_entry_id: journalEntryId } }
|
||
}
|
||
|
||
async function commitSendInvoice(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>,
|
||
userEmail?: string
|
||
): Promise<ExecutorResult> {
|
||
const invoiceId = params.invoice_id as string
|
||
|
||
const emailService = getEmailService()
|
||
if (!emailService.isConfigured()) {
|
||
return { error: 'Email service not configured', status: 500 }
|
||
}
|
||
|
||
const { data: invoice, error: invoiceError } = await supabase
|
||
.from('invoices')
|
||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||
.eq('id', invoiceId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||
if (invoice.status === 'sent' || invoice.status === 'paid' || invoice.status === 'overdue') {
|
||
return { error: 'Invoice has already been sent', status: 409 }
|
||
}
|
||
|
||
const customer = invoice.customer as Customer
|
||
if (!customer.email) return { error: 'Customer has no email address', status: 400 }
|
||
|
||
const { data: company, error: companyError } = await supabase
|
||
.from('company_settings').select('*').eq('company_id', companyId).single()
|
||
|
||
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
|
||
|
||
try {
|
||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||
} catch (err) {
|
||
return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 }
|
||
}
|
||
|
||
const items = (invoice.items as InvoiceItem[]).sort(
|
||
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
|
||
)
|
||
|
||
let originalInvoiceNumber: string | undefined
|
||
if (invoice.credited_invoice_id) {
|
||
const { data: orig } = await supabase
|
||
.from('invoices').select('invoice_number').eq('id', invoice.credited_invoice_id).single()
|
||
if (orig) originalInvoiceNumber = orig.invoice_number
|
||
}
|
||
|
||
// Override `status` to 'sent' on the in-memory copy. The DB flip happens
|
||
// after email delivery (line ~625); rendering with the stale 'draft' status
|
||
// would stamp the customer's PDF with "UTKAST – inte en giltig faktura".
|
||
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
||
const { branding } = prepareInvoicePdfRender(company as CompanySettings)
|
||
const pdfBuffer = await renderToBuffer(
|
||
InvoicePDF({
|
||
invoice: renderableInvoice,
|
||
customer,
|
||
items,
|
||
company: company as CompanySettings,
|
||
originalInvoiceNumber,
|
||
branding,
|
||
})
|
||
)
|
||
|
||
const isCreditNote = !!invoice.credited_invoice_id
|
||
const docType = invoice.document_type || 'invoice'
|
||
let filename: string
|
||
if (isCreditNote) filename = `kreditfaktura-${invoice.invoice_number}.pdf`
|
||
else if (docType === 'proforma') filename = `proformafaktura-${invoice.invoice_number}.pdf`
|
||
else if (docType === 'delivery_note') filename = `foljesedel-${invoice.invoice_number}.pdf`
|
||
else filename = `faktura-${invoice.invoice_number}.pdf`
|
||
|
||
const ccAddress = company.email || userEmail
|
||
const emailData = { invoice: invoice as Invoice, customer, company: company as CompanySettings }
|
||
const result = await emailService.sendEmail({
|
||
to: customer.email,
|
||
cc: ccAddress,
|
||
subject: generateInvoiceEmailSubject(emailData),
|
||
html: generateInvoiceEmailHtml(emailData),
|
||
text: generateInvoiceEmailText(emailData),
|
||
replyTo: company.email || undefined,
|
||
fromName: company.company_name,
|
||
attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }],
|
||
})
|
||
|
||
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
|
||
|
||
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId)
|
||
|
||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||
let createdJournalEntryId: string | undefined
|
||
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
|
||
try {
|
||
const je = await createInvoiceJournalEntry(
|
||
supabase, companyId, userId, invoice as Invoice, (company as CompanySettings).entity_type
|
||
)
|
||
if (je) {
|
||
createdJournalEntryId = je.id
|
||
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
|
||
}
|
||
} catch (err) {
|
||
await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'send_invoice', err)
|
||
}
|
||
}
|
||
|
||
if (isRealInvoice) {
|
||
try {
|
||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||
await uploadDocument(supabase, userId, companyId, {
|
||
name: filename, buffer: pdfArrayBuffer, type: 'application/pdf',
|
||
}, { upload_source: 'system', journal_entry_id: createdJournalEntryId })
|
||
} catch { /* non-blocking */ }
|
||
}
|
||
|
||
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId, companyId } })
|
||
|
||
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
|
||
}
|
||
|
||
async function commitMarkInvoiceSent(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const invoiceId = params.invoice_id as string
|
||
|
||
const { data: invoice, error: invoiceError } = await supabase
|
||
.from('invoices')
|
||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||
.eq('id', invoiceId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
|
||
|
||
try {
|
||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||
} catch (err) {
|
||
return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 }
|
||
}
|
||
|
||
const { error: updateError } = await supabase
|
||
.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId)
|
||
|
||
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
||
|
||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||
let journalEntryId: string | null = null
|
||
|
||
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
|
||
try {
|
||
const je = await createInvoiceJournalEntry(
|
||
supabase, companyId, userId, invoice as Invoice,
|
||
(settings?.entity_type as EntityType) || 'enskild_firma',
|
||
invoice.customer?.name
|
||
)
|
||
if (je) {
|
||
journalEntryId = je.id
|
||
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
|
||
}
|
||
} catch (err) {
|
||
await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'mark_invoice_sent', err)
|
||
}
|
||
}
|
||
|
||
return { data: { status: 'sent', journal_entry_id: journalEntryId } }
|
||
}
|
||
|
||
async function commitMatchTransactionInvoice(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const transactionId = params.transaction_id as string
|
||
const invoiceId = params.invoice_id as string
|
||
|
||
const { data: transaction, error: txError } = await supabase
|
||
.from('transactions').select('*').eq('id', transactionId).eq('company_id', companyId).single()
|
||
|
||
if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
|
||
if (transaction.amount <= 0) return { error: 'Only income transactions can be matched', status: 400 }
|
||
if (transaction.invoice_id) return { error: 'Transaction already linked to an invoice', status: 409 }
|
||
|
||
const { data: invoice, error: invError } = await supabase
|
||
.from('invoices')
|
||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||
.eq('id', invoiceId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||
return { error: 'Invoice is not in a matchable state', status: 409 }
|
||
}
|
||
|
||
if (transaction.journal_entry_id) {
|
||
await reverseEntry(supabase, companyId, userId, transaction.journal_entry_id)
|
||
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
|
||
}
|
||
|
||
const now = new Date().toISOString()
|
||
const paidAmount = transaction.amount
|
||
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
|
||
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
|
||
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
|
||
const isFullyPaid = newRemaining <= 0
|
||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
|
||
|
||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||
|
||
let journalEntryId: string | null = null
|
||
try {
|
||
if (accountingMethod === 'cash' && isFullyPaid) {
|
||
const je = await createInvoiceCashEntry(
|
||
supabase, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
|
||
)
|
||
journalEntryId = je?.id ?? null
|
||
} else {
|
||
const je = await createInvoicePaymentJournalEntry(
|
||
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
|
||
)
|
||
journalEntryId = je?.id ?? null
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
log.error('Failed to create match journal entry:', err)
|
||
}
|
||
|
||
const { data: updatedRows, error: updateInvError } = await supabase
|
||
.from('invoices')
|
||
.update({
|
||
status: newStatus,
|
||
paid_at: isFullyPaid ? now : null,
|
||
paid_amount: newPaidAmount,
|
||
remaining_amount: newRemaining,
|
||
})
|
||
.eq('id', invoiceId)
|
||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||
.select('id')
|
||
|
||
if (updateInvError) return { error: 'Failed to update invoice status', status: 500 }
|
||
if (!updatedRows || updatedRows.length === 0) {
|
||
return { error: 'Invoice has already been fully paid or is no longer matchable', status: 409 }
|
||
}
|
||
|
||
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
|
||
? 'Kontantmetoden: intäkt bokförs vid slutbetalning' : null
|
||
|
||
await supabase.from('invoice_payments').insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
invoice_id: invoiceId,
|
||
payment_date: transaction.date,
|
||
amount: paidAmount,
|
||
currency: invoice.currency,
|
||
exchange_rate: invoice.exchange_rate,
|
||
journal_entry_id: journalEntryId,
|
||
transaction_id: transactionId,
|
||
notes: paymentNotes,
|
||
})
|
||
|
||
await supabase
|
||
.from('transactions')
|
||
.update({
|
||
invoice_id: invoiceId,
|
||
potential_invoice_id: null,
|
||
journal_entry_id: journalEntryId,
|
||
is_business: true,
|
||
category: 'income_services',
|
||
})
|
||
.eq('id', transactionId)
|
||
|
||
try {
|
||
await eventBus.emit({
|
||
type: 'invoice.match_confirmed',
|
||
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId, companyId },
|
||
})
|
||
} catch { /* non-critical */ }
|
||
|
||
return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } }
|
||
}
|
||
|
||
// ── Stream 1 Phase 1 + follow-up executors ───────────────────────
|
||
|
||
async function commitClosePeriod(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.fiscal_period_id as string
|
||
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
||
try {
|
||
const period = await closePeriod(supabase, companyId, userId, id)
|
||
return { data: { period_id: period.id, closed_at: period.closed_at } }
|
||
} catch (err) {
|
||
return { error: err instanceof Error ? err.message : 'Close failed', status: 400 }
|
||
}
|
||
}
|
||
|
||
async function commitLockPeriod(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.fiscal_period_id as string
|
||
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
||
try {
|
||
const period = await lockPeriod(supabase, companyId, userId, id)
|
||
return { data: { period_id: period.id, locked_at: period.locked_at } }
|
||
} catch (err) {
|
||
return { error: err instanceof Error ? err.message : 'Lock failed', status: 400 }
|
||
}
|
||
}
|
||
|
||
async function commitUnlockPeriod(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.fiscal_period_id as string
|
||
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
||
try {
|
||
const period = await unlockPeriod(supabase, companyId, userId, id)
|
||
return { data: { period_id: period.id, locked_at: period.locked_at } }
|
||
} catch (err) {
|
||
return { error: err instanceof Error ? err.message : 'Unlock failed', status: 400 }
|
||
}
|
||
}
|
||
|
||
async function commitUncategorizeTransaction(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const txId = params.transaction_id as string
|
||
const journalEntryId = params.journal_entry_id as string
|
||
if (!txId || !journalEntryId) return { error: 'transaction_id and journal_entry_id are required', status: 400 }
|
||
|
||
try {
|
||
await reverseEntry(supabase, companyId, userId, journalEntryId)
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Reversal failed', status: 500 }
|
||
}
|
||
|
||
const { error: updateError } = await supabase
|
||
.from('transactions')
|
||
.update({ is_business: null, category: null, journal_entry_id: null })
|
||
.eq('id', txId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (updateError) return { error: 'Failed to reset transaction', status: 500 }
|
||
|
||
return { data: { transaction_id: txId, reversed_journal_entry_id: journalEntryId } }
|
||
}
|
||
|
||
async function commitAttachDocumentToTransaction(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const txId = params.transaction_id as string
|
||
const documentId = params.document_id as string
|
||
if (!txId || !documentId) {
|
||
return { error: 'transaction_id and document_id are required', status: 400 }
|
||
}
|
||
|
||
const { data: tx, error: txError } = await supabase
|
||
.from('transactions')
|
||
.select('id, document_id, journal_entry_id')
|
||
.eq('id', txId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (txError || !tx) return { error: 'Transaction not found', status: 404 }
|
||
|
||
const previousDocumentId = (tx.document_id as string | null) ?? null
|
||
|
||
// Pre-check: if the tx already has a doc and that doc is räkenskapsinformation,
|
||
// mirror the DELETE-route 409 instead of letting the DB trigger raise a
|
||
// raw check_violation. Same compliance message in both places.
|
||
if (tx.document_id && tx.document_id !== documentId) {
|
||
const { data: existing } = await supabase
|
||
.from('document_attachments')
|
||
.select('journal_entry_id')
|
||
.eq('id', tx.document_id)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (existing?.journal_entry_id) {
|
||
return {
|
||
error:
|
||
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
|
||
status: 409,
|
||
}
|
||
}
|
||
}
|
||
|
||
const { data: doc, error: docError } = await supabase
|
||
.from('document_attachments')
|
||
.select('id')
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (docError || !doc) return { error: 'Document not found', status: 404 }
|
||
|
||
// Race-free read of journal_entry_id: use UPDATE ... RETURNING so the value
|
||
// we propagate against reflects any concurrent categorize that committed
|
||
// before our UPDATE acquired the row lock. Reading the post-update state
|
||
// (rather than the pre-staging state) is what makes the
|
||
// attach-then-categorize and categorize-then-attach orderings produce the
|
||
// same final state — both end with document_attachments.journal_entry_id
|
||
// set to the tx's journal_entry_id. (BFL 5 kap 6 § verifikation underlag.)
|
||
const { data: postUpdate, error: updateError } = await supabase
|
||
.from('transactions')
|
||
.update({ document_id: documentId })
|
||
.eq('id', txId)
|
||
.eq('company_id', companyId)
|
||
.select('journal_entry_id')
|
||
.maybeSingle()
|
||
|
||
if (updateError) {
|
||
// The DB-level immutability trigger raises P0001 with a stable
|
||
// BFL_DOCUMENT_IMMUTABILITY: prefix when the previous doc is already
|
||
// räkenskapsinformation. Match on the prefix (not the generic SQLSTATE)
|
||
// so unrelated future exceptions don't get translated.
|
||
const errMsg = (updateError as { message?: string }).message ?? ''
|
||
if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) {
|
||
return {
|
||
error:
|
||
'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.',
|
||
status: 409,
|
||
}
|
||
}
|
||
return { error: 'Failed to attach document', status: 500 }
|
||
}
|
||
if (!postUpdate) return { error: 'Transaction not found', status: 404 }
|
||
|
||
// If the attached doc came from an invoice_inbox_items row, mark that row
|
||
// as matched so the inbox UI shows "Kopplad till transaktion". Best-effort:
|
||
// a failure must not roll back the (compliant) attach. Mirrors the REST
|
||
// route in app/api/transactions/[id]/attach-document/route.ts so MCP-staged
|
||
// and REST attaches converge on the same inbox state.
|
||
//
|
||
// The Supabase client resolves with { error } rather than rejecting on
|
||
// RLS/DB errors, so we destructure rather than try/catch.
|
||
const { error: inboxLinkErr } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ matched_transaction_id: txId })
|
||
.eq('document_id', documentId)
|
||
.eq('company_id', companyId)
|
||
.is('matched_transaction_id', null)
|
||
.is('created_supplier_invoice_id', null)
|
||
if (inboxLinkErr) {
|
||
console.error('[commitAttach] Failed to link inbox item:', inboxLinkErr)
|
||
}
|
||
|
||
const journalEntryId = postUpdate.journal_entry_id as string | null
|
||
if (journalEntryId) {
|
||
const { error: linkErr } = await supabase
|
||
.from('document_attachments')
|
||
.update({ journal_entry_id: journalEntryId })
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
if (linkErr) {
|
||
// Surface the propagation failure rather than logging-and-continuing.
|
||
// BFL 5 kap 6 § requires the verifikation to reference its underlag, so
|
||
// a "succeeded" attach that left document_attachments.journal_entry_id
|
||
// null would be a silent compliance gap. Failing here marks the op
|
||
// failed; a retry is idempotent (same documentId on tx, same propagate
|
||
// target) and will replay the document_attachments UPDATE.
|
||
console.error('[commitAttach] Failed to propagate to journal entry:', linkErr)
|
||
return {
|
||
error:
|
||
'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen — operationen är idempotent.',
|
||
status: 500,
|
||
}
|
||
}
|
||
}
|
||
|
||
// Rättelse audit trail (BFL 5 kap 5 §): if we replaced a non-null doc, log
|
||
// the swap to processing_history so the original is traceable. Best-effort —
|
||
// a logging failure must not roll back the (compliant) attach.
|
||
if (previousDocumentId && previousDocumentId !== documentId) {
|
||
try {
|
||
await appendProcessingHistory({
|
||
companyId,
|
||
correlationId: txId,
|
||
aggregateType: 'BankTransaction',
|
||
aggregateId: txId,
|
||
eventType: 'TransactionDocumentReplaced',
|
||
payload: {
|
||
transaction_id: txId,
|
||
previous_document_id: previousDocumentId,
|
||
new_document_id: documentId,
|
||
journal_entry_id: journalEntryId,
|
||
},
|
||
actor: { type: 'user', id: userId },
|
||
occurredAt: new Date(),
|
||
})
|
||
} catch (logErr) {
|
||
console.error('[commitAttach] Failed to append rättelse event:', logErr)
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
transaction_id: txId,
|
||
document_id: documentId,
|
||
previous_document_id: previousDocumentId,
|
||
journal_entry_id: journalEntryId,
|
||
},
|
||
}
|
||
}
|
||
|
||
async function commitRunYearEnd(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.fiscal_period_id as string
|
||
if (!id) return { error: 'fiscal_period_id is required', status: 400 }
|
||
|
||
try {
|
||
const result = await executeYearEndClosing(supabase, companyId, userId, id)
|
||
return {
|
||
data: {
|
||
closing_entry_id: result.closingEntry?.id ?? null,
|
||
next_period_id: result.nextPeriod?.id ?? null,
|
||
opening_balance_entry_id: result.openingBalanceEntry?.id ?? null,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Year-end failed', status: 400 }
|
||
}
|
||
}
|
||
|
||
async function commitSetOpeningBalances(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const closedId = params.closed_period_id as string
|
||
const nextId = params.next_period_id as string
|
||
if (!closedId || !nextId) return { error: 'closed_period_id and next_period_id are required', status: 400 }
|
||
|
||
try {
|
||
const entry = await generateOpeningBalances(supabase, companyId, userId, closedId, nextId)
|
||
return { data: { opening_balance_entry_id: entry.id } }
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Opening balances failed', status: 400 }
|
||
}
|
||
}
|
||
|
||
async function commitRunCurrencyRevaluation(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.fiscal_period_id as string
|
||
const closingDate = params.closing_date as string
|
||
if (!id || !closingDate) return { error: 'fiscal_period_id and closing_date are required', status: 400 }
|
||
|
||
try {
|
||
const result = await executeCurrencyRevaluation(supabase, companyId, closingDate, id, userId)
|
||
return {
|
||
data: result
|
||
? { entry_id: result.entry.id, items_revalued: result.preview.items.length }
|
||
: { entry_id: null, items_revalued: 0, message: 'No foreign-currency items to revalue' },
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Revaluation failed', status: 400 }
|
||
}
|
||
}
|
||
|
||
async function commitPostAnnualDepreciation(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const fiscalPeriodId = params.fiscal_period_id as string
|
||
if (!fiscalPeriodId) return { error: 'fiscal_period_id is required', status: 400 }
|
||
const assetIds = Array.isArray(params.asset_ids) ? (params.asset_ids as string[]) : undefined
|
||
|
||
try {
|
||
const { commitAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine')
|
||
const { posted, skipped } = await commitAnnualPostings(supabase, companyId, userId, fiscalPeriodId, {
|
||
assetIds,
|
||
})
|
||
return {
|
||
data: {
|
||
posted_count: posted.length,
|
||
skipped_count: skipped.length,
|
||
posted: posted.map((p) => ({
|
||
asset_id: p.assetId,
|
||
journal_entry_id: p.entry.id,
|
||
voucher_number: p.entry.voucher_number,
|
||
schedule_id: p.scheduleId,
|
||
})),
|
||
skipped,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Depreciation posting failed', status: 400 }
|
||
}
|
||
}
|
||
|
||
async function commitExplainVoucherGap(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const fiscalPeriodId = params.fiscal_period_id as string
|
||
const voucherSeries = params.voucher_series as string
|
||
const gapStart = Number(params.gap_start)
|
||
const gapEnd = Number(params.gap_end)
|
||
const explanation = params.explanation as string
|
||
if (!fiscalPeriodId || !voucherSeries || !gapStart || !gapEnd || !explanation?.trim()) {
|
||
return { error: 'fiscal_period_id, voucher_series, gap_start, gap_end, and explanation are required', status: 400 }
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('voucher_gap_explanations')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
fiscal_period_id: fiscalPeriodId,
|
||
voucher_series: voucherSeries,
|
||
gap_start: gapStart,
|
||
gap_end: gapEnd,
|
||
explanation: explanation.trim(),
|
||
})
|
||
.select('id')
|
||
.single()
|
||
|
||
if (error) return { error: error.message, status: 500 }
|
||
return { data: { explanation_id: data.id } }
|
||
}
|
||
|
||
async function commitApproveSupplierInvoice(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.supplier_invoice_id as string
|
||
if (!id) return { error: 'supplier_invoice_id is required', status: 400 }
|
||
|
||
const { data: invoice } = await supabase
|
||
.from('supplier_invoices').select('*').eq('id', id).eq('company_id', companyId).single()
|
||
|
||
if (!invoice) return { error: 'Supplier invoice not found', status: 404 }
|
||
if (invoice.status !== 'registered') {
|
||
return { error: 'Kan bara godkänna registrerade fakturor', status: 400 }
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('supplier_invoices')
|
||
.update({ status: 'approved' })
|
||
.eq('id', id)
|
||
.eq('company_id', companyId)
|
||
.select()
|
||
.single()
|
||
|
||
if (error) return { error: error.message, status: 500 }
|
||
|
||
try {
|
||
await eventBus.emit({
|
||
type: 'supplier_invoice.approved',
|
||
payload: { supplierInvoice: data, companyId, userId },
|
||
})
|
||
} catch { /* non-blocking */ }
|
||
|
||
return { data: { supplier_invoice_id: id, status: 'approved' } }
|
||
}
|
||
|
||
async function commitCreateSupplierInvoiceFromInbox(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const inboxItemId = params.inbox_item_id as string
|
||
const supplierId = params.supplier_id as string
|
||
const documentId = (params.document_id as string | null) ?? null
|
||
const supplierInvoiceNumber = params.supplier_invoice_number as string
|
||
const invoiceDate = params.invoice_date as string
|
||
const dueDate = (params.due_date as string | null) ?? null
|
||
const currency = (params.currency as string) || 'SEK'
|
||
const vatTreatment = (params.vat_treatment as string) || 'standard_25'
|
||
const notes = (params.notes as string | null) ?? null
|
||
const rawItems = (params.items as Array<Record<string, unknown>> | undefined) ?? []
|
||
|
||
if (!inboxItemId || !supplierId || !supplierInvoiceNumber || !invoiceDate || rawItems.length === 0) {
|
||
return {
|
||
error: 'inbox_item_id, supplier_id, supplier_invoice_number, invoice_date, and items are required',
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
// Reject tampered financial fields: Number(x) || 0 silently turns string
|
||
// junk and undefined into a zero-value invoice. Require a finite number on
|
||
// every monetary field, including the optional exchange_rate when present.
|
||
const finite = (raw: unknown): number | null =>
|
||
typeof raw === 'number' && Number.isFinite(raw) ? raw : null
|
||
const subtotal = finite(params.subtotal)
|
||
const vatAmount = finite(params.vat_amount)
|
||
const total = finite(params.total)
|
||
if (subtotal === null || vatAmount === null || total === null) {
|
||
return {
|
||
error: 'subtotal, vat_amount, and total must be finite numbers',
|
||
status: 400,
|
||
}
|
||
}
|
||
const exchangeRate = params.exchange_rate === null || params.exchange_rate === undefined
|
||
? null
|
||
: finite(params.exchange_rate)
|
||
if (params.exchange_rate !== null && params.exchange_rate !== undefined && exchangeRate === null) {
|
||
return { error: 'exchange_rate must be a finite number when provided', status: 400 }
|
||
}
|
||
|
||
// Idempotency: a re-fired commit (e.g. retry, double-click on the approval
|
||
// UI, racy MCP call) must not create a second leverantörsfaktura for the
|
||
// same inbox row. The DB FK on invoice_inbox_items.created_supplier_invoice_id
|
||
// is the source of truth.
|
||
const { data: inbox, error: inboxErr } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.select('id, created_supplier_invoice_id, status')
|
||
.eq('id', inboxItemId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (inboxErr || !inbox) return { error: 'Inbox item not found', status: 404 }
|
||
if (inbox.created_supplier_invoice_id) {
|
||
return {
|
||
data: {
|
||
supplier_invoice_id: inbox.created_supplier_invoice_id,
|
||
inbox_item_id: inboxItemId,
|
||
idempotent: true,
|
||
},
|
||
}
|
||
}
|
||
|
||
// Defense in depth: the staging-time supplier lookup may be stale by the
|
||
// time the human approves. RLS would block a cross-company supplier too,
|
||
// but a 404 here is a cleaner error than an RLS denial later.
|
||
const { data: supplier, error: supplierErr } = await supabase
|
||
.from('suppliers')
|
||
.select('id, name, supplier_type')
|
||
.eq('id', supplierId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (supplierErr || !supplier) return { error: 'Supplier not found', status: 404 }
|
||
|
||
const { data: arrivalNum, error: arrivalErr } = await supabase
|
||
.rpc('get_next_arrival_number', { p_company_id: companyId })
|
||
|
||
if (arrivalErr) {
|
||
return { error: `Failed to generate arrival number: ${arrivalErr.message}`, status: 500 }
|
||
}
|
||
|
||
const reverseCharge = vatTreatment === 'reverse_charge'
|
||
const subtotalRounded = Math.round(subtotal * 100) / 100
|
||
const vatAmountRounded = Math.round(vatAmount * 100) / 100
|
||
const totalRounded = Math.round(total * 100) / 100
|
||
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
||
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
|
||
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
||
|
||
const { data: invoice, error: invoiceErr } = await supabase
|
||
.from('supplier_invoices')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
supplier_id: supplierId,
|
||
arrival_number: arrivalNum,
|
||
supplier_invoice_number: supplierInvoiceNumber,
|
||
invoice_date: invoiceDate,
|
||
due_date: dueDate,
|
||
status: 'registered',
|
||
currency,
|
||
exchange_rate: exchangeRate,
|
||
vat_treatment: vatTreatment,
|
||
reverse_charge: reverseCharge,
|
||
paid_with_private_funds: false,
|
||
subtotal: subtotalRounded,
|
||
subtotal_sek: subtotalSek,
|
||
vat_amount: vatAmountRounded,
|
||
vat_amount_sek: vatAmountSek,
|
||
total: totalRounded,
|
||
total_sek: totalSek,
|
||
paid_amount: 0,
|
||
remaining_amount: totalRounded,
|
||
notes,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (invoiceErr || !invoice) {
|
||
const pgErr = invoiceErr as { code?: string; message?: string } | null
|
||
const isDuplicate = pgErr?.code === '23505'
|
||
if (isDuplicate) {
|
||
// Generic 409 — supplier_invoice_number alone is already in the staged
|
||
// params the caller submitted; we just don't echo back the supplier's
|
||
// name or row id. The UI surface uses the supplier-side ledger, not
|
||
// this error.
|
||
log.warn('Duplicate supplier invoice number on inbox conversion', {
|
||
companyId,
|
||
supplierId,
|
||
supplierInvoiceNumber,
|
||
})
|
||
return {
|
||
error: `Leverantörsfaktura ${supplierInvoiceNumber} finns redan registrerad.`,
|
||
status: 409,
|
||
}
|
||
}
|
||
log.error('Failed to insert supplier invoice from inbox', {
|
||
companyId,
|
||
inboxItemId,
|
||
supplierId,
|
||
error: pgErr?.message ?? 'unknown',
|
||
})
|
||
return { error: 'Failed to create supplier invoice', status: 500 }
|
||
}
|
||
|
||
// RC invariant: a reverse-charge supplier invoice never shows output VAT
|
||
// from the supplier. Zero any per-line VAT that slipped through staging so
|
||
// the registration JE's 2614/2645 self-assessed leg lines up with rutor
|
||
// 20–24 / 48 instead of double-counting input VAT into 2641. Tampered
|
||
// params can't smuggle non-zero VAT into the items table.
|
||
const itemInserts = rawItems.map((item, idx) => {
|
||
const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' && Number.isFinite(item.vat_rate) ? item.vat_rate : 0)
|
||
const vatAmt = reverseCharge ? 0 : (typeof item.vat_amount === 'number' && Number.isFinite(item.vat_amount) ? item.vat_amount : 0)
|
||
return {
|
||
supplier_invoice_id: invoice.id,
|
||
sort_order: idx,
|
||
description: String(item.description ?? `Position ${idx + 1}`),
|
||
quantity: typeof item.quantity === 'number' && Number.isFinite(item.quantity) ? item.quantity : 1,
|
||
unit: (item.unit as string | undefined) ?? 'st',
|
||
unit_price: typeof item.unit_price === 'number' && Number.isFinite(item.unit_price) ? item.unit_price : 0,
|
||
line_total: typeof item.line_total === 'number' && Number.isFinite(item.line_total) ? item.line_total : 0,
|
||
account_number: String(item.account_number ?? '4000'),
|
||
vat_code: null,
|
||
vat_rate: vatRate,
|
||
vat_amount: vatAmt,
|
||
}
|
||
})
|
||
|
||
const { error: itemsErr } = await supabase
|
||
.from('supplier_invoice_items')
|
||
.insert(itemInserts)
|
||
|
||
if (itemsErr) {
|
||
// Roll back the parent to avoid orphan supplier_invoices rows. Without
|
||
// line items the registration JE can't be built and the invoice would
|
||
// be invisible in the supplier ledger anyway.
|
||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
|
||
log.error('Failed to insert supplier invoice items, rolled back parent', {
|
||
companyId,
|
||
invoiceId: invoice.id,
|
||
error: itemsErr.message,
|
||
})
|
||
return { error: 'Failed to insert supplier invoice items', status: 500 }
|
||
}
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings')
|
||
.select('accounting_method')
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||
let registrationJournalEntryId: string | null = null
|
||
|
||
if (accountingMethod === 'accrual') {
|
||
try {
|
||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
invoice as SupplierInvoice,
|
||
itemInserts as unknown as SupplierInvoiceItem[],
|
||
supplier.supplier_type,
|
||
supplier.name,
|
||
)
|
||
|
||
if (journalEntry) {
|
||
registrationJournalEntryId = journalEntry.id
|
||
await supabase
|
||
.from('supplier_invoices')
|
||
.update({ registration_journal_entry_id: journalEntry.id })
|
||
.eq('id', invoice.id)
|
||
|
||
// Attach the OCR'd source document to the verifikat so the
|
||
// registration JE has its underlag per BFL 5 kap 6 §. Linking failure
|
||
// is non-fatal — the JE is already posted and immutable; we log and
|
||
// continue so the supplier invoice stays usable.
|
||
if (documentId) {
|
||
try {
|
||
await linkToJournalEntry(supabase, companyId, documentId, journalEntry.id)
|
||
} catch (linkErr) {
|
||
log.warn('Failed to link inbox document to registration JE', {
|
||
documentId,
|
||
journalEntryId: journalEntry.id,
|
||
error: linkErr instanceof Error ? linkErr.message : String(linkErr),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// Roll back: orphan supplier_invoices row without its registration JE
|
||
// understates leverantörsskuld (2440) + ingående moms (2641) on the
|
||
// momsdeklaration. Items must be deleted BEFORE the parent — the FK
|
||
// on supplier_invoice_items.supplier_invoice_id is ON DELETE NO ACTION
|
||
// (default), so a parent-first delete would be silently blocked and
|
||
// leave the doomed invoice in the supplier ledger.
|
||
await supabase
|
||
.from('supplier_invoice_items')
|
||
.delete()
|
||
.eq('supplier_invoice_id', invoice.id)
|
||
const { error: parentDeleteErr } = await supabase
|
||
.from('supplier_invoices')
|
||
.delete()
|
||
.eq('id', invoice.id)
|
||
.eq('company_id', companyId)
|
||
if (parentDeleteErr) {
|
||
// Hard inconsistency: items gone but parent stuck. Log loudly so an
|
||
// operator can clean up — this should not happen in practice.
|
||
log.error('Rollback partial: parent supplier_invoices delete failed after JE failure', {
|
||
companyId,
|
||
invoiceId: invoice.id,
|
||
parentDeleteError: parentDeleteErr.message,
|
||
originalError: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
if (isBookkeepingError(err)) throw err
|
||
log.error('Failed to create registration journal entry; supplier invoice rolled back', {
|
||
companyId,
|
||
inboxItemId,
|
||
invoiceId: invoice.id,
|
||
error: err instanceof Error ? err.message : 'unknown',
|
||
})
|
||
return {
|
||
error: 'Failed to create registration journal entry',
|
||
status: 500,
|
||
}
|
||
}
|
||
}
|
||
|
||
// Terminal state for the inbox row: created_supplier_invoice_id is the
|
||
// dedup key for next time this inbox item is touched. status='confirmed'
|
||
// removes it from the "needs action" filter in the UI.
|
||
const { error: linkInboxErr } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ created_supplier_invoice_id: invoice.id, status: 'confirmed' })
|
||
.eq('id', inboxItemId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (linkInboxErr) {
|
||
log.warn('Failed to link inbox item to new supplier invoice (invoice still created)', {
|
||
inboxItemId,
|
||
supplierInvoiceId: invoice.id,
|
||
error: linkInboxErr.message,
|
||
})
|
||
}
|
||
|
||
try {
|
||
await eventBus.emit({
|
||
type: 'supplier_invoice.registered',
|
||
payload: { supplierInvoice: invoice as SupplierInvoice, companyId, userId },
|
||
})
|
||
} catch { /* non-blocking */ }
|
||
|
||
return {
|
||
data: {
|
||
supplier_invoice_id: invoice.id,
|
||
inbox_item_id: inboxItemId,
|
||
registration_journal_entry_id: registrationJournalEntryId,
|
||
arrival_number: arrivalNum,
|
||
},
|
||
}
|
||
}
|
||
|
||
async function commitCreditSupplierInvoice(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.supplier_invoice_id as string
|
||
if (!id) return { error: 'supplier_invoice_id is required', status: 400 }
|
||
|
||
const { data: original, error: fetchError } = await supabase
|
||
.from('supplier_invoices')
|
||
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
|
||
.eq('id', id)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (fetchError || !original) return { error: 'Supplier invoice not found', status: 404 }
|
||
if (original.status === 'credited') return { error: 'Fakturan har redan krediterats', status: 409 }
|
||
|
||
const { data: arrivalNum } = await supabase.rpc('get_next_arrival_number', { p_company_id: companyId })
|
||
|
||
const { data: creditNote, error: creditError } = await supabase
|
||
.from('supplier_invoices')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
supplier_id: original.supplier_id,
|
||
arrival_number: arrivalNum,
|
||
supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`,
|
||
invoice_date: new Date().toISOString().split('T')[0],
|
||
due_date: new Date().toISOString().split('T')[0],
|
||
status: 'registered',
|
||
currency: original.currency,
|
||
exchange_rate: original.exchange_rate,
|
||
vat_treatment: original.vat_treatment,
|
||
reverse_charge: original.reverse_charge,
|
||
subtotal: original.subtotal,
|
||
subtotal_sek: original.subtotal_sek,
|
||
vat_amount: original.vat_amount,
|
||
vat_amount_sek: original.vat_amount_sek,
|
||
total: original.total,
|
||
total_sek: original.total_sek,
|
||
remaining_amount: 0,
|
||
is_credit_note: true,
|
||
credited_invoice_id: id,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (creditError || !creditNote) return { error: creditError?.message ?? 'Failed to create credit note', status: 500 }
|
||
|
||
const creditItems = (original.items ?? []).map((item: Record<string, unknown>) => ({
|
||
supplier_invoice_id: creditNote.id,
|
||
sort_order: item.sort_order,
|
||
description: item.description,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
unit_price: item.unit_price,
|
||
line_total: item.line_total,
|
||
account_number: item.account_number,
|
||
vat_code: item.vat_code,
|
||
vat_rate: item.vat_rate,
|
||
vat_amount: item.vat_amount,
|
||
}))
|
||
await supabase.from('supplier_invoice_items').insert(creditItems)
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings').select('accounting_method').eq('company_id', companyId).single()
|
||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||
|
||
let journalEntryId: string | null = null
|
||
if (accountingMethod === 'accrual') {
|
||
try {
|
||
const je = await createSupplierCreditNoteEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
creditNote,
|
||
creditItems as never,
|
||
original.supplier?.supplier_type || 'swedish_business',
|
||
original.supplier?.name
|
||
)
|
||
if (je) {
|
||
journalEntryId = je.id
|
||
await supabase
|
||
.from('supplier_invoices')
|
||
.update({ registration_journal_entry_id: je.id })
|
||
.eq('id', creditNote.id)
|
||
}
|
||
} catch (err) {
|
||
await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId)
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Failed to book credit note', status: 500 }
|
||
}
|
||
}
|
||
|
||
const newRemaining = Math.max(0, original.remaining_amount - original.total)
|
||
const newStatus = newRemaining <= 0 ? 'credited' : original.status
|
||
|
||
await supabase
|
||
.from('supplier_invoices')
|
||
.update({ status: newStatus, remaining_amount: newRemaining })
|
||
.eq('id', id)
|
||
|
||
try {
|
||
await eventBus.emit({
|
||
type: 'supplier_invoice.credited',
|
||
payload: { supplierInvoice: original, creditNote, companyId, userId },
|
||
})
|
||
} catch { /* non-blocking */ }
|
||
|
||
return { data: { credit_note_id: creditNote.id, journal_entry_id: journalEntryId } }
|
||
}
|
||
|
||
async function commitCreditInvoice(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.invoice_id as string
|
||
const reason = params.reason as string | undefined
|
||
if (!id) return { error: 'invoice_id is required', status: 400 }
|
||
|
||
const { data: original, error: fetchError } = await supabase
|
||
.from('invoices')
|
||
.select('*, items:invoice_items(*)')
|
||
.eq('id', id)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (fetchError || !original) return { error: 'Original invoice not found', status: 404 }
|
||
if (original.document_type && original.document_type !== 'invoice') {
|
||
return { error: 'Credit notes can only be created from standard invoices', status: 400 }
|
||
}
|
||
if (original.status === 'credited') return { error: 'Invoice has already been credited', status: 409 }
|
||
if (!['sent', 'paid', 'overdue'].includes(original.status)) {
|
||
return { error: 'Only sent, paid, or overdue invoices can be credited', status: 400 }
|
||
}
|
||
|
||
const today = new Date().toISOString().split('T')[0]
|
||
const creditNoteNumber = `KR-${original.invoice_number}`
|
||
|
||
const { data: creditNote, error: creditNoteError } = await supabase
|
||
.from('invoices')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
customer_id: original.customer_id,
|
||
invoice_number: creditNoteNumber,
|
||
invoice_date: today,
|
||
due_date: today,
|
||
delivery_date: original.delivery_date ?? null,
|
||
currency: original.currency,
|
||
exchange_rate: original.exchange_rate,
|
||
exchange_rate_date: original.exchange_rate_date,
|
||
subtotal: -Math.abs(original.subtotal),
|
||
subtotal_sek: original.subtotal_sek != null ? -Math.abs(original.subtotal_sek) : null,
|
||
vat_amount: -Math.abs(original.vat_amount),
|
||
vat_amount_sek: original.vat_amount_sek != null ? -Math.abs(original.vat_amount_sek) : null,
|
||
total: -Math.abs(original.total),
|
||
total_sek: original.total_sek != null ? -Math.abs(original.total_sek) : null,
|
||
vat_treatment: original.vat_treatment,
|
||
vat_rate: original.vat_rate,
|
||
moms_ruta: original.moms_ruta,
|
||
reverse_charge_text: original.reverse_charge_text,
|
||
your_reference: original.your_reference,
|
||
our_reference: original.our_reference,
|
||
notes: reason || `Krediterar faktura ${original.invoice_number}`,
|
||
credited_invoice_id: id,
|
||
status: 'sent',
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (creditNoteError || !creditNote) {
|
||
return { error: creditNoteError?.message ?? 'Failed to create credit note', status: 500 }
|
||
}
|
||
|
||
const creditItems = (original.items || []).map((item: {
|
||
sort_order: number
|
||
description: string
|
||
quantity: number
|
||
unit: string
|
||
unit_price: number
|
||
line_total: number
|
||
vat_rate?: number
|
||
vat_amount?: number
|
||
}) => ({
|
||
invoice_id: creditNote.id,
|
||
sort_order: item.sort_order,
|
||
description: item.description,
|
||
quantity: -Math.abs(item.quantity),
|
||
unit: item.unit,
|
||
unit_price: item.unit_price,
|
||
line_total: -Math.abs(item.line_total),
|
||
vat_rate: item.vat_rate ?? 0,
|
||
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||
}))
|
||
|
||
const { error: itemsError } = await supabase
|
||
.from('invoice_items')
|
||
.insert(creditItems)
|
||
|
||
if (itemsError) {
|
||
await supabase.from('invoices').delete().eq('id', creditNote.id)
|
||
return { error: itemsError.message, status: 500 }
|
||
}
|
||
|
||
await supabase.from('invoices').update({ status: 'credited' }).eq('id', id)
|
||
|
||
const { data: completeCreditNote } = await supabase
|
||
.from('invoices')
|
||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||
.eq('id', creditNote.id)
|
||
.single()
|
||
|
||
const { data: settings } = await supabase
|
||
.from('company_settings')
|
||
.select('entity_type, accounting_method')
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||
|
||
// Resolve the original verifikation reference so the credit-note JE can
|
||
// point back to the corrected entry per BFL 5 kap. 5 §. We tolerate
|
||
// missing-JE on the original (legacy data) — the description simply omits
|
||
// the voucher reference and keeps the invoice-number reference.
|
||
let originalVoucherRef: string | undefined
|
||
if (original.journal_entry_id) {
|
||
const { data: origJe } = await supabase
|
||
.from('journal_entries')
|
||
.select('voucher_series, voucher_number')
|
||
.eq('id', original.journal_entry_id)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
if (origJe?.voucher_series && origJe?.voucher_number != null) {
|
||
originalVoucherRef = `${origJe.voucher_series}-${origJe.voucher_number}`
|
||
}
|
||
}
|
||
|
||
let journalEntryId: string | null = null
|
||
if (completeCreditNote && accountingMethod === 'accrual') {
|
||
try {
|
||
const journalEntry = await createCreditNoteJournalEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
completeCreditNote as Invoice,
|
||
entityType,
|
||
completeCreditNote.customer?.name,
|
||
originalVoucherRef
|
||
)
|
||
if (journalEntry) {
|
||
journalEntryId = journalEntry.id
|
||
await supabase
|
||
.from('invoices')
|
||
.update({ journal_entry_id: journalEntry.id })
|
||
.eq('id', creditNote.id)
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
log.error('Failed to create credit note journal entry:', err)
|
||
}
|
||
|
||
try {
|
||
await eventBus.emit({
|
||
type: 'credit_note.created',
|
||
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
|
||
})
|
||
} catch { /* non-blocking */ }
|
||
}
|
||
|
||
return { data: { credit_note_id: creditNote.id, journal_entry_id: journalEntryId } }
|
||
}
|
||
|
||
async function commitConvertInvoice(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const id = params.invoice_id as string
|
||
if (!id) return { error: 'invoice_id is required', status: 400 }
|
||
|
||
const { data: proforma, error: proformaError } = await supabase
|
||
.from('invoices').select('*, items:invoice_items(*)').eq('id', id).eq('company_id', companyId).single()
|
||
|
||
if (proformaError || !proforma) return { error: 'Proformafakturan hittades inte', status: 404 }
|
||
if (proforma.document_type !== 'proforma') {
|
||
return { error: 'Endast proformafakturor kan konverteras', status: 400 }
|
||
}
|
||
if (proforma.status === 'cancelled') {
|
||
return { error: 'Denna proformafaktura har redan makuleras', status: 409 }
|
||
}
|
||
|
||
const { data: invoice, error: invoiceError } = await supabase
|
||
.from('invoices')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
customer_id: proforma.customer_id,
|
||
invoice_number: null,
|
||
invoice_date: new Date().toISOString().split('T')[0],
|
||
due_date: proforma.due_date,
|
||
currency: proforma.currency,
|
||
exchange_rate: proforma.exchange_rate,
|
||
exchange_rate_date: proforma.exchange_rate_date,
|
||
subtotal: proforma.subtotal,
|
||
subtotal_sek: proforma.subtotal_sek,
|
||
vat_amount: proforma.vat_amount,
|
||
vat_amount_sek: proforma.vat_amount_sek,
|
||
total: proforma.total,
|
||
total_sek: proforma.total_sek,
|
||
vat_treatment: proforma.vat_treatment,
|
||
vat_rate: proforma.vat_rate,
|
||
moms_ruta: proforma.moms_ruta,
|
||
reverse_charge_text: proforma.reverse_charge_text,
|
||
your_reference: proforma.your_reference,
|
||
our_reference: proforma.our_reference,
|
||
notes: proforma.notes,
|
||
document_type: 'invoice',
|
||
converted_from_id: id,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (invoiceError) return { error: invoiceError.message, status: 500 }
|
||
|
||
try {
|
||
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
|
||
} catch (err) {
|
||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||
return { error: err instanceof Error ? err.message : 'Failed to assign invoice number', status: 500 }
|
||
}
|
||
|
||
const items = (proforma.items ?? []).map((item: Record<string, unknown>) => ({
|
||
invoice_id: invoice.id,
|
||
sort_order: item.sort_order,
|
||
description: item.description,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
unit_price: item.unit_price,
|
||
line_total: item.line_total,
|
||
}))
|
||
|
||
if (items.length > 0) {
|
||
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
|
||
if (itemsError) {
|
||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||
return { error: itemsError.message, status: 500 }
|
||
}
|
||
}
|
||
|
||
await supabase.from('invoices').update({ status: 'cancelled' }).eq('id', id)
|
||
|
||
return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } }
|
||
}
|
||
|
||
async function commitImportSie(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const fileContent = params.file_content as string
|
||
const filename = params.filename as string
|
||
const mappings = params.mappings as AccountMapping[] | undefined
|
||
const createFiscalPeriod = Boolean(params.create_fiscal_period)
|
||
const importOpeningBalances = Boolean(params.import_opening_balances)
|
||
const importTransactions = Boolean(params.import_transactions)
|
||
const voucherSeries = params.voucher_series as string | undefined
|
||
|
||
if (!fileContent || !filename || !Array.isArray(mappings)) {
|
||
return { error: 'file_content, filename, and mappings are required', status: 400 }
|
||
}
|
||
|
||
let parsed
|
||
try {
|
||
parsed = parseSIEFile(fileContent)
|
||
} catch (err) {
|
||
return { error: err instanceof Error ? err.message : 'Failed to parse SIE file', status: 400 }
|
||
}
|
||
|
||
try {
|
||
const result = await executeSIEImport(supabase, companyId, userId, parsed, mappings, {
|
||
filename,
|
||
fileContent,
|
||
createFiscalPeriod,
|
||
importOpeningBalances,
|
||
importTransactions,
|
||
voucherSeries,
|
||
})
|
||
|
||
if (!result.success) {
|
||
return { error: result.errors.join('; ') || 'SIE import failed', status: 400 }
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
import_id: result.importId,
|
||
fiscal_period_id: result.fiscalPeriodId,
|
||
opening_balance_entry_id: result.openingBalanceEntryId,
|
||
journal_entries_created: result.journalEntriesCreated,
|
||
warnings: result.warnings,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'SIE import failed', status: 500 }
|
||
}
|
||
}
|
||
|
||
// ── Phase 4: arbitrary-line bookkeeping primitives ───────────────
|
||
|
||
/**
|
||
* Normalize raw JSON line input from pending_operations.params into the
|
||
* engine's typed line shape. Trusts shape because the MCP tool already
|
||
* validates via Zod before staging — defensive coercion only.
|
||
*/
|
||
function normalizeVoucherLines(raw: unknown): CreateJournalEntryLineInput[] {
|
||
if (!Array.isArray(raw)) return []
|
||
return raw.map((l) => {
|
||
const line = l as Record<string, unknown>
|
||
return {
|
||
account_number: String(line.account_number),
|
||
debit_amount: Number(line.debit_amount) || 0,
|
||
credit_amount: Number(line.credit_amount) || 0,
|
||
line_description: line.line_description ? String(line.line_description) : undefined,
|
||
currency: line.currency ? String(line.currency) : undefined,
|
||
amount_in_currency: line.amount_in_currency !== undefined ? Number(line.amount_in_currency) : undefined,
|
||
exchange_rate: line.exchange_rate !== undefined ? Number(line.exchange_rate) : undefined,
|
||
tax_code: line.tax_code ? String(line.tax_code) : undefined,
|
||
cost_center: line.cost_center ? String(line.cost_center) : undefined,
|
||
project: line.project ? String(line.project) : undefined,
|
||
}
|
||
})
|
||
}
|
||
|
||
async function commitCreateVoucher(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>,
|
||
opts: CommitOptions = {}
|
||
): Promise<ExecutorResult> {
|
||
const entryDate = params.entry_date as string
|
||
const description = params.description as string
|
||
const lines = normalizeVoucherLines(params.lines)
|
||
|
||
if (!entryDate || !description || lines.length < 2) {
|
||
return { error: 'entry_date, description, and at least two lines are required', status: 400 }
|
||
}
|
||
|
||
// Re-validate balance defensively. The MCP tool already checks before
|
||
// staging, but a tampered or hand-inserted pending_operations row would
|
||
// bypass that gate. createDraftEntry runs the same check internally — this
|
||
// is for a cleaner 400 + Swedish error before reaching the engine.
|
||
const balance = validateBalance(lines)
|
||
if (!balance.valid) {
|
||
return {
|
||
error: `Verifikationen balanserar inte: debet ${balance.totalDebit} SEK, kredit ${balance.totalCredit} SEK.`,
|
||
status: 400,
|
||
}
|
||
}
|
||
|
||
// Resolve fiscal period: prefer explicit, fall back to date lookup so the
|
||
// caller can post a voucher without first calling list_fiscal_periods.
|
||
let fiscalPeriodId = params.fiscal_period_id as string | undefined
|
||
if (!fiscalPeriodId) {
|
||
const resolved = await findFiscalPeriod(supabase, companyId, entryDate)
|
||
if (!resolved) {
|
||
return {
|
||
error: `Ingen öppen räkenskapsperiod täcker datumet ${entryDate}. Öppna en period eller välj ett annat datum.`,
|
||
status: 400,
|
||
}
|
||
}
|
||
fiscalPeriodId = resolved
|
||
}
|
||
|
||
try {
|
||
const entry = await createJournalEntry(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
{
|
||
fiscal_period_id: fiscalPeriodId,
|
||
entry_date: entryDate,
|
||
description,
|
||
// source_type is hardcoded — never trust params.source_type. The MCP
|
||
// tool stages 'manual', but a future direct-staging path could
|
||
// otherwise inject 'bank'/'invoice'/etc. and corrupt audit attribution.
|
||
source_type: 'manual' as JournalEntrySourceType,
|
||
voucher_series: (params.voucher_series as string) || undefined,
|
||
notes: (params.notes as string) || undefined,
|
||
lines,
|
||
},
|
||
// commit_method records HOW it was committed, not who staged it. MCP-
|
||
// staged ops still go through human approval, so 'user_accept' (or
|
||
// 'bulk_accept' from the bulk route) is the correct value. The DB CHECK
|
||
// constraint rejects anything else (migration 20260420120001).
|
||
opts.commitMethod ?? 'user_accept'
|
||
)
|
||
|
||
// Optional inbox linking — set when gnubok_create_voucher is called with
|
||
// inbox_item_id (book-direct flow for kvitton). The verifikat is already
|
||
// posted and immutable; failures here are non-fatal and only affect
|
||
// discoverability (inbox row stays in "needs action" with the document
|
||
// unlinked). Logged so the user can repair via the UI if needed.
|
||
const inboxItemId = params.inbox_item_id as string | undefined
|
||
const documentId = params.document_id as string | undefined
|
||
let inboxLinked = false
|
||
if (inboxItemId) {
|
||
// Race guard: the UNIQUE constraint on
|
||
// invoice_inbox_items.created_journal_entry_id (migration 20260515090000)
|
||
// stops two inbox items from being linked to the same JE, but it does
|
||
// NOT stop two concurrent commits of different staged ops on the same
|
||
// inbox item from overwriting each other (the second UPDATE on the same
|
||
// row trivially satisfies UNIQUE). We add a `.is('created_journal_entry_id', null)`
|
||
// predicate so only the first commit succeeds; the loser sees a
|
||
// zero-rows-updated result and surfaces a structured warning. We also
|
||
// require .eq('created_supplier_invoice_id', null) so a concurrent
|
||
// create_supplier_invoice_from_inbox doesn't get clobbered either.
|
||
const { data: updatedRows, error: linkInboxErr } = await supabase
|
||
.from('invoice_inbox_items')
|
||
.update({ created_journal_entry_id: entry.id, status: 'confirmed' })
|
||
.eq('id', inboxItemId)
|
||
.eq('company_id', companyId)
|
||
.is('created_journal_entry_id', null)
|
||
.is('created_supplier_invoice_id', null)
|
||
.select('id')
|
||
|
||
if (linkInboxErr) {
|
||
log.warn('Failed to link inbox item to new voucher (voucher still posted)', {
|
||
inboxItemId,
|
||
journalEntryId: entry.id,
|
||
error: linkInboxErr.message,
|
||
})
|
||
} else if (!updatedRows || updatedRows.length === 0) {
|
||
// Race: another commit already claimed this inbox item (either as a
|
||
// journal entry or supplier invoice). The verifikat is already posted
|
||
// and immutable — we leave it; an operator can rättelse via storno
|
||
// if it's a true duplicate.
|
||
log.warn('Voucher posted but inbox item was already claimed by a concurrent commit', {
|
||
inboxItemId,
|
||
journalEntryId: entry.id,
|
||
})
|
||
} else {
|
||
inboxLinked = true
|
||
}
|
||
|
||
// Only attach the OCR document when the inbox link succeeded — if a
|
||
// racing commit already owns the inbox row, the document already lives
|
||
// on its JE and re-attaching here would either fail noisily (UNIQUE on
|
||
// document_attachments.journal_entry_id, if any) or silently shift it.
|
||
if (documentId && inboxLinked) {
|
||
try {
|
||
await linkToJournalEntry(supabase, companyId, documentId, entry.id)
|
||
} catch (linkDocErr) {
|
||
log.warn('Failed to attach inbox document to new voucher', {
|
||
documentId,
|
||
journalEntryId: entry.id,
|
||
error: linkDocErr instanceof Error ? linkDocErr.message : String(linkDocErr),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
journal_entry_id: entry.id,
|
||
voucher_number: entry.voucher_number,
|
||
voucher_series: entry.voucher_series,
|
||
fiscal_period_id: fiscalPeriodId,
|
||
...(inboxItemId ? { inbox_item_id: inboxItemId, inbox_linked: inboxLinked } : {}),
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Failed to create voucher', status: 500 }
|
||
}
|
||
}
|
||
|
||
async function commitCorrectEntry(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const entryId = params.entry_id as string
|
||
const lines = normalizeVoucherLines(params.lines)
|
||
|
||
if (!entryId || lines.length < 2) {
|
||
return { error: 'entry_id and at least two lines are required', status: 400 }
|
||
}
|
||
|
||
// Pre-flight: verify the original is posted and its period is not locked.
|
||
// Falling into correctEntry without this returns a less helpful DB error and
|
||
// half-creates the storno before rolling back; surfacing the Swedish message
|
||
// here matches the period_locked UX everywhere else in the app.
|
||
//
|
||
// Period lock check is two-layer (matches the DB triggers): per-period
|
||
// (is_closed / locked_at) AND company-wide (bookkeeping_locked_through).
|
||
// The staging tool uses resolvePeriodStatusForDate; we reuse it here so the
|
||
// commit-time gate matches the staging-time signal.
|
||
const { data: original, error: origErr } = await supabase
|
||
.from('journal_entries')
|
||
.select('id, status, entry_date, fiscal_period_id, fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(is_closed, locked_at)')
|
||
.eq('id', entryId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
if (origErr || !original) {
|
||
return { error: 'Verifikationen hittades inte.', status: 404 }
|
||
}
|
||
if (original.status !== 'posted') {
|
||
return {
|
||
error: `Endast bokförda verifikationer kan rättas. Aktuell status: ${original.status}. Drafts redigeras direkt.`,
|
||
status: 409,
|
||
}
|
||
}
|
||
const period = original.fiscal_periods as { is_closed?: boolean; locked_at?: string | null } | { is_closed?: boolean; locked_at?: string | null }[] | null
|
||
const periodRow = Array.isArray(period) ? period[0] : period
|
||
if (periodRow?.is_closed || periodRow?.locked_at) {
|
||
return {
|
||
error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.',
|
||
status: 409,
|
||
}
|
||
}
|
||
// resolvePeriodStatusForDate also covers the company-wide bookkeeping_locked_through
|
||
// gate. A DB blip here would otherwise propagate as a 500 with a raw Postgres
|
||
// message; wrap so the caller sees a clean Swedish 500 instead, consistent with
|
||
// the staging-side log-and-degrade behaviour in stagePendingOperation.
|
||
try {
|
||
const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, original.entry_date)
|
||
if (periodStatus.status === 'locked' || periodStatus.status === 'closed') {
|
||
return {
|
||
error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.',
|
||
status: 409,
|
||
}
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
error: `Kunde inte verifiera periodstatus: ${err instanceof Error ? err.message : 'okänt fel'}`,
|
||
status: 500,
|
||
}
|
||
}
|
||
|
||
try {
|
||
// correctEntry() posts both the storno and the corrected entry into the
|
||
// SAME fiscal_period_id and entry_date as the original (see
|
||
// lib/core/bookkeeping/storno-service.ts:99,102,195,198). So a rättelse
|
||
// made in May 2026 for a December 2025 voucher correctly lands in 2025,
|
||
// keeping that period's balances consistent. The is_closed pre-flight
|
||
// above is what blocks corrections to already-locked periods.
|
||
const result = await correctEntry(supabase, companyId, userId, entryId, lines)
|
||
return {
|
||
data: {
|
||
original_entry_id: entryId,
|
||
storno_entry_id: result.reversal.id,
|
||
corrected_entry_id: result.corrected.id,
|
||
storno_voucher_number: result.reversal.voucher_number,
|
||
corrected_voucher_number: result.corrected.voucher_number,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Failed to correct entry', status: 500 }
|
||
}
|
||
}
|
||
|
||
async function commitReverseEntry(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const entryId = params.entry_id as string
|
||
const reversalDate = typeof params.reversal_date === 'string' ? params.reversal_date : undefined
|
||
|
||
if (!entryId) {
|
||
return { error: 'entry_id is required', status: 400 }
|
||
}
|
||
|
||
// Pre-flight matches commitCorrectEntry: posted + period not closed. Surfaces
|
||
// Swedish messages before reverseEntry() throws less helpful errors. Period
|
||
// lock check is two-layer (per-period + company-wide bookkeeping_locked_through)
|
||
// via resolvePeriodStatusForDate, matching the staging-time signal.
|
||
const { data: original, error: origErr } = await supabase
|
||
.from('journal_entries')
|
||
.select('id, status, entry_date, fiscal_period_id, fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(is_closed, locked_at)')
|
||
.eq('id', entryId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
if (origErr || !original) {
|
||
return { error: 'Verifikationen hittades inte.', status: 404 }
|
||
}
|
||
if (original.status !== 'posted') {
|
||
return {
|
||
error: `Endast bokförda verifikationer kan makuleras. Aktuell status: ${original.status}.`,
|
||
status: 409,
|
||
}
|
||
}
|
||
const period = original.fiscal_periods as { is_closed?: boolean; locked_at?: string | null } | { is_closed?: boolean; locked_at?: string | null }[] | null
|
||
const periodRow = Array.isArray(period) ? period[0] : period
|
||
if (periodRow?.is_closed || periodRow?.locked_at) {
|
||
return {
|
||
error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.',
|
||
status: 409,
|
||
}
|
||
}
|
||
try {
|
||
const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, original.entry_date)
|
||
if (periodStatus.status === 'locked' || periodStatus.status === 'closed') {
|
||
return {
|
||
error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.',
|
||
status: 409,
|
||
}
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
error: `Kunde inte verifiera periodstatus: ${err instanceof Error ? err.message : 'okänt fel'}`,
|
||
status: 500,
|
||
}
|
||
}
|
||
|
||
try {
|
||
const reversal = await reverseEntry(supabase, companyId, userId, entryId, reversalDate)
|
||
// Invariant per BFL 5 kap 5§: the storno must land in the same fiscal period
|
||
// as the original entry. reverseEntry() at lib/bookkeeping/engine.ts:492 uses
|
||
// original.fiscal_period_id, but assert it here so a future engine change that
|
||
// breaks this invariant fails fast instead of silently shifting period attribution.
|
||
if (reversal.fiscal_period_id !== original.fiscal_period_id) {
|
||
return {
|
||
error: `BFL invariant broken: storno period ${reversal.fiscal_period_id} differs from original ${original.fiscal_period_id}.`,
|
||
status: 500,
|
||
}
|
||
}
|
||
return {
|
||
data: {
|
||
original_entry_id: entryId,
|
||
reversal_entry_id: reversal.id,
|
||
reversal_voucher_number: reversal.voucher_number,
|
||
reversal_voucher_series: reversal.voucher_series,
|
||
fiscal_period_id: reversal.fiscal_period_id,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
if (isBookkeepingError(err)) throw err
|
||
return { error: err instanceof Error ? err.message : 'Failed to reverse entry', status: 500 }
|
||
}
|
||
}
|
||
|
||
// ── Payroll executors ────────────────────────────────────────────
|
||
|
||
async function commitCreateSalaryRun(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const periodYear = params.period_year as number
|
||
const periodMonth = params.period_month as number
|
||
const paymentDate = params.payment_date as string
|
||
if (
|
||
!Number.isInteger(periodYear) ||
|
||
!Number.isInteger(periodMonth) ||
|
||
typeof paymentDate !== 'string'
|
||
) {
|
||
return { error: 'period_year, period_month, payment_date are required', status: 400 }
|
||
}
|
||
|
||
try {
|
||
const { createSalaryRunWithEmployees } = await import('@/lib/salary/create-run')
|
||
const { run, employeeCount } = await createSalaryRunWithEmployees(
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
{ periodYear, periodMonth, paymentDate },
|
||
)
|
||
return {
|
||
data: {
|
||
salary_run_id: (run as { id?: string }).id,
|
||
employee_count: employeeCount,
|
||
period: `${periodYear}-${String(periodMonth).padStart(2, '0')}`,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
error: err instanceof Error ? err.message : 'Failed to create salary run',
|
||
status: 500,
|
||
}
|
||
}
|
||
}
|
||
|
||
async function commitGenerateAgi(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
params: Record<string, unknown>
|
||
): Promise<ExecutorResult> {
|
||
const salaryRunId = params.salary_run_id as string
|
||
if (!salaryRunId) return { error: 'salary_run_id is required', status: 400 }
|
||
|
||
try {
|
||
const { generateAgiDeclaration } = await import('@/lib/salary/agi/generate-declaration')
|
||
const { randomUUID } = await import('node:crypto')
|
||
const result = await generateAgiDeclaration({
|
||
supabase,
|
||
companyId,
|
||
userId,
|
||
userEmail: null,
|
||
salaryRunId,
|
||
log: createLogger('commit/generate_agi'),
|
||
requestId: randomUUID(),
|
||
})
|
||
if (!result.ok) {
|
||
return { error: `AGI-generering misslyckades: ${result.code}`, status: 500 }
|
||
}
|
||
const period = `${result.periodYear}-${String(result.periodMonth).padStart(2, '0')}`
|
||
return {
|
||
data: {
|
||
agi_declaration_id: result.agiDeclarationId,
|
||
period,
|
||
employee_count: result.employeeCount,
|
||
is_correction: result.isCorrection,
|
||
download_url: `/api/salary/runs/${salaryRunId}/agi/xml`,
|
||
},
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
error: err instanceof Error ? err.message : 'Failed to generate AGI',
|
||
status: 500,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Public dispatcher ────────────────────────────────────────────
|
||
|
||
/**
|
||
* Execute a pending_operation by type, update its status row, and return a
|
||
* normalized CommitResult.
|
||
*
|
||
* Used by both the human-approval route and the auto-commit path. Status row
|
||
* transitions are applied here so the two callers stay consistent.
|
||
*/
|
||
export async function commitPendingOperation(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
pendingOp: PendingOperation,
|
||
opts: CommitOptions = {}
|
||
): Promise<CommitResult> {
|
||
// ── Atomic claim: flip status pending → committing in a single conditional
|
||
// update. If 0 rows are affected, another caller (auto-commit ↔ human
|
||
// approval, or two parallel approvals) already claimed this op and we
|
||
// must not run side-effects. Without this, both callers can pass the
|
||
// in-memory status check and double-book journal entries, send duplicate
|
||
// emails, etc.
|
||
const { data: claimed, error: claimError } = await supabase
|
||
.from('pending_operations')
|
||
.update({ status: 'committing' })
|
||
.eq('id', pendingOp.id)
|
||
.eq('status', 'pending')
|
||
.select('id')
|
||
.maybeSingle()
|
||
|
||
if (claimError) {
|
||
log.error('Failed to claim pending_operation:', claimError)
|
||
return { status: 'failed', error: 'Failed to claim operation', http_status: 500 }
|
||
}
|
||
if (!claimed) {
|
||
return {
|
||
status: 'failed',
|
||
error: 'Operation already claimed or resolved by another caller',
|
||
http_status: 409,
|
||
}
|
||
}
|
||
|
||
let result: ExecutorResult
|
||
try {
|
||
switch (pendingOp.operation_type) {
|
||
case 'categorize_transaction':
|
||
result = await commitCategorizeTransaction(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_customer':
|
||
result = await commitCreateCustomer(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_supplier':
|
||
result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_invoice':
|
||
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_transaction':
|
||
result = await commitCreateTransaction(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'mark_invoice_paid':
|
||
result = await commitMarkInvoicePaid(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'send_invoice':
|
||
result = await commitSendInvoice(supabase, userId, companyId, pendingOp.params, opts.userEmail)
|
||
break
|
||
case 'mark_invoice_sent':
|
||
result = await commitMarkInvoiceSent(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'match_transaction_invoice':
|
||
result = await commitMatchTransactionInvoice(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'close_period':
|
||
result = await commitClosePeriod(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'lock_period':
|
||
result = await commitLockPeriod(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'unlock_period':
|
||
result = await commitUnlockPeriod(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'uncategorize_transaction':
|
||
result = await commitUncategorizeTransaction(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'attach_document_to_transaction':
|
||
result = await commitAttachDocumentToTransaction(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'run_year_end':
|
||
result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'set_opening_balances':
|
||
result = await commitSetOpeningBalances(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'run_currency_revaluation':
|
||
result = await commitRunCurrencyRevaluation(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'explain_voucher_gap':
|
||
result = await commitExplainVoucherGap(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'approve_supplier_invoice':
|
||
result = await commitApproveSupplierInvoice(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_supplier_invoice_from_inbox':
|
||
result = await commitCreateSupplierInvoiceFromInbox(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'credit_supplier_invoice':
|
||
result = await commitCreditSupplierInvoice(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'convert_invoice':
|
||
result = await commitConvertInvoice(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'credit_invoice':
|
||
result = await commitCreditInvoice(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'import_sie':
|
||
result = await commitImportSie(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_voucher':
|
||
result = await commitCreateVoucher(supabase, userId, companyId, pendingOp.params, opts)
|
||
break
|
||
case 'correct_entry':
|
||
result = await commitCorrectEntry(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'reverse_entry':
|
||
result = await commitReverseEntry(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'post_annual_depreciation':
|
||
result = await commitPostAnnualDepreciation(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'create_salary_run':
|
||
result = await commitCreateSalaryRun(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
case 'generate_agi':
|
||
result = await commitGenerateAgi(supabase, userId, companyId, pendingOp.params)
|
||
break
|
||
default:
|
||
return {
|
||
status: 'failed',
|
||
error: `Unknown operation type: ${pendingOp.operation_type}`,
|
||
http_status: 400,
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// Accounts-not-in-chart is RECOVERABLE: the booking itself is valid; the
|
||
// company's chart just lacks the (standard BAS) accounts it posts to. Do
|
||
// NOT consume the op — release the atomic claim back to 'pending' so the
|
||
// user can activate the accounts and retry the SAME op — and surface the
|
||
// structured code + numbers so the client can offer one-click activation.
|
||
if (err instanceof AccountsNotInChartError) {
|
||
await supabase
|
||
.from('pending_operations')
|
||
.update({ status: 'pending' })
|
||
.eq('id', pendingOp.id)
|
||
return {
|
||
status: 'failed',
|
||
error: err.message,
|
||
http_status: 400,
|
||
code: ACCOUNTS_NOT_IN_CHART,
|
||
account_numbers: err.accountNumbers,
|
||
}
|
||
}
|
||
const isBkErr = isBookkeepingError(err)
|
||
const message = err instanceof Error ? err.message : (isBkErr ? 'Bookkeeping error' : 'Executor failed')
|
||
// Release the claim by transitioning to 'rejected' so the row never gets
|
||
// stuck in 'committing'. The error text is persisted in result_data for
|
||
// audit/debug.
|
||
await supabase
|
||
.from('pending_operations')
|
||
.update({
|
||
status: 'rejected',
|
||
resolved_at: new Date().toISOString(),
|
||
result_data: { error: message, threw: true },
|
||
})
|
||
.eq('id', pendingOp.id)
|
||
return {
|
||
status: 'failed',
|
||
error: message,
|
||
http_status: isBkErr ? 400 : 500,
|
||
}
|
||
}
|
||
|
||
if (result.error) {
|
||
const isAutoReject = result.status === 404 || result.status === 409
|
||
await supabase
|
||
.from('pending_operations')
|
||
.update({
|
||
status: 'rejected',
|
||
resolved_at: new Date().toISOString(),
|
||
result_data: isAutoReject
|
||
? { auto_rejected: true, reason: result.error }
|
||
: { error: result.error, http_status: result.status },
|
||
})
|
||
.eq('id', pendingOp.id)
|
||
if (isAutoReject) {
|
||
return {
|
||
status: 'rejected',
|
||
auto_rejected: true,
|
||
error: result.error,
|
||
http_status: result.status,
|
||
}
|
||
}
|
||
return {
|
||
status: 'failed',
|
||
error: result.error,
|
||
http_status: result.status ?? 500,
|
||
}
|
||
}
|
||
|
||
const now = new Date().toISOString()
|
||
await supabase
|
||
.from('pending_operations')
|
||
.update({
|
||
status: 'committed',
|
||
resolved_at: now,
|
||
result_data: result.data || {},
|
||
})
|
||
.eq('id', pendingOp.id)
|
||
|
||
return {
|
||
status: 'committed',
|
||
data: result.data,
|
||
}
|
||
}
|
||
|