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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests: 4112 unchanged. Build: green.

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

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

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

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

Tests: 4112. Build: green.

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

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

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

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

Tests: 4112. Build: green.

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

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

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

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

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

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

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

Tests: 4112. Build: green.

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

* fix(pending): trim the agent context strip

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

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

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

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

Two regressions surfaced in real usage. Both are systemic.

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

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

Tests: 4112. Build: green.

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

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

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

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

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

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

Three pre-ship quality wins.

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

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

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

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

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

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

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

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

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

Tests: 4112. Build: green.

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

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

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

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

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

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

Tests: 4112. Build: green.

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

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

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

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

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

Build: green.

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

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

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

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

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

Tests: 4112. Build: green.

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

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

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

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

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

Build: green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* wip: bundle in-progress branch work

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

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

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

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

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

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

PR #584 went red on three things:

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

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

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

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

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

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

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

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

---------

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

3067 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Entity types
export type EntityType = 'enskild_firma' | 'aktiebolag'
// Swedish accounting framework. K2 (BFNAR 2016:10) is the default simplified
// ruleset for smaller AB; K3 (BFNAR 2012:1) is the principles-based ruleset
// required for medium-to-large AB and permitted voluntarily for smaller ones.
// Only meaningful for entity_type='aktiebolag'.
export type AccountingFramework = 'k2' | 'k3'
// Company role for multi-tenant access
export type CompanyRole = 'owner' | 'admin' | 'member' | 'viewer'
// Team (consulting firm) roles and source tracking
export type TeamRole = 'owner' | 'admin' | 'member'
export type MemberSource = 'direct' | 'team'
// Team (consulting firm grouping)
export interface Team {
id: string
name: string
created_by: string
created_at: string
updated_at: string
}
// Company (multi-tenant identity)
export interface Company {
id: string
name: string
org_number: string | null
entity_type: EntityType
accounting_framework: AccountingFramework
created_by: string
team_id: string | null
archived_at: string | null
created_at: string
updated_at: string
}
// Company membership
export interface CompanyMember {
id: string
company_id: string
user_id: string
role: CompanyRole
invited_by: string | null
joined_at: string
created_at: string
updated_at: string
}
// User preferences (cross-company)
export interface UserPreferences {
id: string
user_id: string
active_company_id: string | null
created_at: string
updated_at: string
}
// Transaction categories
export type TransactionCategory =
| 'income_services'
| 'income_products'
| 'income_other'
| 'expense_equipment'
| 'expense_software'
| 'expense_travel'
| 'expense_office'
| 'expense_marketing'
| 'expense_professional_services'
| 'expense_education'
| 'expense_representation'
| 'expense_consumables'
| 'expense_vehicle'
| 'expense_telecom'
| 'expense_bank_fees'
| 'expense_card_fees'
| 'expense_currency_exchange'
| 'expense_other'
| 'private'
| 'uncategorized'
// Customer types for VAT handling
export type CustomerType =
| 'individual' // Swedish private person
| 'swedish_business' // Swedish company
| 'eu_business' // EU company (needs VAT validation)
| 'non_eu_business' // Non-EU company
// Invoice status
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partially_paid' | 'overdue' | 'cancelled' | 'credited'
// Invoice document type
export type InvoiceDocumentType = 'invoice' | 'proforma' | 'delivery_note'
// Supplier types
export type SupplierType = 'swedish_business' | 'eu_business' | 'non_eu_business'
// Supplier invoice status
// 'reversed' marks a credit note whose journal entry was storno-reversed via
// "Ångra kreditering". The row is preserved (BFL 7 kap) rather than hard-deleted.
export type SupplierInvoiceStatus = 'registered' | 'approved' | 'paid' | 'partially_paid' | 'overdue' | 'disputed' | 'credited' | 'reversed'
// VAT treatment
export type VatTreatment =
| 'standard_25' // 25% Swedish VAT
| 'reduced_12' // 12% reduced rate
| 'reduced_6' // 6% reduced rate
| 'reverse_charge' // EU reverse charge (0%)
| 'export' // Non-EU export (0%)
| 'exempt' // VAT exempt
// Accounting method (bokföringsmetod)
export type AccountingMethod = 'accrual' | 'cash'
// Moms reporting period
export type MomsPeriod = 'monthly' | 'quarterly' | 'yearly'
// Reconciliation method
export type ReconciliationMethod = 'auto_exact' | 'auto_date_range' | 'auto_reference' | 'auto_fuzzy' | 'manual'
// Processing history (behandlingshistorik) — event-driven audit trail per BFNAR 2013:2 kap 8
export type ProcessingHistoryActorType = 'user' | 'system' | 'llm' | 'cron' | 'api_key'
export interface ProcessingHistoryActor {
type: ProcessingHistoryActorType
id: string
label?: string
}
export type ProcessingHistoryAggregateType =
| 'Document'
| 'BankTransaction'
| 'MatchProposal'
| 'Verifikation'
| 'CounterpartyTemplate'
| 'Period'
| 'Migration'
| 'System'
export interface ProcessingHistoryEvent {
event_id: string
seq: number
company_id: string
correlation_id: string
causation_id: string | null
aggregate_type: ProcessingHistoryAggregateType
aggregate_id: string
event_type: string // open type — validated at runtime against processing_event_types registry
payload: Record<string, unknown>
payload_schema_version: number
actor: ProcessingHistoryActor
rubric_version: string | null
occurred_at: string
appended_at: string
}
// Bank connection status
// 'pending_selection' = PSD2 consent granted, awaiting user to pick which
// accounts to actually sync. No transactions are pulled in this state.
export type BankConnectionStatus = 'pending' | 'pending_selection' | 'active' | 'expired' | 'revoked' | 'error'
// Currency types
export type Currency = 'SEK' | 'EUR' | 'USD' | 'GBP' | 'NOK' | 'DKK'
// Profile (extends auth.users)
export interface Profile {
id: string
email: string
full_name: string | null
avatar_url: string | null
created_at: string
updated_at: string
}
// Company Settings
export interface CompanySettings {
id: string
user_id: string
company_id: string
// Entity info
entity_type: EntityType
company_name: string | null
org_number: string | null
// Address
address_line1: string | null
address_line2: string | null
postal_code: string | null
city: string | null
country: string
// Contact
phone: string | null
email: string | null
website: string | null
// Tax registration
pays_salaries: boolean
f_skatt: boolean
vat_registered: boolean
vat_number: string | null
moms_period: MomsPeriod | null
periodisk_sammanstallning_period: 'monthly' | 'quarterly'
// Tax contact (SKV-filings, periodisk sammanställning, AGI, etc.)
tax_contact_name: string | null
tax_contact_phone: string | null
tax_contact_email: string | null
// Fiscal year
fiscal_year_start_month: number // 1-12
// Transient first-year fields (used during onboarding, not persisted in DB)
is_first_fiscal_year?: boolean
first_year_start?: string
first_year_end?: string
// Preliminary tax
preliminary_tax_monthly: number | null
// Bank details for invoices
bank_name: string | null
clearing_number: string | null
account_number: string | null
bankgiro: string | null
plusgiro: string | null
swish: string | null
iban: string | null
bic: string | null
// Accounting method
accounting_method: AccountingMethod
// Invoice settings
invoice_prefix: string | null
next_invoice_number: number
next_delivery_note_number: number
invoice_default_days: number
invoice_default_notes: string | null
// Bookkeeping lock
bookkeeping_locked_through: string | null
auto_lock_period_days: number | null
// Voucher series
default_voucher_series: string
/**
* Per-source-type default voucher series map. Keys are
* JournalEntrySourceType values; values are single uppercase letters A–Z.
* Resolved by `lib/bookkeeping/voucher-series-resolver.ts`. Defaults to
* all "A" entries; users can override per source via the bookkeeping
* settings UI.
*/
default_voucher_series_per_source_type: Partial<Record<JournalEntrySourceType, string>>
// Invoice PDF settings
ore_rounding: boolean
invoice_show_ocr: boolean
invoice_show_bankgiro: boolean
invoice_show_plusgiro: boolean
invoice_show_swish: boolean
invoice_show_logo: boolean
invoice_show_company_name: boolean
invoice_company_name_position: 'header' | 'footer'
invoice_late_fee_text: string | null
invoice_credit_terms_text: string | null
// Invoice branding (per-company colors, font, optional header/footer text).
// Defaults preserve the legacy hardcoded palette so unbranded companies
// render identically to the pre-branding template.
invoice_primary_color: string // hex #RRGGBB, default '#1a1a1a'
invoice_accent_color: string // hex #RRGGBB, default '#666666'
invoice_font_family: 'Helvetica' | 'Times-Roman' | 'Courier'
invoice_header_text: string | null
invoice_footer_text: string | null
// Automation
send_invoice_reminders: boolean
// Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
reminder_fee_enabled: boolean
reminder_fee_amount: number
reminder_interest_rate_override: number | null
// Logo
logo_url: string | null
// Onboarding
onboarding_step: number
onboarding_complete: boolean
// Sector
sector_slug: string | null
// Sandbox
is_sandbox: boolean
// Timestamps
created_at: string
updated_at: string
}
// Bank Connection
export interface BankConnection {
id: string
user_id: string
company_id: string
bank_name: string
provider: string
// Enable Banking specific
session_id: string | null
authorization_id: string | null
// Account info
accounts_data: BankAccount[]
// Status
status: BankConnectionStatus
// Consent
consent_expires: string | null
last_synced_at: string | null
error_message: string | null
// Initial-sync metadata. initial_sync_completed_at gates the cron's
// first-sync 90-day backfill path independently of last_synced_at, so
// a manual "Sync now" doesn't permanently lose the deep backfill window.
// The returned-date columns power the "we requested X but got Y" UI when
// an ASPSP truncates history below the requested window.
initial_sync_completed_at: string | null
initial_sync_requested_from: string | null
initial_sync_returned_min_date: string | null
initial_sync_returned_max_date: string | null
initial_sync_lookback_days: number | null
created_at: string
updated_at: string
}
export interface BankAccount {
uid: string // Enable Banking account UID
iban: string | null
name: string | null
currency: Currency
balance: number | null
balance_updated_at?: string | null
}
// Cash account — first-class entity for ledger-account routing decisions.
// Backed by the cash_accounts table; bank_connections.accounts_data remains
// the source for PSD2 sync metadata + UI display until a follow-up migration
// drops it 30 days after this PR.
export type CashAccountSource = 'enable_banking' | 'manual' | 'sie_import'
export interface CashAccount {
id: string
company_id: string
bank_connection_id: string | null
external_uid: string | null // PSD2 StoredAccount.uid
iban: string | null
bg_pg: string | null
name: string | null
currency: string // 3-char ISO; broader than Currency union to
// tolerate future currencies without DB-driven enum drift
ledger_account: string
balance: number | null
balance_updated_at: string | null
enabled: boolean
is_primary: boolean
source: CashAccountSource
created_at: string
updated_at: string
}
// Import source identifiers
export type ImportSource =
| 'enable_banking'
| 'csv_nordea'
| 'csv_seb'
| 'csv_swedbank'
| 'csv_handelsbanken'
| 'csv_generic'
| 'camt053'
| 'manual'
// Transaction
export interface Transaction {
id: string
user_id: string
company_id: string
// Source
bank_connection_id: string | null
external_id: string | null // For deduplication
// Details
date: string
description: string
amount: number // Positive = income, negative = expense
currency: Currency
// For non-SEK transactions
amount_sek: number | null
exchange_rate: number | null
exchange_rate_date: string | null
// Categorization
category: TransactionCategory
is_business: boolean | null // null = uncategorized
// Linked invoice (for matching)
invoice_id: string | null
// Linked supplier invoice (for matching)
supplier_invoice_id: string | null
// Potential invoice match (suggested, not confirmed)
potential_invoice_id: string | null
// Potential supplier invoice match (suggested, not confirmed)
potential_supplier_invoice_id: string | null
// Bookkeeping
journal_entry_id: string | null
mcc_code: number | null
merchant_name: string | null
// Receipt link
receipt_id: string | null
// Inbox/upload document pinned to this transaction (pre-categorization).
// Propagates to document_attachments.journal_entry_id on categorize.
document_id: string | null
// Reconciliation
reconciliation_method: ReconciliationMethod | null
// Import tracking
import_source: string | null
reference: string | null // OCR number, Bankgiro reference
// Counterparty identification from PSD2 (creditor for outflows, debtor for
// inflows). The own-account transfer detector matches `counterparty_iban`
// against cash_accounts.iban for the same company. `counterparty_account`
// is the BG/PG/BBAN fallback for Swedish domestic transfers without IBAN.
counterparty_iban: string | null
counterparty_account: string | null
// Notes
notes: string | null
created_at: string
updated_at: string
}
// Bank File Import (tracking table for file-based imports)
export type BankFileImportStatus = 'pending' | 'processing' | 'completed' | 'failed'
export interface BankFileImport {
id: string
user_id: string
company_id: string
filename: string
file_hash: string
file_format: string
transaction_count: number
imported_count: number
duplicate_count: number
matched_count: number
date_from: string | null
date_to: string | null
status: BankFileImportStatus
error_message: string | null
created_at: string
updated_at: string
}
// Customer
export interface Customer {
id: string
user_id: string
company_id: string
// Basic info
name: string
customer_type: CustomerType
// Contact
email: string | null
phone: string | null
// Address
address_line1: string | null
address_line2: string | null
postal_code: string | null
city: string | null
country: string
// Tax info
org_number: string | null
vat_number: string | null
vat_number_validated: boolean
vat_number_validated_at: string | null
personal_number: string | null
// Language for customer-facing invoice PDF and email
language: 'sv' | 'en'
// Payment
default_payment_terms: number // Days
// Notes
notes: string | null
created_at: string
updated_at: string
}
// Supplier
export interface Supplier {
id: string
user_id: string
company_id: string
name: string
supplier_type: SupplierType
email: string | null
phone: string | null
address_line1: string | null
address_line2: string | null
postal_code: string | null
city: string | null
country: string
org_number: string | null
vat_number: string | null
bankgiro: string | null
plusgiro: string | null
bank_account: string | null
iban: string | null
bic: string | null
default_expense_account: string | null
default_payment_terms: number
default_currency: string
notes: string | null
created_at: string
updated_at: string
}
// Supplier Invoice
export interface SupplierInvoice {
id: string
user_id: string
company_id: string
supplier_id: string
arrival_number: number
supplier_invoice_number: string
invoice_date: string
due_date: string
received_date: string
delivery_date: string | null
status: SupplierInvoiceStatus
currency: string
exchange_rate: number | null
exchange_rate_date: string | null
subtotal: number
subtotal_sek: number | null
vat_amount: number
vat_amount_sek: number | null
total: number
total_sek: number | null
vat_treatment: VatTreatment
reverse_charge: boolean
payment_reference: string | null
paid_at: string | null
paid_amount: number
remaining_amount: number
is_credit_note: boolean
credited_invoice_id: string | null
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
transaction_id: string | null
document_id: string | null
// Owner paid out-of-pocket; AP step is bypassed and the expense is booked
// directly against 2893 (AB) or 2018 (EF). Status is set to 'paid' at
// creation and mark-paid is rejected by the existing status guard.
paid_with_private_funds: boolean
notes: string | null
created_at: string
updated_at: string
// Relations (populated when fetched)
supplier?: Supplier
items?: SupplierInvoiceItem[]
payments?: SupplierInvoicePayment[]
}
// Supplier Invoice Item
export interface SupplierInvoiceItem {
id: string
supplier_invoice_id: string
sort_order: number
description: string
quantity: number
unit: string
unit_price: number
line_total: number
account_number: string
vat_code: string | null
vat_rate: number
vat_amount: number
created_at: string
}
// Supplier Invoice Payment (partial payments)
export interface SupplierInvoicePayment {
id: string
supplier_invoice_id: string
payment_date: string
amount: number
currency: string
exchange_rate: number | null
exchange_rate_difference: number
journal_entry_id: string | null
transaction_id: string | null
notes: string | null
created_at: string
}
// Invoice Payment (partial payments)
export interface InvoicePayment {
id: string
user_id: string
company_id: string
invoice_id: string
payment_date: string
amount: number
currency: string
exchange_rate: number | null
exchange_rate_difference: number
journal_entry_id: string | null
transaction_id: string | null
notes: string | null
created_at: string
}
// Invoice
export interface Invoice {
id: string
user_id: string
company_id: string
customer_id: string
// Invoice number (auto-generated at first send; null while draft)
invoice_number: string | null
// Dates
invoice_date: string
due_date: string
delivery_date: string | null
// Status
status: InvoiceStatus
// Currency
currency: Currency
// Exchange rate (if non-SEK)
exchange_rate: number | null
exchange_rate_date: string | null
// Amounts
subtotal: number
subtotal_sek: number | null
vat_amount: number
vat_amount_sek: number | null
total: number
total_sek: number | null
// VAT
vat_treatment: VatTreatment
vat_rate: number
moms_ruta: string | null // For Swedish VAT reporting (05, 39, 40, etc.)
// Reference
your_reference: string | null
our_reference: string | null
// Notes
notes: string | null
// Reverse charge text (auto-added for EU B2B)
reverse_charge_text: string | null
// Credit note reference
credited_invoice_id: string | null
// Document type (invoice, proforma, delivery_note, quote)
document_type: InvoiceDocumentType
// Conversion tracking (proforma -> invoice)
converted_from_id: string | null
// Payment tracking
paid_at: string | null
paid_amount: number | null
remaining_amount: number
// ROT/RUT-avdrag claim info. `deduction_total` is the sum of the per-item
// deduction_amount and equals the 1513 debit on the verifikation. The
// personnummer is stored only as AES-256-GCM ciphertext + the last four
// digits (PII isolation). All three fields are null/0 on invoices with
// no ROT/RUT lines. Optional in TypeScript to keep legacy fixtures
// (pre-migration) valid — treat undefined the same as 0/null.
deduction_total?: number
deduction_personnummer_encrypted?: string | null
deduction_personnummer_last4?: string | null
created_at: string
updated_at: string
// Relations (populated when fetched)
customer?: Customer
items?: InvoiceItem[]
payments?: InvoicePayment[]
}
// Invoice Item
export interface InvoiceItem {
id: string
invoice_id: string
// Order
sort_order: number
// Description
description: string
// Quantity
quantity: number
unit: string // 'st', 'tim', 'dag', etc.
// Price
unit_price: number
// Calculated
line_total: number
// Per-line VAT
vat_rate: number
vat_amount: number
// ROT/RUT-avdrag (Sweden's tax deduction for household services / home
// renovation). When `deduction_type` is set, the system computes
// `deduction_amount` from the rules in lib/invoices/rot-rut-rules.ts
// and posts the receivable to BAS 1513 (Skatteverket). v1 deducts on
// the full line total; future work can use `labor_hours` to honour the
// labor-only restriction.
//
// All fields are optional in TypeScript even though Postgres has
// defaults — legacy rows pulled before the schema change carry
// `undefined` in JS land, and many existing test fixtures predate the
// ROT/RUT migration. Treat undefined the same as null/0 throughout.
deduction_type?: 'rot' | 'rut' | null
deduction_amount?: number
labor_hours?: number | null
/** Skatteverket arbetstypskod (e.g. 'BYGG', 'STAD'). See ROT_WORK_TYPES / RUT_WORK_TYPES. */
work_type?: string | null
/** Fastighetsbeteckning. Required for ROT, optional for RUT. */
housing_designation?: string | null
/** Lägenhetsnummer. Optional, used for ROT in flerbostadshus. */
apartment_number?: string | null
created_at: string
}
// Recurring Invoice Schedule (template + monthly cadence)
export type RecurringInvoiceScheduleStatus = 'active' | 'paused'
export interface RecurringInvoiceSchedule {
id: string
company_id: string
user_id: string
customer_id: string
name: string
// Monthly cadence, day-of-month 1-31. Clamped to last day of month in
// shorter months (handled by computeNextRunDate).
day_of_month: number
payment_terms_days: number
currency: Currency
your_reference: string | null
our_reference: string | null
notes: string | null
auto_send: boolean
status: RecurringInvoiceScheduleStatus
next_run_date: string
last_run_at: string | null
last_invoice_id: string | null
last_run_warning: string | null
generated_count: number
created_at: string
updated_at: string
// Relations
customer?: Customer
items?: RecurringInvoiceScheduleItem[]
}
export interface RecurringInvoiceScheduleItem {
id: string
schedule_id: string
sort_order: number
description: string
quantity: number
unit: string
unit_price: number
// null = inherit customer's default VAT rate at spawn time
vat_rate: number | null
created_at: string
}
// Tax Rates (reference table)
export interface TaxRate {
id: string
// Type
rate_type: 'egenavgifter' | 'bolagsskatt' | 'arbetsgivaravgifter' | 'vat' | 'municipal'
// Rate
rate: number
// Validity
valid_from: string
valid_to: string | null
// Description
description: string
}
// Form types for creating/updating
export interface CreateCustomerInput {
name: string
customer_type: CustomerType
email?: string
phone?: string
address_line1?: string
address_line2?: string
postal_code?: string
city?: string
country?: string
org_number?: string
vat_number?: string
personal_number?: string
language?: 'sv' | 'en'
default_payment_terms?: number
notes?: string
}
export interface CreateSupplierInput {
name: string
supplier_type: SupplierType
email?: string
phone?: string
address_line1?: string
address_line2?: string
postal_code?: string
city?: string
country?: string
org_number?: string
vat_number?: string
bankgiro?: string
plusgiro?: string
bank_account?: string
iban?: string
bic?: string
default_expense_account?: string
default_payment_terms?: number
default_currency?: string
notes?: string
}
export interface CreateSupplierInvoiceInput {
supplier_id: string
supplier_invoice_number: string
invoice_date: string
due_date: string
delivery_date?: string
currency?: string
exchange_rate?: number
vat_treatment?: VatTreatment
reverse_charge?: boolean
payment_reference?: string
notes?: string
items: CreateSupplierInvoiceItemInput[]
}
export interface CreateSupplierInvoiceItemInput {
description: string
amount: number
account_number: string
vat_rate?: number
// Manual override. See CreateSupplierInvoiceItemSchema for rationale.
vat_amount?: number
vat_code?: string
// Legacy fields (backward compat, ignored when amount is set)
quantity?: number
unit?: string
unit_price?: number
}
export interface CreateInvoiceInput {
customer_id: string
invoice_date: string
due_date: string
currency: Currency
document_type?: InvoiceDocumentType
your_reference?: string
our_reference?: string
notes?: string
/** Plaintext personnummer — encrypted server-side before storage. */
deduction_personnummer?: string
/** Fastighetsbeteckning. Required when any item carries deduction_type === 'rot'. */
deduction_housing_designation?: string
items: CreateInvoiceItemInput[]
}
export interface CreateInvoiceItemInput {
description: string
quantity: number
unit: string
unit_price: number
vat_rate?: number
/** ROT/RUT toggle. null/undefined = no deduction. */
deduction_type?: 'rot' | 'rut' | null
labor_hours?: number | null
work_type?: string | null
housing_designation?: string | null
apartment_number?: string | null
}
export interface CreateTransactionInput {
date: string
description: string
amount: number
currency: Currency
category?: TransactionCategory
is_business?: boolean
notes?: string
}
// API Response types
export interface ApiResponse<T> {
data?: T
error?: string
}
export interface PaginatedResponse<T> {
data: T[]
count: number
page: number
pageSize: number
totalPages: number
}
// VAT validation response
export interface VatValidationResult {
valid: boolean
name?: string
address?: string
country_code?: string
vat_number?: string
error?: string
}
// Exchange rate response
export interface ExchangeRate {
currency: Currency
rate: number
date: string
}
// Dashboard summary types
export interface DashboardSummary {
// Income
total_income_ytd: number
total_income_mtd: number
// Expenses
total_expenses_ytd: number
total_expenses_mtd: number
// Net
net_income_ytd: number
net_income_mtd: number
// Tax estimates
estimated_tax: TaxEstimate
// Alerts
uncategorized_count: number
unpaid_invoices_count: number
unpaid_invoices_total: number
overdue_invoices_count: number
// Bank
bank_balance: number | null
available_balance: number | null // After tax reservations
}
export interface TaxEstimate {
// For EF
egenavgifter?: number
income_tax?: number // Municipal tax (kommunalskatt)
state_tax?: number // State tax (statlig skatt) - 20% on high incomes
grundavdrag?: number // Basic deduction applied
// For AB
bolagsskatt?: number
// Common
moms_to_pay: number
total_tax_liability: number
// Comparison with preliminary
preliminary_paid_ytd: number
difference: number // Positive = underpaying
}
// ============================================================
// BAS Kontoplan & Bookkeeping Types
// ============================================================
// Risk levels for mapping rules
export type RiskLevel = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
// Account types
export type AccountType = 'asset' | 'equity' | 'liability' | 'revenue' | 'expense' | 'untaxed_reserves'
export type NormalBalance = 'debit' | 'credit'
export type PlanType = 'k1' | 'full_bas'
// Journal entry source
export type JournalEntrySourceType =
| 'manual'
| 'bank_transaction'
| 'invoice_created'
| 'invoice_paid'
| 'invoice_cash_payment'
| 'credit_note'
| 'salary_payment'
| 'opening_balance'
| 'year_end'
| 'storno'
| 'correction'
| 'import'
| 'system'
| 'inbox_item'
| 'supplier_invoice_registered'
| 'supplier_invoice_paid'
| 'supplier_invoice_cash_payment'
| 'supplier_invoice_privately_paid'
| 'supplier_credit_note'
| 'currency_revaluation'
| 'reminder_fee'
// Journal entry status
export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled'
// Mapping rule type
export type MappingRuleType =
| 'mcc_code'
| 'merchant_name'
| 'description_pattern'
| 'amount_threshold'
| 'combined'
// BAS Account
export interface BASAccount {
id: string
user_id: string
company_id: string
account_number: string
account_name: string
account_class: number
account_group: string
account_type: AccountType
normal_balance: NormalBalance
plan_type: PlanType
is_active: boolean
is_system_account: boolean
default_vat_code: string | null
description: string | null
sru_code: string | null
k2_excluded: boolean
sort_order: number
created_at: string
updated_at: string
}
// Fiscal Period (Räkenskapsår)
export interface FiscalPeriod {
id: string
user_id: string
company_id: string
name: string
period_start: string
period_end: string
is_closed: boolean
closed_at: string | null
locked_at: string | null
retention_expires_at: string | null
opening_balances_set: boolean
closing_entry_id: string | null
opening_balance_entry_id: string | null
previous_period_id: string | null
created_at: string
updated_at: string
}
// Journal Entry (Verifikation)
export interface JournalEntry {
id: string
user_id: string
company_id: string
fiscal_period_id: string
voucher_number: number
voucher_series: string
entry_date: string
description: string
source_type: JournalEntrySourceType
source_id: string | null
status: JournalEntryStatus
committed_at: string | null
reversed_by_id: string | null
reverses_id: string | null
correction_of_id: string | null
attachment_urls: string[] | null
notes: string | null
commit_method: string | null
rubric_version: string | null
source_voucher_series: string | null
source_voucher_number: number | null
created_at: string
updated_at: string
// Relations
lines?: JournalEntryLine[]
// Set by list_fiscal_period_entries_with_related when the entry was
// returned as a follow-up from a different fiscal period than the one
// being viewed. Absent from plain PostgREST responses.
out_of_period?: boolean
}
// Journal Entry Line
export interface JournalEntryLine {
id: string
journal_entry_id: string
account_number: string
account_id: string | null
debit_amount: number
credit_amount: number
currency: string
amount_in_currency: number | null
exchange_rate: number | null
line_description: string | null
tax_code: string | null
cost_center: string | null
project: string | null
sort_order: number
created_at: string
}
// Mapping Rule
export interface MappingRule {
id: string
user_id: string | null
company_id: string | null
rule_name: string
rule_type: MappingRuleType
priority: number
// Matching
mcc_codes: number[] | null
merchant_pattern: string | null
description_pattern: string | null
amount_min: number | null
amount_max: number | null
// Targets
debit_account: string | null
credit_account: string | null
vat_treatment: string | null
vat_debit_account: string | null
vat_credit_account: string | null
// Risk
risk_level: RiskLevel
default_private: boolean
requires_review: boolean
confidence_score: number
// Capitalization
capitalization_threshold: number | null
capitalized_debit_account: string | null
// Source tracking
source: 'auto' | 'user_description' | 'system'
user_description: string | null
template_id: string | null
// Meta
is_active: boolean
created_at: string
updated_at: string
}
// Mapping engine result
export interface MappingResult {
rule: MappingRule | null
template_id?: string
debit_account: string
credit_account: string
risk_level: RiskLevel
confidence: number
requires_review: boolean
default_private: boolean
vat_lines: VatJournalLine[]
all_lines_complete?: boolean // when true, vat_lines contains ALL non-settlement lines
description: string
}
// VAT journal line (auto-generated)
export interface VatJournalLine {
account_number: string
debit_amount: number
credit_amount: number
description: string
}
// Categorization template source
export type CategorizationTemplateSource = 'sie_import' | 'user_approved' | 'sni_default' | 'auto_learned' | 'ai_corrected'
// Multi-line booking pattern entry
export interface LinePatternEntry {
account: string
type: 'business' | 'vat' | 'tax'
side: 'debit' | 'credit'
ratio?: number // proportion of NON-VAT amount (business + tax ratios sum to ~1.0)
vat_rate?: number // applied to FULL amount via rate/(1+rate) (vat type only)
}
// Per-tenant counterparty-based categorization template
export interface CategorizationTemplate {
id: string
user_id: string
company_id: string
counterparty_name: string
counterparty_aliases: string[]
debit_account: string
credit_account: string
vat_treatment: VatTreatment | null
vat_account: string | null
category: TransactionCategory | null
line_pattern: LinePatternEntry[] | null
occurrence_count: number
confidence: number
last_seen_date: string | null
source: CategorizationTemplateSource
is_active: boolean
created_at: string
updated_at: string
}
// Booking template library categories
export type BookingTemplateCategory =
| 'eu_trade'
| 'tax_account'
| 'private_transfer'
| 'salary'
| 'representation'
| 'year_end'
| 'vat'
| 'financial'
| 'other'
// Booking template library line
export interface BookingTemplateLibraryLine {
account: string
label: string
side: 'debit' | 'credit'
type: 'business' | 'vat' | 'settlement'
ratio?: number
vat_rate?: number
}
// Booking template library entry (system, team, or company-scoped)
export interface BookingTemplateLibrary {
id: string
company_id: string | null
team_id: string | null
created_by: string | null
name: string
description: string
category: BookingTemplateCategory
entity_type: 'all' | EntityType
lines: BookingTemplateLibraryLine[]
is_system: boolean
is_active: boolean
created_at: string
updated_at: string
}
// Account Balance (cached)
export interface AccountBalance {
id: string
user_id: string
company_id: string
fiscal_period_id: string
account_number: string
account_id: string | null
opening_debit: number
opening_credit: number
period_debit: number
period_credit: number
closing_debit: number
closing_credit: number
created_at: string
updated_at: string
}
// Report types
export interface TrialBalanceRow {
account_number: string
account_name: string
account_class: number
opening_debit: number
opening_credit: number
period_debit: number
period_credit: number
closing_debit: number
closing_credit: number
}
export interface IncomeStatementSection {
title: string
rows: { account_number: string; account_name: string; amount: number }[]
subtotal: number
}
export interface IncomeStatementReport {
revenue_sections: IncomeStatementSection[]
total_revenue: number
expense_sections: IncomeStatementSection[]
total_expenses: number
financial_sections: IncomeStatementSection[]
total_financial: number
net_result: number
period: { start: string; end: string }
}
export interface BalanceSheetSection {
title: string
rows: { account_number: string; account_name: string; amount: number }[]
subtotal: number
}
export interface BalanceSheetReport {
asset_sections: BalanceSheetSection[]
total_assets: number
equity_liability_sections: BalanceSheetSection[]
total_equity_liabilities: number
period: { start: string; end: string }
}
export interface ResultatrapportRow {
account_number: string
account_name: string
current_period: number
prior_period: number
}
export interface ResultatrapportGroup {
class: number
class_label: string
rows: ResultatrapportRow[]
subtotal_current: number
subtotal_prior: number
}
export interface ResultatrapportReport {
groups: ResultatrapportGroup[]
net_result_current: number
net_result_prior: number
period: { start: string; end: string }
prior_period: { start: string; end: string } | null
}
export interface BalansrapportRow {
account_number: string
account_name: string
ib: number
ub: number
period_change: number
}
export interface BalansrapportGroup {
class: number
class_label: string
rows: BalansrapportRow[]
subtotal_ib: number
subtotal_ub: number
}
export interface BalansrapportReport {
groups: BalansrapportGroup[]
total_assets_ub: number
total_equity_liabilities_ub: number
beraknat_resultat: number
is_balanced: boolean
period: { start: string; end: string }
}
export interface SIEExportOptions {
fiscal_period_id: string
company_name: string
org_number: string | null
program_name?: string
/**
* When true, omit year-end closing verifikat (source_type = 'year_end')
* from #VER and from #RES/#UB calculations. Use when handing the file
* to systems (e.g. eDeklarera) that do their own closing — including
* our closing entry would zero out the P&L accounts.
*/
exclude_year_end_closing?: boolean
}
// Input types for creating entries
export interface CreateJournalEntryInput {
fiscal_period_id: string
entry_date: string
description: string
source_type: JournalEntrySourceType
source_id?: string
voucher_series?: string
notes?: string
lines: CreateJournalEntryLineInput[]
}
export interface CreateJournalEntryLineInput {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
currency?: string
amount_in_currency?: number
exchange_rate?: number
tax_code?: string
cost_center?: string
project?: string
}
export interface CreateFiscalPeriodInput {
name: string
period_start: string
period_end: string
}
// ── Pending Operations ────────────────────────────────────────
export type PendingOperationType =
| 'categorize_transaction'
| 'create_customer'
| 'create_supplier'
| 'create_invoice'
| 'mark_invoice_paid'
| 'send_invoice'
| 'mark_invoice_sent'
| 'match_transaction_invoice'
// Stream 1 Phase 1: bookkeeping period operations
| 'close_period'
| 'lock_period'
| 'unlock_period'
| 'set_opening_balances'
| 'run_year_end'
| 'run_currency_revaluation'
// Stream 1 Phase 1: SIE import (export is read-only)
| 'import_sie'
// Stream 1 Phase 1: voucher gap explanations
| 'explain_voucher_gap'
// Stream 1 Phase 1: transaction reversal
| 'uncategorize_transaction'
// Document inbox: pin doc to bank transaction
| 'attach_document_to_transaction'
// Manual transaction ingestion (uncategorized row, reversible by delete)
| 'create_transaction'
// Stream 1 Phase 1: supplier invoice lifecycle
| 'approve_supplier_invoice'
| 'credit_supplier_invoice'
// Phase 5: convert an OCR'd inbox item to a leverantörsfaktura + registration JE
| 'create_supplier_invoice_from_inbox'
// Stream 1 Phase 1: invoice operations beyond simple create/send
| 'credit_invoice'
| 'convert_invoice'
// Phase 4: arbitrary-line bookkeeping primitives
| 'create_voucher'
| 'correct_entry'
// Pure makulering (storno) of a posted entry — agent-native API plan item 38
| 'reverse_entry'
// Bokslut: planenlig avskrivning (one journal entry per asset)
| 'post_annual_depreciation'
// Payroll: salary run creation + AGI declaration
| 'create_salary_run'
| 'generate_agi'
export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected'
export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'
export type PendingOperationRiskLevel = 'low' | 'medium' | 'high'
export interface PendingOperationAgentMetadata {
conversation_id?: string
intent_id?: string
model?: string
model_version?: string
prompt_hash?: string
atoms_loaded?: string[]
approved_by_user_id?: string
}
export type PendingOperationRejectionCategory =
| 'wrong_category'
| 'wrong_amount'
| 'duplicate'
| 'wrong_period'
| 'other'
export interface PendingOperation {
id: string
user_id: string
company_id: string
operation_type: PendingOperationType
status: PendingOperationStatus
title: string
params: Record<string, unknown>
preview_data: Record<string, unknown>
result_data: Record<string, unknown> | null
// Stream 2 Phase 1: actor model
actor_type: PendingOperationActorType
actor_id: string | null
actor_label: string | null
risk_level: PendingOperationRiskLevel
// Stream 2 Phase 3: agent provenance (populated by chat loop, NULL for user-staged)
agent_metadata: PendingOperationAgentMetadata | null
// Stream 2 Phase 4: structured rejection so the agent can learn from "no"
rejection_category: PendingOperationRejectionCategory | null
rejection_reason: string | null
created_at: string
resolved_at: string | null
updated_at: string
}
// Onboarding progress for new user checklist
export interface OnboardingProgress {
hasCustomers: boolean
hasInvoices: boolean
hasBankConnected: boolean
hasSIEImport: boolean
/** True when the active user has a stored Skatteverket OAuth token. */
hasSkatteverketConnected: boolean
}
// Onboarding step data
export interface OnboardingStepData {
step1?: {
entity_type: EntityType
}
step2?: {
company_name: string
org_number?: string
address_line1?: string
postal_code?: string
city?: string
}
step3?: {
f_skatt: boolean
fiscal_year_start_month: number
is_first_fiscal_year?: boolean
first_year_start?: string
first_year_end?: string
vat_registered: boolean
vat_number?: string
moms_period?: MomsPeriod
}
step4?: {
preliminary_tax_monthly?: number
}
step5?: {
bank_name?: string
clearing_number?: string
account_number?: string
iban?: string
bic?: string
}
step6?: {
bank_connected: boolean
bank_connection_id?: string
}
}
// ============================================================
// Calendar & Deadline Types
// ============================================================
// Calendar view mode
export type CalendarViewMode = 'month' | 'week' | 'day'
// Payment calendar day (for invoice due date tracking)
export interface PaymentCalendarDay {
date: string
invoices: Invoice[]
totalExpected: number
overdueCount: number
}
// Tax deadline types (Swedish Skatteverket)
export type TaxDeadlineType =
| 'moms_monthly'
| 'moms_quarterly'
| 'moms_yearly'
| 'f_skatt'
| 'arbetsgivardeklaration'
| 'inkomstdeklaration_ef'
| 'inkomstdeklaration_ab'
| 'arsredovisning'
| 'periodisk_sammanstallning'
| 'bokslut'
// Deadline status workflow
export type DeadlineStatus =
| 'upcoming' // More than 14 days away
| 'action_needed' // Within 14 days, needs attention
| 'in_progress' // User is working on it
| 'submitted' // Submitted to Skatteverket
| 'confirmed' // Confirmed/acknowledged
| 'overdue' // Past due date without submission
// Deadline source
export type DeadlineSource = 'system' | 'user'
// Deadline types
export type DeadlineType = 'delivery' | 'invoicing' | 'report' | 'tax' | 'other'
export type DeadlinePriority = 'critical' | 'important' | 'normal'
// Deadline record
export interface Deadline {
id: string
user_id: string
company_id: string
title: string
due_date: string
due_time: string | null
deadline_type: DeadlineType
priority: DeadlinePriority
is_completed: boolean
completed_at: string | null
customer_id: string | null
is_auto_generated: boolean
notes: string | null
created_at: string
updated_at: string
// Tax deadline fields
tax_deadline_type: TaxDeadlineType | null
tax_period: string | null
source: DeadlineSource
reminder_offsets: number[] | null
status: DeadlineStatus
status_changed_at: string
linked_report_type: string | null
linked_report_period: Record<string, unknown> | null
// Relations
customer?: Customer
}
// Input for creating a deadline
export interface CreateDeadlineInput {
title: string
due_date: string
due_time?: string
deadline_type: DeadlineType
priority?: DeadlinePriority
customer_id?: string
notes?: string
// Tax deadline fields
tax_deadline_type?: TaxDeadlineType
tax_period?: string
source?: DeadlineSource
linked_report_type?: string
linked_report_period?: Record<string, unknown>
}
// ============================================================
// Push Notification Types
// ============================================================
// Push subscription for Web Push API
export interface PushSubscription {
id: string
user_id: string
endpoint: string
p256dh: string
auth: string
user_agent: string | null
is_active: boolean
last_used_at: string | null
created_at: string
}
// Notification settings per user
export interface NotificationSettings {
id: string
user_id: string
tax_deadlines_enabled: boolean
invoice_reminders_enabled: boolean
quiet_start: string // time format "HH:MM"
quiet_end: string // time format "HH:MM"
email_enabled: boolean
push_enabled: boolean
period_locked_enabled: boolean
period_year_closed_enabled: boolean
invoice_sent_enabled: boolean
receipt_extracted_enabled: boolean
receipt_matched_enabled: boolean
created_at: string
updated_at: string
}
// Notification type for logging
export type NotificationType =
| 'tax_deadline'
| 'invoice_due'
| 'invoice_overdue'
| 'period_locked'
| 'period_year_closed'
| 'receipt_extracted'
| 'receipt_matched'
| 'invoice_sent'
| 'missing_underlag'
// Notification log entry
export interface NotificationLog {
id: string
user_id: string
company_id: string | null
notification_type: NotificationType
reference_id: string
days_before: number
sent_at: string
delivery_status: 'sent' | 'delivered' | 'failed'
}
// ============================================================
// Calendar Feed Types (ICS)
// ============================================================
// Calendar feed for Apple Calendar / Google Calendar sync
export interface CalendarFeed {
id: string
user_id: string
company_id: string
feed_token: string
is_active: boolean
include_tax_deadlines: boolean
include_invoices: boolean
last_accessed_at: string | null
access_count: number
created_at: string
updated_at: string
}
// Input for creating/updating calendar feed
export interface UpdateCalendarFeedInput {
include_tax_deadlines?: boolean
include_invoices?: boolean
}
// Swedish labels for deadline status
export const DEADLINE_STATUS_LABELS: Record<DeadlineStatus, string> = {
upcoming: 'Kommande',
action_needed: 'Åtgärd krävs',
in_progress: 'Pågår',
submitted: 'Inskickad',
confirmed: 'Bekräftad',
overdue: 'Försenad'
}
// Swedish labels for tax deadline types
export const TAX_DEADLINE_TYPE_LABELS: Record<TaxDeadlineType, string> = {
moms_monthly: 'Momsdeklaration (månad)',
moms_quarterly: 'Momsdeklaration (kvartal)',
moms_yearly: 'Momsdeklaration (år)',
f_skatt: 'F-skatt',
arbetsgivardeklaration: 'Arbetsgivardeklaration',
inkomstdeklaration_ef: 'Inkomstdeklaration EF',
inkomstdeklaration_ab: 'Inkomstdeklaration AB',
arsredovisning: 'Årsredovisning',
periodisk_sammanstallning: 'Periodisk sammanställning',
bokslut: 'Bokslut'
}
// ============================================================
// SIE Import Types
// ============================================================
// SIE import status
export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed'
// SIE import record
export interface SIEImport {
id: string
user_id: string
company_id: string
filename: string
file_hash: string
org_number: string | null
company_name: string | null
sie_type: number
fiscal_year_start: string | null
fiscal_year_end: string | null
accounts_count: number
transactions_count: number
opening_balance_total: number | null
status: SIEImportStatus
error_message: string | null
fiscal_period_id: string | null
opening_balance_entry_id: string | null
imported_at: string | null
created_at: string
updated_at: string
}
// SIE account mapping record
export interface SIEAccountMapping {
id: string
user_id: string
company_id: string
source_account: string
source_name: string | null
target_account: string
confidence: number
match_type: 'exact' | 'name' | 'class' | 'manual'
created_at: string
updated_at: string
}
// ============================================================
// Invoice Inbox Types
// ============================================================
export type InboxItemStatus = 'received' | 'error'
export type InboxItemSource = 'email' | 'upload'
export type CompanyInboxStatus = 'active' | 'deprecated' | 'blocked'
export interface CompanyInbox {
id: string
company_id: string
local_part: string
status: CompanyInboxStatus
slug_seed: string
created_at: string
updated_at: string
deprecated_at: string | null
}
export interface InvoiceInboxItem {
id: string
user_id: string
company_id: string
status: InboxItemStatus
source: InboxItemSource
email_from: string | null
email_subject: string | null
email_received_at: string | null
email_body_text: string | null
resend_email_id: string | null
resend_attachment_id: string | null
document_id: string | null
extracted_data: Record<string, unknown> | null
matched_supplier_id: string | null
created_supplier_invoice_id: string | null
matched_transaction_id: string | null
created_journal_entry_id: string | null
error_message: string | null
raw_email_payload: Record<string, unknown> | null
// Audit chain (processing_history correlation)
correlation_id: string | null
created_at: string
updated_at: string
// Relations (populated when fetched)
document?: DocumentAttachment
supplier?: Supplier
supplier_invoice?: SupplierInvoice
}
// ============================================================
// Receipt Types
// ============================================================
// Receipt extraction status
export type ReceiptStatus = 'pending' | 'processing' | 'extracted' | 'confirmed' | 'error'
// Receipt record
export interface Receipt {
id: string
user_id: string
company_id: string
// Image storage
image_url: string
image_thumbnail_url: string | null
// Extraction status
status: ReceiptStatus
extraction_confidence: number | null
// Extracted header data
merchant_name: string | null
merchant_org_number: string | null
merchant_vat_number: string | null
receipt_date: string | null
receipt_time: string | null
total_amount: number | null
currency: string
vat_amount: number | null
// Special flags
is_restaurant: boolean
is_systembolaget: boolean
is_foreign_merchant: boolean
// Restaurant representation data
representation_persons: number | null
representation_purpose: string | null
representation_business_connection: string | null
// Source tracking (for email-originated receipts)
source: 'upload' | 'camera' | 'email'
email_from: string | null
// Transaction matching
matched_transaction_id: string | null
match_confidence: number | null
// Raw extraction data
raw_extraction: ReceiptExtractionResult | null
created_at: string
updated_at: string
// Relations (populated when fetched)
line_items?: ReceiptLineItem[]
matched_transaction?: Transaction
}
// Receipt line item record
export interface ReceiptLineItem {
id: string
receipt_id: string
// Extracted data
description: string
quantity: number
unit_price: number | null
line_total: number
vat_rate: number | null
vat_amount: number | null
// Classification
is_business: boolean | null
category: TransactionCategory | null
bas_account: string | null
// Confidence
extraction_confidence: number | null
suggested_category: string | null
sort_order: number
created_at: string
}
// AI extraction result from Claude Vision
export interface ReceiptExtractionResult {
merchant: {
name: string | null
orgNumber: string | null
vatNumber: string | null
isForeign: boolean
}
receipt: {
date: string | null
time: string | null
currency: string
}
lineItems: ExtractedLineItem[]
totals: {
subtotal: number | null
vatAmount: number | null
total: number | null
}
flags: {
isRestaurant: boolean
isSystembolaget: boolean
isForeignMerchant: boolean
}
confidence: number
suggestedTemplateId?: string
}
// Extracted line item from AI
export interface ExtractedLineItem {
description: string
quantity: number
unitPrice: number | null
lineTotal: number
vatRate: number | null
suggestedCategory: string | null
suggestedTemplateId?: string
confidence?: number
}
// Match candidate for receipt-to-transaction matching
export interface ReceiptMatchCandidate {
transaction: Transaction
confidence: number
matchReasons: string[]
dateVariance: number
amountVariance: number
}
// Input for creating a receipt
export interface CreateReceiptInput {
image_url: string
image_thumbnail_url?: string
}
// Input for confirming receipt line items
export interface ConfirmReceiptInput {
line_items: ConfirmLineItemInput[]
matched_transaction_id?: string
representation_persons?: number
representation_purpose?: string
}
export interface ConfirmLineItemInput {
id: string
is_business: boolean
category?: TransactionCategory
bas_account?: string
}
// Receipt queue summary
export interface ReceiptQueueSummary {
unmatched_receipts_count: number
unmatched_transactions_count: number
pending_review_count: number
streak_count: number
}
// Camera quality feedback
export interface CameraQualityFeedback {
lightingOk: boolean
distanceOk: boolean
focusOk: boolean
readyToCapture: boolean
message?: string
}
// Swedish labels for receipt status
export const RECEIPT_STATUS_LABELS: Record<ReceiptStatus, string> = {
pending: 'Väntar',
processing: 'Analyserar',
extracted: 'Extraherat',
confirmed: 'Bekräftat',
error: 'Fel'
}
// ============================================================
// VAT Declaration Types (Momsdeklaration)
// ============================================================
// VAT period type
export type VatPeriodType = 'monthly' | 'quarterly' | 'yearly'
// VAT declaration rutor (boxes) according to SKV 4700
// Complete set of all 30 boxes in the momsdeklaration form.
export interface VatDeclarationRutor {
// Momspliktig försäljning (taxable sales basis, all rates combined)
ruta05: number // Momspliktig försäljning (excl. ruta 06, 07, 08)
ruta06: number // Momspliktiga uttag (always 0 for most users)
ruta07: number // Vinstmarginalbeskattning (always 0 for most users)
ruta08: number // Hyresinkomster frivillig beskattning (always 0 for most users)
// Utgående moms (Output VAT per rate)
ruta10: number // Utgående moms 25%
ruta11: number // Utgående moms 12%
ruta12: number // Utgående moms 6%
// Inköp vid omvänd skattskyldighet (reverse charge purchase bases)
ruta20: number // Inköp av varor från annat EU-land
ruta21: number // Inköp av tjänster från annat EU-land
ruta22: number // Inköp av tjänster från land utanför EU
ruta23: number // Inköp av varor i Sverige (construction reverse charge goods)
ruta24: number // Övriga inköp av tjänster i Sverige (domestic reverse charge)
// Utgående moms omvänd skattskyldighet (self-assessed output VAT on reverse charge)
ruta30: number // Utgående moms 25% omvänd skattskyldighet
ruta31: number // Utgående moms 12% omvänd skattskyldighet
ruta32: number // Utgående moms 6% omvänd skattskyldighet
// EU och export försäljning
ruta35: number // Varuförsäljning till annat EU-land
ruta36: number // Varuförsäljning utanför EU (export)
ruta37: number // Mellanmans inköp vid trepartshandel
ruta38: number // Mellanmans försäljning vid trepartshandel
ruta39: number // Försäljning av tjänster till annat EU-land (reverse charge)
ruta40: number // Övrig försäljning av tjänster utomlands
ruta41: number // Försäljning med omvänd skattskyldighet (Sverige)
ruta42: number // Övrig momsfri försäljning m.m.
// Ingående moms (Input VAT)
ruta48: number // Ingående moms att dra av
// Moms att betala eller få tillbaka
ruta49: number // Moms att betala (positive) eller återfå (negative)
// Import (via Tullverket)
ruta50: number // Beskattningsunderlag vid import
ruta60: number // Utgående moms 25% import
ruta61: number // Utgående moms 12% import
ruta62: number // Utgående moms 6% import
}
// VAT declaration response
export interface VatDeclaration {
period: {
type: VatPeriodType
year: number
period: number // 1-12 for monthly, 1-4 for quarterly, 1 for yearly
start: string // YYYY-MM-DD
end: string // YYYY-MM-DD
}
rutor: VatDeclarationRutor
// Supporting data
invoiceCount: number
transactionCount: number
// Breakdown by source
breakdown: {
invoices: {
ruta05: number
ruta06: number
ruta07: number
ruta10: number
ruta11: number
ruta12: number
ruta39: number
ruta40: number
// Per-rate base amounts for UI display
base25: number
base12: number
base6: number
}
transactions: {
ruta48: number // Ingående moms from categorized expenses
}
receipts: {
ruta48: number // Ingående moms from receipts
}
reverseCharge: {
ruta20: number
ruta21: number
ruta22: number
ruta23: number
ruta24: number
ruta30: number
ruta31: number
ruta32: number
}
}
}
// VAT declaration request parameters
export interface VatDeclarationRequest {
periodType: VatPeriodType
year: number
period: number
}
// Labels for VAT rutor
export const VAT_RUTA_LABELS: Record<keyof VatDeclarationRutor, string> = {
ruta05: 'Momspliktig försäljning',
ruta06: 'Momspliktiga uttag',
ruta07: 'Vinstmarginalbeskattning',
ruta08: 'Hyresinkomster (frivillig beskattning)',
ruta10: 'Utgående moms 25%',
ruta11: 'Utgående moms 12%',
ruta12: 'Utgående moms 6%',
ruta20: 'Inköp av varor från annat EU-land',
ruta21: 'Inköp av tjänster från annat EU-land',
ruta22: 'Inköp av tjänster från land utanför EU',
ruta23: 'Inköp av varor i Sverige',
ruta24: 'Övriga inköp av tjänster i Sverige',
ruta30: 'Utgående moms 25% (omvänd skattskyldighet)',
ruta31: 'Utgående moms 12% (omvänd skattskyldighet)',
ruta32: 'Utgående moms 6% (omvänd skattskyldighet)',
ruta35: 'Varuförsäljning till annat EU-land',
ruta36: 'Varuförsäljning utanför EU (export)',
ruta37: 'Mellanmans inköp vid trepartshandel',
ruta38: 'Mellanmans försäljning vid trepartshandel',
ruta39: 'Försäljning av tjänster till EU-land',
ruta40: 'Övrig försäljning av tjänster utomlands',
ruta41: 'Försäljning med omvänd skattskyldighet (Sverige)',
ruta42: 'Övrig momsfri försäljning m.m.',
ruta48: 'Ingående moms att dra av',
ruta49: 'Moms att betala/återfå',
ruta50: 'Beskattningsunderlag vid import',
ruta60: 'Utgående moms 25% import',
ruta61: 'Utgående moms 12% import',
ruta62: 'Utgående moms 6% import',
}
// ============================================================
// Event Payload Placeholder Types
// ============================================================
/** Credit note is an invoice with a credited_invoice_id */
export interface CreditNote extends Invoice {
credited_invoice_id: string
}
/** Generic key-value store record for extensions */
export interface ExtensionDataRecord {
id: string
user_id: string
company_id: string
extension_id: string
key: string
value: Record<string, unknown>
created_at: string
updated_at: string
}
// ============================================================
// Tax Code Types
// ============================================================
// Tax code identifiers (standard Swedish codes)
export type TaxCodeId =
| 'MP1' | 'MP2' | 'MP3' // Output VAT 25%, 12%, 6%
| 'MPI' | 'MPI12' | 'MPI6' // Input VAT 25%, 12%, 6%
| 'IV' // Intra-EU acquisition
| 'EUS' // EU sale (reverse charge)
| 'IP' // Import
| 'EXP' // Export outside EU
| 'OSS' // One Stop Shop
| 'NONE' // VAT exempt
export interface TaxCode {
id: string
user_id: string | null
code: string
description: string
rate: number
moms_basis_boxes: string[]
moms_tax_boxes: string[]
moms_input_boxes: string[]
is_output_vat: boolean
is_reverse_charge: boolean
is_eu: boolean
is_export: boolean
is_oss: boolean
is_system: boolean
created_at: string
updated_at: string
}
// ============================================================
// Document Archive Types
// ============================================================
export type DocumentUploadSource =
| 'camera'
| 'file_upload'
| 'email'
| 'e_invoice'
| 'scan'
| 'api'
| 'system'
export interface DocumentAttachment {
id: string
user_id: string
company_id: string
storage_path: string
file_name: string
file_size_bytes: number | null
mime_type: string | null
sha256_hash: string
version: number
original_id: string | null
superseded_by_id: string | null
is_current_version: boolean
uploaded_by: string | null
upload_source: DocumentUploadSource | null
digitization_date: string | null
journal_entry_id: string | null
journal_entry_line_id: string | null
prev_version_hash: string | null
last_integrity_check_at: string | null
created_at: string
updated_at: string
}
export interface CreateDocumentAttachmentInput {
storage_path: string
file_name: string
file_size_bytes?: number
mime_type?: string
sha256_hash: string
upload_source?: DocumentUploadSource
journal_entry_id?: string
journal_entry_line_id?: string
}
// ============================================================
// Audit Log Types
// ============================================================
export type AuditAction =
| 'INSERT'
| 'UPDATE'
| 'DELETE'
| 'COMMIT'
| 'REVERSE'
| 'CORRECT'
| 'LOCK_PERIOD'
| 'CLOSE_PERIOD'
| 'DOCUMENT_DELETE_BLOCKED'
| 'RETENTION_BLOCK'
| 'SECURITY_EVENT'
| 'INTEGRITY_FAILURE'
export interface AuditLogEntry {
id: string
user_id: string
company_id: string | null
action: AuditAction
table_name: string | null
record_id: string | null
actor_id: string | null
actor_type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'system' | null
actor_label: string | null
old_state: Record<string, unknown> | null
new_state: Record<string, unknown> | null
description: string | null
created_at: string
}
// ============================================================
// Dimension Types (Kostnadsställen & Projekt)
// ============================================================
export interface CostCenter {
id: string
company_id: string
code: string
name: string
is_active: boolean
created_at: string
updated_at: string
}
export interface Project {
id: string
company_id: string
code: string
name: string
is_active: boolean
start_date: string | null
end_date: string | null
created_at: string
updated_at: string
}
// ============================================================
// Voucher Gap Detection
// ============================================================
export interface VoucherGap {
gap_start: number
gap_end: number
series: string
}
export interface VoucherGapExplanation {
id: string
company_id: string
user_id: string
fiscal_period_id: string
voucher_series: string
gap_start: number
gap_end: number
explanation: string
created_at: string
updated_at: string
}
export interface SequenceMismatch {
series: string
sequenceCounter: number
actualMax: number
}
// ============================================================
// Year-End Closing Types (Årsbokslut)
// ============================================================
export interface YearEndValidation {
ready: boolean
errors: string[]
warnings: string[]
draftCount: number
voucherGaps: VoucherGap[]
unexplainedGaps: VoucherGap[]
sequenceMismatches: SequenceMismatch[]
trialBalanceBalanced: boolean
}
export interface YearEndPreview {
netResult: number
closingAccount: string
closingAccountName: string
closingLines: CreateJournalEntryLineInput[]
resultAccountSummary: { account_number: string; account_name: string; amount: number }[]
currencyRevaluation: CurrencyRevaluationPreview | null
}
export interface YearEndResult {
closingEntry: JournalEntry
nextPeriod: FiscalPeriod
openingBalanceEntry: JournalEntry
revaluationEntry: JournalEntry | null
/**
* IB/UB reconciliation per balance sheet account, computed after the
* opening balances are posted. Surfaced to the UI's ResultStep so the
* user can verify continuity before navigating away. Always within
* ORE_TOLERANCE — otherwise executeYearEndClosing would have thrown.
*/
continuity?: ContinuityCheckResult
}
// ============================================================
// Asset Register Types (Anläggningsregister)
// ============================================================
export type AssetCategory =
| 'immaterial'
| 'building'
| 'land_improvement'
| 'machinery'
| 'equipment'
| 'vehicle'
| 'computer'
| 'other_tangible'
export type DepreciationMethod =
| 'linear'
| 'declining_balance_30'
| 'declining_balance_20'
| 'restvardesavskrivning_25'
/**
* K3 component (BFNAR 2012:1 ch 17.4 — komponentavskrivning). When a
* substantial asset (typically real estate) has significant components with
* materially different useful lives, K3 reporting requires each component to
* be depreciated on its own life rather than treating the asset as a single
* unit. Components are stored as an array on `Asset.k3_components`; when
* non-null, the depreciation engine routes through `computeComponentDepreciation`
* and sums per-component linear depreciation (with the same pro-ration logic
* as the asset-level linear method).
*
* Validation (enforced in `lib/bokslut/assets/k3-components.ts`):
* - sum(components.cost) === asset.acquisition_cost (±1 kr tolerance)
* - every component: cost > 0, useful_life_months > 0
* - salvage_value (if present) ≤ component cost
* - non-empty array when set to non-null
*
* Salvage_value defaults to 0 when omitted.
*/
export interface K3Component {
name: string
cost: number
useful_life_months: number
salvage_value?: number
}
export interface Asset {
id: string
user_id: string
company_id: string
name: string
category: AssetCategory
acquisition_date: string
acquisition_cost: number
salvage_value: number
useful_life_months: number
depreciation_method: DepreciationMethod
bas_asset_account: string
bas_accumulated_account: string
bas_expense_account: string
/** Book-value floor for restvärdeavskrivning (IL 18 kap 13§ st.3). Required
* iff depreciation_method = 'restvardesavskrivning_25'; null otherwise. */
restvarde_target: number | null
disposed_at: string | null
disposed_proceeds: number | null
/** Output VAT on disposal proceeds (ML 3 kap 3 § / 7 kap 3 §). Defaults to
* 0 — only nonzero when the sale was momspliktig. The VAT account
* (2611/2621/2631) is derived from disposed_vat_treatment. */
disposed_proceeds_vat: number
/** VAT treatment applied to disposal proceeds. Null for legacy disposals
* without VAT data. Constrained by DB CHECK to the same enum as
* VatTreatment. */
disposed_vat_treatment: VatTreatment | null
/** Jämkning amount per ML 8a kap 7 § — input VAT paid back on disposal
* inside the correction period. Defaults to 0; positive number = debt
* to the state booked on 2641 credit. */
jamkning_amount: number
/** Remaining months in the korrigeringstid at disposal date. Audit
* metadata only — the booking sits on the journal entry. */
jamkning_remaining_months: number | null
/** Total korrigeringstid in months: 60 (lös egendom) or 120 (fastighet /
* markanläggning). Audit metadata. */
jamkning_total_months: number | null
/** Original input VAT that was deducted at acquisition. Audit metadata
* the user supplies (or the system derives from the supplier invoice). */
jamkning_original_input_vat: number | null
/** K3 component depreciation (BFNAR 2012:1 ch.17.4). When non-null, the
* depreciation engine sums per-component linear depreciation instead of
* applying `depreciation_method` to the asset as a whole. Null for K2
* companies (the API rejects writes for accounting_framework='k2'). */
k3_components: K3Component[] | null
notes: string | null
created_at: string
updated_at: string
}
export interface DepreciationSchedule {
id: string
user_id: string
company_id: string
asset_id: string
fiscal_period_id: string
planned_depreciation: number
journal_entry_id: string | null
posted_at: string | null
created_at: string
updated_at: string
}
// ============================================================
// IB/UB Continuity Check Types (Avstämning ingående/utgående balans)
// ============================================================
export interface ContinuityDiscrepancy {
account_number: string
account_name: string
previous_ub_net: number
current_ib_net: number
difference: number
}
export interface ContinuityCheckResult {
valid: boolean
period_name: string
previous_period_name: string | null
discrepancies: ContinuityDiscrepancy[]
checked_accounts: number
}
// ============================================================
// Currency Revaluation Types (Omvärdering utländsk valuta)
// ============================================================
export interface RevaluationItem {
type: 'receivable' | 'payable'
source_id: string
reference: string
currency: Currency
amount_in_currency: number
original_rate: number
closing_rate: number
original_sek: number
closing_sek: number
difference_sek: number
}
export interface CurrencyRevaluationPreview {
items: RevaluationItem[]
lines: CreateJournalEntryLineInput[]
closingRates: Record<string, number>
totalGain: number
totalLoss: number
netEffect: number
}
export interface CurrencyRevaluationResult {
entry: JournalEntry
preview: CurrencyRevaluationPreview
}
export interface PeriodStatus {
is_locked: boolean
is_closed: boolean
has_closing_entry: boolean
has_opening_balances: boolean
draft_count: number
next_period_exists: boolean
}
// ============================================================
// Invoice Reminder Types (Betalningspåminnelser)
// ============================================================
// Response type from customer action
export type ReminderResponseType = 'marked_paid' | 'disputed'
// Invoice reminder record
export interface InvoiceReminder {
id: string
invoice_id: string
user_id: string
company_id: string
reminder_level: 1 | 2 | 3
sent_at: string
email_to: string
response_type: ReminderResponseType | null
response_at: string | null
action_token: string
action_token_used: boolean
created_at: string
// Dröjsmålsränta + lagstadgad påminnelseavgift (Räntelagen §6, Lag 1981:739)
interest_amount: number
interest_rate: number | null
interest_from_date: string | null
interest_days: number | null
reminder_fee: number
fee_journal_entry_id: string | null
}
// Swedish labels for reminder levels
export const REMINDER_LEVEL_LABELS: Record<1 | 2 | 3, string> = {
1: 'Vänlig påminnelse',
2: 'Andra påminnelsen',
3: 'Slutlig påminnelse'
}
// Reminder level descriptions
export const REMINDER_LEVEL_DESCRIPTIONS: Record<1 | 2 | 3, string> = {
1: '15 dagar efter förfallodatum',
2: '30 dagar efter förfallodatum',
3: '45 dagar efter förfallodatum'
}
// ============================================================
// Transaction Ingestion Types (re-exported for extension use)
// ============================================================
/** Normalized transaction input for the generic ingestion pipeline */
export interface RawTransaction {
date: string
description: string
amount: number
currency: string
external_id: string
mcc_code?: number | null
merchant_name?: string | null
reference?: string | null
bank_connection_id?: string | null
import_source?: string
/**
* Counterparty IBAN from PSD2 (creditor for outflows, debtor for inflows).
* Used by the own-account transfer detector — when this matches another
* cash_accounts row for the same company, both legs auto-book as a transfer.
*/
counterparty_iban?: string | null
/**
* Bankgiro / Plusgiro / BBAN fallback when no IBAN is available (typical
* for Swedish domestic transfers). Kept distinct from IBAN so matching
* doesn't accidentally collide BG numbers with IBAN strings.
*/
counterparty_account?: string | null
}
/** Options for the transaction ingestion pipeline */
export interface IngestOptions {
/** Skip auto-categorization (mapping engine + journal entry creation).
* Reconciliation and invoice matching still run.
* Used when SIE-imported entries overlap the sync date range
* to prevent double-booking. */
skipAutoCategorization?: boolean
/** Override the default settlement account (1930) for bank transactions.
* Used when importing to a secondary bank account (e.g., 1931). */
settlementAccount?: string
/** Only INSERT transactions + dedup. Skip reconciliation, invoice matching,
* supplier matching, and auto-categorization. For viewer imports. */
rawInsertOnly?: boolean
}
/** Result of the transaction ingestion pipeline */
export interface IngestResult {
imported: number
duplicates: number
reconciled: number
auto_categorized: number
auto_matched_invoices: number
errors: number
transaction_ids: string[]
/** First insert error encountered, surfaced for debugging. Optional. */
first_error?: { message: string; code?: string | null; details?: string | null; hint?: string | null }
}
// ── Invoice extraction (used by invoice-inbox extension and core utils) ──
export interface InvoiceExtractionResult {
supplier: {
name: string | null
orgNumber: string | null
vatNumber: string | null
address: string | null
bankgiro: string | null
plusgiro: string | null
}
invoice: {
invoiceNumber: string | null
invoiceDate: string | null
dueDate: string | null
paymentReference: string | null
currency: string
}
lineItems: ExtractedInvoiceLineItem[]
totals: {
subtotal: number | null
vatAmount: number | null
total: number | null
}
vatBreakdown: VatBreakdownItem[]
confidence: number
suggestedTemplateId?: string
}
export interface ExtractedInvoiceLineItem {
description: string
quantity: number
unitPrice: number | null
lineTotal: number
vatRate: number | null
accountSuggestion: string | null
suggestedTemplateId?: string
}
export interface VatBreakdownItem {
rate: number
base: number
amount: number
}
// KPI Report
export interface KPIReport {
netResult: number // SEK
cashPosition: number // SEK (sum of 19xx account balances)
outstandingReceivables: number // SEK
overdueReceivables: number // SEK
vatLiability: number // SEK, ruta 49 (positive = owe, negative = refund)
totalRevenue: number // SEK
totalExpenses: number // SEK
grossMargin: number | null // percentage, null if no revenue
expenseRatio: number | null // percentage, null if no revenue
avgPaymentDays: number | null // days, null if fewer than 5 paid invoices
periodComplete: boolean // whether selected period is closed/complete
months: { label: string; income: number; expenses: number; net: number }[]
period: { start: string; end: string }
expenseComposition: {
class4: number
class5: number
class6: number
class7: number
}
topSuppliers: { supplier_id: string; supplier_name: string; total: number }[]
}
export interface KPIPreferences {
visibleKpis: string[]
kpiOrder: string[]
accountOverrides: Record<string, string[]>
}
// ============================================================
// Salary Module Types (Lönehantering)
// ============================================================
export type EmploymentType = 'employee' | 'company_owner' | 'board_member'
export type SalaryType = 'monthly' | 'hourly'
export type FSkattStatus = 'a_skatt' | 'f_skatt' | 'fa_skatt' | 'not_verified'
export type VacationRule = 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning'
export type SalaryRunStatus = 'draft' | 'review' | 'approved' | 'paid' | 'booked' | 'corrected'
export type AGIStatus =
| 'generated' // XML built from a salary run; nothing sent to SKV yet
| 'pending_signature' // underlag accepted into Eget utrymme; awaiting BankID
| 'exported' // legacy: manual XML download path
| 'submitted' // kvittens received; AGI is filed
| 'accepted' // reserved (SKV does not currently expose this)
| 'rejected' // reserved (kontrollresultat DONE_REJECTED could land here)
export type SalaryLineItemType =
| 'monthly_salary' | 'hourly_salary'
| 'overtime' | 'overtime_50' | 'overtime_100'
| 'ob_weekday_evening' | 'ob_weekend' | 'ob_night' | 'ob_holiday'
| 'bonus' | 'commission'
| 'gross_deduction_pension' | 'gross_deduction_other'
| 'benefit_car' | 'benefit_housing' | 'benefit_meals' | 'benefit_wellness' | 'benefit_bike' | 'benefit_other'
| 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus'
| 'vab' | 'parental_leave' | 'vacation' | 'semesterersattning'
| 'traktamente_taxfree' | 'traktamente_taxable'
| 'mileage_taxfree' | 'mileage_taxable'
| 'net_deduction_advance' | 'net_deduction_union' | 'net_deduction_benefit_payment'
| 'net_deduction_other'
| 'correction' | 'other'
export type ShiftPremiumItemType =
| 'overtime_50' | 'overtime_100'
| 'ob_weekday_evening' | 'ob_weekend' | 'ob_night' | 'ob_holiday'
export interface ShiftPremiumRule {
id: string
company_id: string
name: string
applies_to_all_employees: boolean
applies_to_employee_ids: string[]
/** ISO weekday array: 1 = Monday … 7 = Sunday. */
day_of_week: number[]
/** 'HH:MM' or 'HH:MM:SS' (PostgreSQL TIME). */
start_time: string
/** 'HH:MM' or 'HH:MM:SS'. End values <= start mean the window wraps midnight. */
end_time: string
premium_percent: number
item_type: ShiftPremiumItemType
priority: number
is_active: boolean
created_at: string
updated_at: string
created_by: string | null
}
export interface Employee {
id: string
company_id: string
user_id: string
first_name: string
last_name: string
personnummer: string
personnummer_last4: string
employment_type: EmploymentType
employment_start: string
employment_end: string | null
employment_degree: number
salary_type: SalaryType
monthly_salary: number | null
hourly_rate: number | null
tax_table_number: number | null
tax_column: number
tax_municipality: string | null
jamkning_percentage: number | null
jamkning_valid_from: string | null
jamkning_valid_to: string | null
is_sidoinkomst: boolean
f_skatt_status: FSkattStatus
f_skatt_verified_at: string | null
clearing_number: string | null
bank_account_number: string | null
vacation_rule: VacationRule
vacation_days_per_year: number
vacation_days_saved: number
semestertillagg_rate: number
email: string | null
phone: string | null
address_line1: string | null
postal_code: string | null
city: string | null
specification_number: number | null
vaxa_stod_eligible: boolean
vaxa_stod_start: string | null
vaxa_stod_end: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface SalaryRun {
id: string
company_id: string
user_id: string
period_year: number
period_month: number
payment_date: string
status: SalaryRunStatus
voucher_series: string
total_gross: number
total_tax: number
total_net: number
total_avgifter: number
total_vacation_accrual: number
total_employer_cost: number
salary_entry_id: string | null
avgifter_entry_id: string | null
vacation_entry_id: string | null
agi_generated_at: string | null
agi_submitted_at: string | null
payment_file_format: 'bg_lb' | 'pain001' | null
payment_file_generated_at: string | null
calculation_params: Record<string, unknown> | null
approved_by: string | null
approved_at: string | null
paid_at: string | null
booked_at: string | null
booked_by: string | null
notes: string | null
is_correction: boolean
corrects_run_id: string | null
created_at: string
updated_at: string
// Relations
employees?: SalaryRunEmployee[]
}
export interface SalaryRunEmployee {
id: string
salary_run_id: string
employee_id: string
company_id: string
employment_degree: number
monthly_salary: number
salary_type: string
hours_worked: number | null
gross_salary: number
gross_deductions: number
benefit_values: number
taxable_income: number
tax_withheld: number
net_deductions: number
net_salary: number
avgifter_rate: number
avgifter_amount: number
avgifter_basis: number
vacation_accrual: number
vacation_accrual_avgifter: number
tax_table_number: number | null
tax_column: number | null
tax_table_year: number | null
sick_days: number
vab_days: number
parental_days: number
vacation_days_taken: number
calculation_breakdown: Record<string, unknown> | null
ytd_gross: number
ytd_tax: number
ytd_net: number
created_at: string
updated_at: string
// Relations
employee?: Employee
line_items?: SalaryLineItem[]
}
export interface SalaryLineItem {
id: string
salary_run_employee_id: string
company_id: string
item_type: SalaryLineItemType
description: string
quantity: number | null
unit_price: number | null
amount: number
is_taxable: boolean
is_avgift_basis: boolean
is_vacation_basis: boolean
is_gross_deduction: boolean
is_net_deduction: boolean
account_number: string | null
sort_order: number
created_at: string
updated_at: string
}
export interface AGIDeclaration {
id: string
company_id: string
user_id: string
salary_run_id: string | null
period_year: number
period_month: number
xml_content: string
status: AGIStatus
individuppgifter: Record<string, unknown>[]
total_gross: number
total_tax: number
total_avgifter_basis: number
total_avgifter: number
employee_count: number
kvittensnummer: string | null
submitted_at: string | null
submitted_by: string | null
response_data: Record<string, unknown> | null
is_correction: boolean
corrects_agi_id: string | null
created_at: string
updated_at: string
}