Files
accounted/types/index.ts
T
Mattsson c0818bb2d2 feat(sales-orders): kundorder with partial delivery and partial invoicing (#2166)
* feat(sales-orders): kundorder with partial delivery and partial invoicing

Adds sales orders (kundorder) as their own non-ledger document between
agreement and invoice, for companies that deliver or invoice in parts.

Schema (20260902130000): sales_orders + sales_order_items with RLS via
user_company_ids(), OR-<n> numbering RPC (membership-gated, no anon
execute), company_settings.sales_orders_enabled UI gate, and back-links
invoices.sales_order_id / invoice_items.sales_order_item_id. The invoiced
quantity per order line is DERIVED from the linked invoice lines on
non-cancelled, non-credited invoices and enforced by a BEFORE trigger, so
no counter can drift and a credited invoice frees its quantity. Header
status is draft / confirmed / completed / cancelled; completion is kept
by DB triggers from the same derived quantity. Delivery and invoicing
progress are derived per line, never stored as status.

Service + API: lib/sales-orders (create/update with id-preserving line
replace, transitions with compare-and-set, cumulative delivery
registration, invoice-from-order through buildInvoiceWriteData so
booking stays in the engine, proforma -> order conversion), routes under
/api/sales-orders and /api/invoices/[id]/convert-to-order, structured
SALES_ORDER_* error codes, archive classification of the new tables.
The invoice editor round-trips sales_order_item_id so a draft edit
cannot drop the link; GET /api/invoices gains ?sales_order_id=.

UI: /sales-orders list, create/edit form reusing the invoice line
conventions, detail with deliver and create-invoice dialogs and linked
invoices; nav row behind the settings toggle; the webshop row is
relabelled webshop_orders; "Skapa order" on proformas.

MCP (20260902141000/141001): list/get reads plus four staged writes
(create, transition, register delivery, create invoice from order) whose
executors call the lib services; op types added to the pending
operations CHECK.

Tests: route tests for every route (401/400/404/happy), service unit
tests, executor and tool tests, and tests/pg/sales-orders.pg.test.ts
(16 cases, green on staging) covering RLS, numbering guards, the
over-invoice trigger incl. release on cancel/credit and cross-company
refusal, the quantity floor, and completion maintenance.

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

* fix(sales-orders): harden kundorder after skeptic and security review

Resolves every finding from the PR #2166 review pass in one batch.

Order link integrity: replaceInvoiceItems now refuses a line set that
drops an existing sales_order_item_id (INVOICE_UPDATE_DROPS_ORDER_LINK),
closing the MCP update_invoice header-only edit and the v1 PATCH path
that severed the link and freed the quantity for double invoicing. The
update_invoice re-fetch, gnubok_get_invoice and the v1 item projection
now carry sales_order_item_id so well-behaved clients round-trip it.

Quantity math: derived remaining/invoiced quantities are rounded to six
decimals and compared with an epsilon (roundQty, qtyGreater) so a float
remainder such as 0.5999999999999996 can neither refuse the final partial
invoice nor land as an invoice quantity; duplicate explicit picks are
summed before validation.

Leveransdatum: per-line last_delivery_date (migration 20260902160000);
an invoice takes the latest date over the lines it covers and only when
the covered quantity was delivered, never the header date and never for
an advance invoice (ML 17 kap 24 p.7, FX anchor per ML 8 kap 21-23).

VAT drift: the order stores the customer type and VAT-validation flag its
lines were priced under; invoicing refuses with
SALES_ORDER_CUSTOMER_VAT_CHANGED when they differ, and re-saving the
order re-validates the lines. Customer and currency are frozen once
invoices exist.

Tenant and role gates: composite FK (sales_order_id, company_id) ties a
line to its parent's company (Superagent P2); aa_enforce_company_writer_role
on both tables so a viewer cannot write through the browser client.

Proforma -> order refuses proformas with ROT/RUT, periodisering or
negative-quantity lines instead of dropping those fields. RESTRICT FK
errors on delete map to SALES_ORDER_LINE_LOCKED / SALES_ORDER_HAS_INVOICES.

Also: schema-guard literal payloads in lib/sales-orders (ceiling +2 with
reason), regenerated skills/accounted-api (sales_order_item_id on invoice
items), pg tests for the composite FK, the viewer gate and the new
columns, unit tests for every changed path.

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

* fix(sales-orders): resolve CodeRabbit round on PR #2166

Quick wins from the review, all in one pass:

- replaceInvoiceItems fails closed when the invoice_items snapshot cannot
  be read (it is both the restore source and the input to the kundorder
  link guard); the guard branch is explicit in both PATCH routes.
- Cumulative delivery registration carries an optimistic predicate on the
  quantity it read, so two concurrent registrations cannot regress each
  other; DELETE of an order keeps its allowed status in the predicate and
  answers a conflict when zero rows match.
- Business dates (order date, delivery date, invoice date) default to the
  Europe/Stockholm calendar day (todayIsoStockholm), never UTC: the
  delivery date is also the Riksbanken rate anchor.
- The invoice-from-order executor treats an event emit failure as
  non-blocking: the draft already exists.
- sales_order_items are archived through their parent with the order
  currency denormalised, like invoice_items.
- Proforma "Skapa order" tolerates a 2xx without a parsable body; the
  settings toggle refreshes the server-rendered nav.
- List route doc states that q matches the order number (customer names
  are matched client-side).

Declined (out of scope for this PR): moving header + line writes and the
delivery loop into transactional RPCs (same PostgREST pattern as the
invoice PATCH path, tracked as a follow-up), the MCP approval handler's
error message shape (pre-existing code outside this change), and the
docstring-coverage warning (no repo convention).

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

* fix(sales-orders): move hardening migration off a colliding version; archive contract; ceiling

- 20260902160000_sales_orders_hardening.sql collided with main's
  20260902160000_parties_substrate.sql after the third sync; renamed to
  20260902180000 and made idempotent (DROP ... IF EXISTS before each
  ADD CONSTRAINT) so a preview branch that applied it under the old
  version replays it cleanly. Staging's schema_migrations row renamed.
- sales_order_items goes back to a direct archive dump: the coverage
  contract (tests/pg/full-archive-coverage.pg.test.ts) requires it for a
  table with its own company_id; the currency lives on the parent order
  one file over, joined by sales_order_id.
- Scanner ceiling re-baselined after merging main (parties phase 1): 397.
- v1 PATCH test queues a real empty invoice_items snapshot now that
  replaceInvoiceItems fails closed on an unreadable one.

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

* fix(sales-orders): drop the composite FK before its unique index on replay

The idempotent guard in 20260902180000_sales_orders_hardening.sql dropped
the unique (id, company_id) before the FK that depends on its index, so
the preview branch replay (which had applied the file under its former
version) failed with SQLSTATE 2BP01. Order swapped; replay verified on
staging.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:14:49 +02:00

4263 lines
136 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 grouping). 'personal' teams are the implicit
// one-per-user grouping; 'byra' teams are ops-created accounting-firm
// tenants (WL-08) with invites, a brand, and cockpit access.
export interface Team {
id: string
name: string
kind: 'personal' | 'byra'
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
// Denormalised from company_settings onto the active company in the
// dashboard layout so context consumers (e.g. the settings rail) can tell
// whether the company is a registered employer without an extra fetch.
// Optional because it isn't a column on `companies`. #782
pays_salaries?: boolean
}
// 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
}
export const COMPANY_MIGRATION_RESET_COUNT_KEYS = [
'journal_entries',
'journal_entry_lines',
'committed_import_entries',
'transactions',
'fiscal_periods',
'documents',
'voucher_sequences',
'sie_imports',
'bank_file_imports',
'skattekonto_file_imports',
'customers',
'suppliers',
'invoices',
'supplier_invoices',
'bank_connections',
] as const
export type CompanyMigrationResetCountKey =
(typeof COMPANY_MIGRATION_RESET_COUNT_KEYS)[number]
export type CompanyMigrationResetBlockerCode =
| 'company_not_found'
| 'company_already_archived'
| 'migration_window_expired'
| 'sandbox_company'
| 'locked_or_closed_periods'
| 'authority_submission_detected'
| 'live_bank_connections'
| 'imports_in_progress'
| 'active_integrations_or_schedules'
| 'background_work_in_progress'
export interface CompanyMigrationResetBlocker {
code: CompanyMigrationResetBlockerCode
count: number
}
export interface CompanyMigrationResetEligibility {
eligible: boolean
display_name: string
created_at: string
window_ends_at: string
counts: Record<CompanyMigrationResetCountKey, number>
blockers: CompanyMigrationResetBlocker[]
}
export interface CompanyMigrationResetRpcResult {
ok: boolean
code?: string
details?: unknown
eligibility?: CompanyMigrationResetEligibility
reset_id?: string
source_company_id?: string
replacement_company_id?: string
archived_at?: string
counts?: CompanyMigrationResetEligibility['counts']
}
// Fiscal-year reset (issue #1883): guarded hard-delete of one OPEN fiscal
// year's vouchers. Mirrors the migration-reset envelope shapes above.
export type FiscalYearResetBlockerCode =
| 'period_closed'
| 'period_locked'
| 'company_lock_date'
| 'year_end_state'
| 'arsredovisning_state'
| 'next_year_dependency'
| 'vat_declared'
| 'agi_declared'
| 'rot_rut_state'
| 'cross_year_reference'
export interface FiscalYearResetBlocker {
code: FiscalYearResetBlockerCode
count?: number
date?: string
}
export interface FiscalYearResetEligibility {
eligible: boolean
blockers: FiscalYearResetBlocker[]
period: {
id: string
name: string
period_start: string
period_end: string
}
counts: {
vouchers: number
documents_to_detach: number
}
}
export interface FiscalYearResetRpcResult {
ok: boolean
code?: string
eligible?: boolean
blockers?: FiscalYearResetBlocker[]
period?: FiscalYearResetEligibility['period']
counts?: FiscalYearResetEligibility['counts']
deleted?: number
detached_documents?: number
period_name?: string
}
// Shape of user_preferences.ui_state. All fields optional: the bag grows
// as UI surfaces add preferences (UI migration plan PR 2/3).
export interface UserUiState {
nav_collapsed?: boolean
nav_folds?: {
register?: boolean
bokslut?: boolean
}
// Split-button last-used create modes, keyed per surface (plan PR 3/4),
// e.g. create_mode.bookkeeping = 'mall'.
create_mode?: Record<string, string>
// Assistant panel geometry (components/agent/AgentSheet): docked width,
// undocked floating rect, and which of the two modes is active. Client
// re-clamps to the current viewport on read, so stale sizes from another
// screen are safe.
agent_panel?: AgentPanelState
// One-time expired-trial dialog acknowledgement, keyed per company
// (companyId -> ISO timestamp of the ack). Lives on the user so each
// member of a company sees the notice once.
trial_expired_ack?: Record<string, string>
}
export type AgentPanelMode = 'docked' | 'floating'
// Viewport pixels of the undocked assistant window.
export interface AgentPanelFloatRect {
x: number
y: number
w: number
h: number
}
export interface AgentPanelState {
mode?: AgentPanelMode
dock_width?: number
float?: AgentPanelFloatRect
}
// 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'
export type TaxFilingMethod = 'electronic' | 'paper'
// 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'
// 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'
export interface InvoicePaymentAccount {
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
/**
* Foreign non-IBAN routing: ABA routing number (USD), sort code (GBP),
* BSB (AUD) or a comparable national bank code. Only meaningful together
* with foreign_account_number + bic on a non-SEK account.
*/
bank_code?: string | null
/** Foreign account number for non-IBAN countries (US/UK/AU/CA style). */
foreign_account_number?: string | null
}
// Editable invoice email texts (standard invoices only; sv + en).
// Missing / whitespace-only fields fall back to the hardcoded defaults in
// lib/email/invoice-templates.ts. Supports the fixed placeholder set
// {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp}.
export interface InvoiceEmailTextOverrides {
subject?: string
greeting?: string
body?: string
signoff?: string
}
export interface InvoiceEmailTexts {
sv?: InvoiceEmailTextOverrides
en?: InvoiceEmailTextOverrides
}
// Editable reminder email texts per reminder level (Swedish only, matching
// the reminder templates). Missing / whitespace-only fields fall back to the
// defaults in lib/email/reminder-templates.ts (REMINDER_EMAIL_DEFAULT_TEXTS).
// Supports the fixed placeholder set {fakturanummer} {kundnamn} {förnamn}
// {företag} {fakturadatum} {förfallodatum} {belopp} {dagar}. TEXT only:
// reminder fee and interest math are unaffected (Lag 1981:739 caps the
// påminnelseavgift at 60 kr; the 450 kr förseningsersättning is out of scope).
export interface ReminderTextOverride {
subject?: string
body?: string
}
export interface ReminderTextOverrides {
level_1?: ReminderTextOverride
level_2?: ReminderTextOverride
level_3?: ReminderTextOverride
}
export type InvoiceFontFamily =
| 'Helvetica'
| 'Times-Roman'
| 'Courier'
| 'Source Sans 3'
| 'Source Serif 4'
| 'Custom'
// 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
// null = never attested; deadline generation falls back to pays_salaries.
employer_registered?: boolean | null
employer_seasonal?: boolean
f_skatt: boolean
vat_registered: boolean
vat_number: string | null
moms_period: MomsPeriod | null
periodisk_sammanstallning_period: 'monthly' | 'quarterly'
vat_taxable_base_over_40m: boolean
vat_has_eu_trade: boolean
vat_filing_method: TaxFilingMethod
periodisk_sammanstallning_enabled: boolean
periodisk_sammanstallning_filing_method: TaxFilingMethod
// Annual kontrolluppgifter (KU10/KU20/KU31) reminder, due 31 January.
kontrolluppgifter_enabled: boolean
// ROT/RUT begäran om utbetalning reminder, due 31 January after the
// payment year (Lag 2009:194 8 §). Rows are only generated for years
// that actually have paid ROT/RUT invoices.
rot_rut_enabled: boolean
// Long-tail deadlines, explicit opt-in only ("Fler deadlines" in tax
// settings). OSS/IOSS are EU-law deadlines that never move to the next
// banking day.
oss_enabled: boolean
ioss_enabled: boolean
intrastat_enabled: boolean
punktskatt_enabled: boolean
fyllnadsinbetalning_enabled: boolean
// 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
// Share capital per Bolagsverket (aktiekapital note in the annual report).
// Kvotvärde is derived as aktiekapital / antal_aktier, never stored.
aktiekapital?: number | null
antal_aktier?: 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
// Foreign non-IBAN routing, only ever populated on the render-time copy
// produced by companyWithInvoicePaymentAccount (never a DB column).
bank_code?: string | null
foreign_account_number?: string | null
// Invoice payment instructions keyed by the currency they can receive.
// Legacy bank fields above remain the SEK fallback for older companies.
invoice_payment_accounts?: Partial<Record<Currency, InvoicePaymentAccount>>
// Accounting method
accounting_method: AccountingMethod
// #967: when true (accrual only), registering supplier invoices / sending
// customer invoices does NOT book them; booking is a separate explicit step.
defer_invoice_booking?: boolean
// Invoice settings
invoice_prefix: string | null
next_invoice_number: number
// Starting ankomstnummer for the supplier-invoice (leverantorsfaktura)
// series. Acts as a floor: get_next_arrival_number returns
// GREATEST(MAX(arrival_number)+1, next_arrival_number). Defaults to 1.
next_arrival_number: number
next_delivery_note_number: number
invoice_default_days: number
invoice_default_notes: string | null
// Default "Vår referens": pre-fills the per-invoice our_reference field.
default_our_reference: 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>>
// Most recently picked BAS account for supplier invoice payments: used to
// default the mark-paid dialog so repeat payments don't force re-picking.
last_supplier_payment_account: string | null
// 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
// Opt-in for the invoice payment-link feature (default false): shows the
// payment-link field in the invoice editor and enables automatic Stripe
// payment links on send. Enforced server-side in
// lib/extensions/payment-links.ts, not just in the UI.
invoice_payment_links_enabled: boolean
// 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: InvoiceFontFamily
invoice_custom_font_path: string | null
invoice_custom_font_name: string | null
invoice_header_text: string | null
invoice_footer_text: string | null
// Editable invoice email texts. null = all defaults.
invoice_email_texts: InvoiceEmailTexts | null
// Fixed invoice-email recipients. null means the company has not configured
// the setting yet and keeps the historical automatic CC fallback. [] is an
// explicit choice to send no copies.
invoice_email_cc_addresses?: string[] | null
invoice_email_bcc_addresses?: string[] | null
// Automation
send_invoice_reminders: boolean
reminder_days_level_1: number
reminder_days_level_2: number
reminder_days_level_3: number
// Editable reminder email texts per level. null = all defaults.
reminder_text_overrides: ReminderTextOverrides | null
// 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
initial_setup_path?: InitialSetupPath | null
initial_setup_completed_at?: string | null
initial_setup_dismissed_at?: string | null
// Sector
sector_slug: string | null
// Dimensions (kostnadsställe/projekt): UI-visibility toggle only, never
// load-bearing for correctness. Free tier (founder decision 2026-07-02).
dimensions_enabled: boolean
// Körjournal (mileage log): UI-visibility toggle only, never load-bearing
// for correctness. The nav row also shows when mileage_trips rows exist.
mileage_enabled: boolean
// Kundorder (sales orders): UI-visibility toggle only, never load-bearing
// for correctness (the /sales-orders pages and APIs work regardless).
sales_orders_enabled: boolean
// Per-company counter behind generate_sales_order_number (OR-<n>).
next_sales_order_number?: number
// Data analysis consent (migration 20260828120000): when true, the
// company's bookkeeping outcomes may be read across companies to evaluate
// and improve automatic booking. Default false, enforced server-side
// (lib/company/data-analysis.ts); the UI only mirrors it.
data_analysis_opt_in: boolean
// Salary payments (migration 20260508120000 + 20260703190000).
// preferred_payment_format defaults to 'pain001' — Bankgirot Lön is
// retired by the banks during 2026.
preferred_payment_format: 'bg_lb' | 'pain001'
salary_pay_day: number
salary_default_bank: 'swedbank' | 'seb' | 'handelsbanken' | 'nordea' | 'other' | null
// Öresavrundning (migration 20260813143000): round each net payout up to
// whole kronor; the 0-99 öre diff books on 3740 via a derived line item.
salary_net_rounding: boolean
// 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
// PSD2 PSU type chosen at authorization. Reused on reconnect so consent
// renewals keep the account type that actually worked. NULL on legacy rows.
psu_type: 'personal' | 'business' | null
// 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
available_balance: number | null
balance_updated_at: string | null
enabled: boolean
is_primary: boolean
source: CashAccountSource
// Optional verifikationsserie (single letter) for entries booked from this
// account. null = follow company_settings.default_voucher_series_per_source_type.
// See 20260902121420_cash_accounts_voucher_series.sql.
voucher_series: string | null
created_at: string
updated_at: string
}
/**
* Closed vocabulary for HOW money moved (the payment rail), classified at
* ingest by classifyTransactionMethod() (lib/transactions/transaction-method.ts).
* Mirrored by the transactions_transaction_method_check DB constraint
* (migration 20260808090000): keep the three in sync when adding a value.
*/
export const TRANSACTION_METHODS = [
'card',
'transfer',
'bankgiro',
'plusgiro',
'swish',
'autogiro',
'e_invoice',
'international',
'deposit',
'withdrawal',
'salary',
'fee',
'interest',
'adjustment',
] as const
export type TransactionMethod = (typeof TRANSACTION_METHODS)[number]
// Transaction
export interface Transaction {
id: string
user_id: string
company_id: string
// Source
bank_connection_id: string | null
external_id: string | null // For deduplication
// The cash account (cash_accounts row) this transaction settled on. Drives
// per-account bank reconciliation isolation and the correct bank leg when
// booking. Null on legacy/unresolved rows: callers fall back to currency.
// See 20260606120000_transactions_cash_account_id.sql.
cash_account_id: string | null
// Details
date: string
description: string // Mutable working title: user-editable while unbooked (see PATCH /api/transactions/[id])
// Bank/PSD2 description captured at ingest, normalized (empty/whitespace and
// the legacy "Unknown" sentinel map to the Swedish neutral). Never overwritten
// by user title edits; source for the dedup bridge and the "restore original"
// action. Null only for rows predating the column.
original_description: string | null
// Set when the user has overridden the title; null = still the bank original.
title_edited_at: string | null
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
// Potential journal-entry match (suggested by the reconciliation sweep, not
// confirmed). All three set together, or all null; cleared by DB triggers
// when the row is booked/ignored or the entry is consumed/reversed.
potential_journal_entry_id?: string | null
potential_match_method?: string | null
potential_match_confidence?: number | null
// Bookkeeping
journal_entry_id: string | null
mcc_code: number | null
merchant_name: string | null
// Payment rail classified at ingest (or by the 20260808090100 backfill);
// null = unclassifiable from the source data.
transaction_method: TransactionMethod | null
// Raw PSD2 transaction-type codes, verbatim provider evidence for the
// classification (previously dropped at insert). Null for non-PSD2 sources.
bank_transaction_code: string | null
proprietary_bank_transaction_code: 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
// User has chosen to suppress this transaction from the bank reconciliation
// view without booking it. See migration
// 20260529140000_transactions_is_ignored.sql for the rationale.
is_ignored: boolean
// Import tracking
import_source: string | null
// The bank_file_imports batch that inserted this row (bank-file CSV/CAMT
// import paths only). NULL for PSD2/manual/MCP rows and rows imported
// before migration 20260820071500. Scope key for undo_bank_file_import.
// Optional like the other late-added columns: older fixtures/readers
// predate it.
bank_file_import_id?: 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)
// 'undone' = the batch's unbooked transactions were bulk-deleted via
// undo_bank_file_import; a re-import of the same file reuses the row
// (upsert on company_id + file_hash) and moves it back to 'processing'.
export type BankFileImportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'undone'
// Customer
export interface Customer {
id: string
user_id: string
company_id: string
// Basic info
name: string
customer_type: CustomerType
// User-assigned customer number (kundnummer) shown on invoices.
// Free text, no uniqueness enforced in v1.
customer_number: string | null
// Contact
contact_person: string | null
email: string | null
phone: string | null
invoice_email_cc_addresses: string[] | null
invoice_email_bcc_addresses: 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
clearing_number: string | null
account_number: string | null
default_expense_account: string | null
default_payment_terms: number
default_currency: string
notes: string | null
created_at: string
updated_at: string
}
// Supplier payment batch (betalfil): an immutable snapshot of payment
// instructions handed to the bank as a file. Generating or downloading a
// batch books nothing; settlement stays in mark-paid / bank matching.
export type SupplierPaymentBatchFormat = 'pain001' | 'bg_lb'
export type SupplierPaymentBatchStatus = 'created' | 'cancelled'
export interface SupplierPaymentBatchDebtor {
name: string
org_number: string
iban: string
bic: string
/** Absent on batches created before the Swedbank MIG fixes (2026-08-10). */
bankgiro?: string | null
/** Company town for Dbtr/PstlAdr; absent on pre-TownName-fix batches. */
city?: string | null
}
export interface SupplierPaymentBatch {
id: string
company_id: string
user_id: string
format: SupplierPaymentBatchFormat
status: SupplierPaymentBatchStatus
currency: string
total_amount: number
item_count: number
/** pain.001 MsgId, fixed at creation; re-downloads reuse it verbatim. */
msg_id: string
debtor_snapshot: SupplierPaymentBatchDebtor
file_generated_at: string | null
download_count: number
cancelled_at: string | null
cancelled_by: string | null
created_at: string
updated_at: string
}
export type SupplierPaymentBatchPayeeType = 'bankgiro' | 'plusgiro' | 'bank_account'
export type SupplierPaymentBatchReferenceType = 'ocr' | 'invoice_number'
export interface SupplierPaymentBatchItem {
id: string
batch_id: string
company_id: string
supplier_invoice_id: string
amount: number
payment_date: string
payee_type: SupplierPaymentBatchPayeeType
payee_bankgiro: string | null
payee_plusgiro: string | null
payee_clearing: string | null
payee_account: string | null
payee_name: string
/** Supplier town at creation; feeds Cdtr/PstlAdr/TwnNm on IBAN-debited payments. */
payee_city: string | null
reference_type: SupplierPaymentBatchReferenceType
reference: string
created_at: string
}
// Kundorder (sales order): the non-ledger document between agreement and
// invoice. Never books. Four-state header machine; delivery and invoicing
// progress are derived per line (see SalesOrderItem.invoiced_qty).
export type SalesOrderStatus = 'draft' | 'confirmed' | 'completed' | 'cancelled'
/** Derived per-axis progress: none / partial / full. */
export type SalesOrderProgress = 'none' | 'partial' | 'full'
export interface SalesOrder {
id: string
company_id: string
user_id: string
customer_id: string | null
/** OR-<n>, allocated at creation by generate_sales_order_number. */
order_number: string | null
status: SalesOrderStatus
/** Proforma the order was converted from, if any. */
source_invoice_id: string | null
order_date: string
requested_delivery_date: string | null
/** Latest registered delivery date across all lines (display only; invoices use per-line dates). */
last_delivery_date: string | null
/** Customer facts the lines were VAT-validated under; invoicing refuses when they changed. */
customer_type_snapshot?: CustomerType | null
customer_vat_validated_snapshot?: boolean | null
currency: string
subtotal: number
vat_amount: number
total: number
your_reference: string | null
our_reference: string | null
notes: string | null
default_dimensions: Record<string, string>
confirmed_at: string | null
completed_at: string | null
cancelled_at: string | null
created_at: string
updated_at: string
// Embeds / derived (list + detail responses)
customer?: Customer | null
items?: SalesOrderItem[]
delivery_progress?: SalesOrderProgress
invoicing_progress?: SalesOrderProgress
}
export interface SalesOrderItem {
id: string
company_id: string
sales_order_id: string
sort_order: number
line_type: 'product' | 'text'
description: string
quantity: number
/** Stored: registered by the user via the deliver action. */
delivered_qty: number
/** Latest delivery date registered for this line (null until delivered). */
last_delivery_date?: string | null
unit: string
unit_price: number
discount_percent: number
vat_rate: number
/** NET of discount, order currency. */
line_total: number
article_id: string | null
revenue_account: string | null
dimensions: Record<string, string>
created_at: string
updated_at: string
/** Derived from linked invoice_items on non-cancelled, non-credited invoices. */
invoiced_qty?: number
/** quantity - invoiced_qty (never negative). */
remaining_qty?: number
}
export interface SalesOrderItemInput {
id?: string
line_type?: 'product' | 'text'
description: string
quantity: number
unit: string
unit_price: number
discount_percent?: number | null
vat_rate?: number
article_id?: string | null
revenue_account?: string | null
dimensions?: Record<string, string>
}
// Article (artikelregister): reusable invoice-line preset. NON-INVENTORY:
// no stock fields and no inventory postings, by deliberate design.
export type ArticleType = 'vara' | 'tjanst'
export interface Article {
id: string
company_id: string
user_id: string
/** Auto-numbered per company (generate_article_number RPC); user-overridable. */
article_number: string | null
name: string
/** English benämning for English-language invoices. */
name_en: string | null
type: ArticleType
unit: string
/** Always stored EXCLUDING VAT. */
price_excl_vat: number
/** Default line VAT rate as an integer percent: 25 | 12 | 6 | 0. */
vat_rate: number
/** Default price currency (ISO 4217 code from the currencies table);
* pre-fills the invoice currency when added. */
currency: string
/** Optional BAS class 1-3 posting account override. null = derive from VAT treatment. */
revenue_account: string | null
/** Margin/display only: never posted to the ledger. */
cost_price: number | null
ean: string | null
/** ROT/RUT arbetstypskod (tjänst only); pre-fills the invoice line. */
housework_type: string | null
notes: string | null
/** Soft-delete flag. Inactive articles are hidden from pickers but keep history. */
active: boolean
created_at: string
updated_at: string
}
export interface CreateArticleInput {
name: string
type?: ArticleType
unit?: string
price_excl_vat: number
vat_rate?: number
currency?: string
revenue_account?: string | null
cost_price?: number | null
ean?: string | null
housework_type?: string | null
name_en?: string | null
notes?: string | null
/** Optional manual article number; omit to auto-generate. */
article_number?: string | null
}
// 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
/**
* When the invoice was attested. The overdue cron collapses 'registered' and
* 'approved' into 'overdue', so this is the only durable attest marker: use
* it, not the status, to tell whether approval has happened.
*/
approved_at: string | null
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
/** Per-invoice öresavrundning override (display-only). null = off. */
ore_rounding: boolean | 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
// Default dimensions bag ({sie_dim_no: code}, e.g. {"1":"KS01","6":"P001"})
// applied to every generated journal line; item-level `dimensions` merge on
// top of it for the expense lines (dimensions PR7). Stored as jsonb
// DEFAULT '{}'. Optional in TS for pre-migration fixtures.
default_dimensions?: Record<string, string>
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
// Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25), null
// for non-RC lines. The supplier charges no VAT so vat_rate stays 0; this
// rate drives the fiktiv-moms + basbelopp booking. See the booking engine.
reverse_charge_rate: number | null
// Periodisering (förutbetald kostnad): when set, the registration entry
// debits accrual_balance_account (17xx) instead of account_number, and an
// accrual_schedules row dissolves the net amount monthly over the period.
// VAT is never deferred. Both dates set together or not at all.
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
// Per-item dimensions bag, merged over the invoice's default_dimensions on
// the expense line this item books to (dimensions PR7). jsonb DEFAULT '{}'.
dimensions?: Record<string, string>
// Särskild löneskatt på pensionskostnader: when true the booking engine
// injects a self-balancing 7533 D / 2514 K pair at 24.26 % of line_total
// (lib/bookkeeping/slp-lines.ts). Only valid on 741x pension-premium
// accounts. Optional in TS for pre-migration fixtures.
apply_slp?: boolean
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
/** Per-invoice öresavrundning override (display-only). null = inherit company_settings.ore_rounding. */
ore_rounding: boolean | 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
// Fakturamärkning: buyer-required marking (cost center, project, PO label),
// separate from your_reference (Er referens = contact person). Printed on
// the PDF and mapped to Peppol BT-10 BuyerReference when set. Optional in
// TS for pre-migration fixtures.
invoice_marking?: string | null
// Optional online payment link (pasted by the user, e.g. a Stripe Payment
// Link). Rendered as a "Betala online" button in the invoice email and as a
// QR code + link on the PDF. Never copied to derived documents (credit
// notes, conversions, recurring invoices). Optional in TS for pre-migration
// fixtures.
payment_link_url?: string | null
// Stripe Payment Link id (plink_...) when the link above was auto-created by
// the Stripe extension; NULL for manually pasted links. Deterministic
// matching key for checkout.session.completed events and the handle used to
// deactivate the link on credit/paid.
stripe_payment_link_id?: string | null
// Per-invoice opt-out for automatic payment link creation on send.
payment_link_auto?: boolean
// 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
// Kundorder this invoice was created from (sales_orders.id). Header-level
// provenance only; the per-line link is invoice_items.sales_order_item_id.
sales_order_id?: string | null
// Self-billing received (mottagen självfaktura, ML 17 kap 15§). When
// `is_self_billed` is true the customer issued the invoice on our behalf;
// for us it is a sale. The counterparty's number lives in
// `external_invoice_number` and our own `invoice_number` stays null so we
// never consume our löpnummerserie (BFL 5 kap 6§).
is_self_billed?: boolean
external_invoice_number?: string | null
self_billing_agreement_ref?: string | null
received_date?: string | null
// Verifikation produced when the invoice was booked (registration entry).
// Lets the payment flow detect an already-booked sale and clear 1510 rather
// than re-recognising revenue.
journal_entry_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
// Default dimensions bag ({sie_dim_no: code}) applied to every journal line
// generated from this invoice (issuance, payment, credit); item-level
// `dimensions` merge on top for the revenue lines (dimensions PR7).
// jsonb DEFAULT '{}'. Optional in TS for pre-migration fixtures.
default_dimensions?: Record<string, string>
created_at: string
updated_at: string
// Relations (populated when fetched)
customer?: Customer
items?: InvoiceItem[]
payments?: InvoicePayment[]
}
export type InvoiceDeliveryChannel = 'email' | 'manual'
export type InvoiceDeliveryStatus = 'preparing' | 'pending' | 'sent' | 'failed' | 'marked_sent'
/**
* Delivery outcome reported by the email provider after the send itself
* succeeded. The delivery keeps an aggregate outcome and, when the provider
* identifies affected recipients, outcomes keyed by stable To/CC positions.
* `null` means no report has arrived yet.
*/
export type InvoiceDeliveryProviderStatus =
| 'delayed'
| 'delivered'
| 'complained'
| 'bounced'
| 'failed'
| 'suppressed'
export interface InvoiceDeliveryRecipientStatus {
status: InvoiceDeliveryProviderStatus
status_at: string
}
/**
* PII-free recipient references. `to:1` is the first immutable To address and
* `cc:1` the first immutable CC address. BCC recipients are never exposed.
*/
export type InvoiceDeliveryRecipientStatuses = Partial<Record<
`to:${number}` | `cc:${number}`,
InvoiceDeliveryRecipientStatus
>>
export interface InvoiceDelivery {
id: string
company_id: string
user_id: string | null
invoice_id: string
channel: InvoiceDeliveryChannel
status: InvoiceDeliveryStatus
to_addresses: string[]
cc_addresses: string[]
bcc_addresses: string[]
reply_to: string | null
from_name: string | null
subject: string | null
body_text: string | null
body_html: string | null
provider: string | null
provider_message_id: string | null
provider_status: InvoiceDeliveryProviderStatus | null
provider_status_at: string | null
provider_status_detail: string | null
provider_recipient_statuses: InvoiceDeliveryRecipientStatuses
error_code: string | null
document_attachment_id: string | null
attachment_filename: string | null
attachment_content_type: string | null
attachment_sha256: string | null
sent_at: string | null
failed_at: string | null
retention_expires_at: string
pii_redacted_at: string | null
created_at: string
updated_at: string
}
// Invoice Item
export interface InvoiceItem {
id: string
invoice_id: string
// Order
sort_order: number
// Line kind. 'product' is a normal billable line; 'text' is a free-text or
// blank spacer row that carries only a description: no amounts, excluded from
// totals and bookkeeping. Optional in TS for legacy rows (defaults to
// 'product' in Postgres).
line_type?: 'product' | 'text'
// Description
description: string
// Quantity
quantity: number
unit: string // 'st', 'tim', 'dag', etc.
// Price
unit_price: number
// Percentage discount on the line (0-100). line_total and vat_amount are
// stored NET of this discount (lib/invoices/line-amounts.ts). Optional in
// TS for pre-migration fixtures; treat undefined the same as 0.
discount_percent?: number
// Calculated (net of discount_percent)
line_total: number
// Per-line VAT
vat_rate: number
vat_amount: number
// Article linkage. `article_id` is a soft back-reference to the source
// article (for the "Affärshändelser" history view); `revenue_account` is the
// BAS class 1-3 posting account frozen-copied from the article at line-create time.
// null `revenue_account` preserves the legacy "derive from VAT treatment"
// booking in generatePerRateLines().
article_id?: string | null
revenue_account?: string | null
// Kundorder line this invoice line was created from. The order line's
// invoiced quantity is DERIVED from these links (never stored), so an
// edit that drops the link would free the quantity for double invoicing:
// every write path round-trips it.
sales_order_item_id?: string | null
// Periodisering (förutbetald intäkt): when set, the revenue entry credits
// accrual_balance_account (29xx) instead of the line's revenue account, and
// an accrual_schedules row dissolves the net amount monthly over the
// period. Output VAT is never deferred. Both dates set together or not at
// all. Not combinable with ROT/RUT or text lines.
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
// 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
/** Bostadsrättsföreningens orgnr. ROT i bostadsrätt reports lägenhetsnummer
* + BRF orgnr instead of fastighetsbeteckning (Begaran.xsd: BrfOrgNr). */
brf_org_number?: string | null
// Per-item dimensions bag, merged over the invoice's default_dimensions on
// the revenue line this item books to (dimensions PR7). jsonb DEFAULT '{}'.
dimensions?: Record<string, string>
created_at: string
}
// Rot/rut payout request (begäran om utbetalning, Skatteverkets husavdragstjänst).
// One row per generated HUS XML file; items link the invoices whose 1513
// receivable the file requests. See lib/invoices/rot-rut-file.ts.
export type RotRutPayoutRequestStatus =
| 'generated'
| 'submitted'
| 'paid'
| 'partially_paid'
| 'rejected'
| 'cancelled'
// 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
// Day-of-month anchor, 1-31. Clamped to last day of month in shorter
// months (handled by computeNextRunDate).
day_of_month: number
// Months between runs: 1 = monthly, 3 = quarterly, 6 = half-yearly,
// 12 = yearly. next_run_date is the month anchor the interval advances from.
interval_months: number
// Whole hour (0-23) in Europe/Stockholm time at which the schedule sends.
// The hourly cron only fires schedules matching the current Stockholm hour.
send_hour: number
payment_terms_days: number
currency: Currency
your_reference: string | null
our_reference: string | null
notes: string | null
// Dimension bag {sie_dim_no: code} copied onto every generated invoice's
// default_dimensions at spawn time.
default_dimensions?: Record<string, string>
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
// Per-item bag copied onto the generated invoice_items.dimensions; merges
// over the schedule default on that item's revenue line.
dimensions?: Record<string, string>
created_at: string
}
// Form types for creating/updating
export interface CreateCustomerInput {
name: string
customer_type: CustomerType
customer_number?: string | null
contact_person?: string | null
email?: string
phone?: string
invoice_email_cc_addresses?: string[] | null
invoice_email_bcc_addresses?: string[] | null
address_line1?: string
address_line2?: string
postal_code?: string
city?: string
country?: string
org_number?: string
vat_number?: string
personal_number?: string | null
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
clearing_number?: string
account_number?: string
default_expense_account?: string
default_payment_terms?: number
default_currency?: string
notes?: string
}
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 ArchiveEstimate {
total_bytes: number
document_bytes: number
document_count: number
size_limit_bytes: number
within_limit: boolean
}
// 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
}
// ============================================================
// 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'
| 'accrual'
| 'result_appropriation'
| 'rot_rut_payout'
| 'vat_settlement'
| 'stripe_payout'
| 'webshop_order'
// 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
// Per-account default VAT rate for booking lines (0/0.06/0.12/0.25).
// null = no default (line keeps its own rate). Öresavrundning (3740) = 0.
default_vat_rate: number | null
default_vat_treatment: import('@/lib/vat/account-vat-treatment').AccountVatTreatment | 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
// Closed via "klarmarkera": the bokslut was done in a previous bookkeeping
// system, so the period is closed here without a closing entry of its own.
// Optional: rows predate the column on some cached readers.
closed_externally?: boolean
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
tax_depreciation_method?: 'rakenskapsenlig' | 'restvarde' | null
tax_depreciation_rule?: 'huvudregel_30' | 'kompletteringsregel_20' | null
tax_depreciation_opening_value?: number | null
tax_depreciation_base?: number | null
tax_depreciation_deduction?: number | null
tax_depreciation_closing_value?: number | null
tax_depreciation_calculation?: Record<string, unknown> | 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
// WHO relayed the commit; complements commit_method = HOW. Stamped at
// commit time since migration 20260619120000. actor_type is NULL or one of
// 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'system' | 'agent_chat'
// (the DB CHECK is the authority); actor_label is a credential snapshot
// (e.g. the API key name).
committed_actor_type: string | null
committed_actor_label: 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
// SIE dimension map {sie_dim_no: object_code}, e.g. {"1":"KS01","6":"P001"}.
// Source of truth; cost_center/project mirror keys '1'/'6'. Optional so
// pre-migration fixtures and partial selects stay type-valid.
dimensions?: Record<string, string>
cost_center: string | null
project: string | null
sort_order: number
created_at: string
}
// ── Periodisering (accrual schedules) ─────────────────────────
// One schedule per deferred invoice line: the net amount sits on a 17xx/29xx
// interim account and dissolves to the P&L account via monthly 'accrual'
// entries. See lib/bookkeeping/accruals/.
export type AccrualDirection = 'expense' | 'revenue'
export type AccrualScheduleStatus = 'active' | 'completed' | 'cancelled'
export type AccrualInstallmentStatus = 'pending' | 'posted' | 'cancelled'
export interface AccrualSchedule {
id: string
user_id: string
company_id: string
direction: AccrualDirection
supplier_invoice_id: string | null
supplier_invoice_item_id: string | null
invoice_id: string | null
invoice_item_id: string | null
// Interim balance account (17xx for expense, 29xx for revenue) and the
// P&L account each installment dissolves to. Strings, like all accounts.
balance_account: string
target_account: string
// Net SEK amount as booked (ex VAT). Always equals the sum of installments.
total_amount: number
period_start: string
period_end: string
months: number
origin_journal_entry_id: string | null
// Dissolution entries are never dated before this (= origin entry date).
posting_floor_date: string
status: AccrualScheduleStatus
description: string | null
// Dimensions bag ({sie_dim_no: object_code}) copied from the origin line
// (invoice default_dimensions merged with the item bag); carried onto both
// dissolution lines. jsonb DEFAULT '{}'. Optional in TS for pre-migration
// fixtures.
dimensions?: Record<string, string>
created_at: string
updated_at: string
// Relations
installments?: AccrualScheduleInstallment[]
}
export interface AccrualScheduleInstallment {
id: string
user_id: string
company_id: string
schedule_id: string
// First day of the calendar month the installment belongs to.
period_month: string
amount: number
status: AccrualInstallmentStatus
journal_entry_id: string | null
posted_at: string | null
last_error: string | null
created_at: string
updated_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
// Set when a matched counterparty template's learned direction contradicts
// the transaction sign (e.g. an incoming refund matching an expense-learned
// template). The result is mirrored and review-gated, and must never be
// learned back into the template (it would flip the learned accounts).
direction_mismatch?: boolean
// Dimensions bag applied to the business (expense/revenue) lines of the
// generated entry: from a counterparty template's line pattern or an
// explicit categorize param (dimensions PR7). Bank/VAT lines stay untagged.
dimensions?: Record<string, string>
}
// VAT journal line (auto-generated)
export interface VatJournalLine {
account_number: string
debit_amount: number
credit_amount: number
description: string
// Set on business-type lines materialized from a LinePatternEntry that
// carries dimensions (dimensions PR7); VAT/tax lines stay untagged.
dimensions?: Record<string, 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)
// Dimensions bag ({sie_dim_no: code}) learned from the source vouchers'
// lines; applied to the materialized line on booking (dimensions PR7).
// Only preserved by learning when every occurrence agrees.
dimensions?: Record<string, string>
}
// Per-tenant counterparty-based categorization template
export interface CategorizationTemplate {
id: string
// Pre-multi-tenant relic: nullable since 20260711100000 and never written
// by the learning path anymore. Scoping is company_id.
user_id: string | null
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
// Bag {sie_dim_no: code} learned from the latest tagged booking; applied to
// the business line on the legacy single-line template path (line_pattern
// entries carry their own bags on the multi-line path).
default_dimensions?: Record<string, string>
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
}
// 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
}
/**
* A non-latest fiscal year whose P&L (class 3-8) does not net to zero —
* its result was never transferred to equity (omföring av årets resultat
* saknas). Every later period that derives its opening balance from prior
* class 1-2 lines inherits exactly this residual as a balance-sheet
* differens.
*/
export interface UntransferredResult {
fiscal_period_id: string
period_name: string
/** Class 3-8 net (credit-positive = profit), rounded to öre. */
pl_net: number
}
/**
* Server-built explanation for an unbalanced balance report. The message is
* Swedish (user-facing domain messages are Swedish) and names the exact
* fiscal years whose results were never moved to equity.
*/
export interface BalanceImbalanceDiagnosis {
differens: number
untransferred_results: UntransferredResult[]
message: string
}
export interface BalanceSheetReport {
asset_sections: BalanceSheetSection[]
total_assets: number
equity_liability_sections: BalanceSheetSection[]
total_equity_liabilities: number
period: { start: string; end: string }
/** Present only when the report does not balance. */
imbalance_diagnosis?: BalanceImbalanceDiagnosis
}
/**
* Highest POSTED voucher number per series inside a reported window.
*
* Reconciliation aid, not statutory (BFL does not require it). Deliberately the
* last posted number, not `voucher_sequences.last_number`: the sequence counter
* is an allocation high-water mark that can sit ahead of the books.
*/
export interface LatestVoucherPerSeries {
series: string
last_number: number
}
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
/** Omitted when the window holds no posted vouchers, or the report is dimension-filtered. */
latest_vouchers?: LatestVoucherPerSeries[]
}
// Resultat per projekt/kostnadsställe: value-as-column P&L matrix over one
// SIE dimension. `code: null` marks the "(Utan dimension)" residual bucket,
// which is computed as Totalt tagged columns so every row sums exactly to
// its resultatrapport counterpart.
export interface DimensionPnlColumn {
code: string | null
name: string | null
}
export interface DimensionPnlRow {
account_number: string
account_name: string
values: number[]
total: number
}
export interface DimensionPnlGroup {
class: number
class_label: string
rows: DimensionPnlRow[]
subtotals: number[]
subtotal_total: number
}
export interface DimensionPnlReport {
dimension: { sie_dim_no: string; name: string }
columns: DimensionPnlColumn[]
groups: DimensionPnlGroup[]
net_per_column: number[]
net_total: number
period: { start: string; end: string }
}
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 }
/** Present only when the underlying trial balance does not balance. */
imbalance_diagnosis?: BalanceImbalanceDiagnosis
/** Omitted when the window holds no posted vouchers. */
latest_vouchers?: LatestVoucherPerSeries[]
}
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
// SIE dimension map {sie_dim_no: object_code}. Wins per key over the
// deprecated cost_center/project aliases (normalizeLineDimensions).
dimensions?: Record<string, string>
/** @deprecated alias for dimensions['1']: kept for API/MCP compatibility */
cost_center?: string
/** @deprecated alias for dimensions['6']: kept for API/MCP compatibility */
project?: string
}
// ── Pending Operations ────────────────────────────────────────
export type PendingOperationType =
| 'categorize_transaction'
| 'create_customer'
| 'update_customer'
| 'update_company_settings'
| 'create_article'
| 'update_article'
// Kundorder (gnubok_create_sales_order / _transition_sales_order /
// _register_sales_order_delivery / _create_invoice_from_sales_order)
| 'create_sales_order'
| 'transition_sales_order'
| 'register_sales_order_delivery'
| 'create_invoice_from_sales_order'
// Kontoplan reference data (gnubok_create_account / gnubok_update_account)
| 'create_account'
| 'update_account'
| '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'
| 'post_kontantmetod_cutoff'
| 'run_currency_revaluation'
// Stream 1 Phase 1: SIE import (export is read-only)
| 'import_sie'
// SIE undo: hard-deletes the import's journal entries and releases the
// (company_id, file_hash) slot. Recovery for botched imports.
| 'undo_sie_import'
// 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'
// Link a document directly to a journal entry (verifikation): for imported/
// manual vouchers that have no bank-transaction row.
| 'link_document_to_voucher'
// Bulk counterpart: N docs linked to N posted verifikationer in one staged
// op, addressed by voucher_series/voucher_number/fiscal_year instead of
// journal_entry_id UUIDs (resolved server-side).
| 'link_documents_to_vouchers'
// 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'
// Draft-only invoice removal: unnumbered drafts hard delete, numbered
// drafts are makulerade (number retained). Non-drafts are refused.
| 'delete_draft_invoice'
// Draft-only invoice edit (items full-replace); sent/booked stays immutable,
// correction is a kreditfaktura.
| 'update_invoice'
// Recurring invoice schedules (monthly templates; invoices spawn from the
// hourly cron, never at commit time). Update covers pause/resume via status.
| 'create_recurring_schedule'
| 'update_recurring_schedule'
// 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'
// Notes-only annotation on a verifikat: the immutability trigger's carve-out
// (migration 20260608120000) makes this legal even on posted entries.
| 'set_voucher_note'
// Ignore / restore a bank transaction that is not an affärshändelse (PSD2
// ghost row, duplicate, never-executed transfer). Writes no verifikat, so a
// locked or closed period does not block it (issue #1661).
| 'ignore_transaction'
// Bokslut: planenlig avskrivning (one journal entry per asset)
| 'post_annual_depreciation'
// Payroll: salary run creation + AGI declaration
| 'create_salary_run'
| 'generate_agi'
// Körjournal: log a trip (pure travel documentation) + book the period's
// milersättning as one verifikat (7331 at schablon rate)
| 'log_mileage_trip'
| 'book_mileage_period'
// Mark invoice paid by linking an existing posted verifikat (no new JE)
| 'link_invoice_voucher'
// Supplier-side mirror: mark a leverantörsfaktura paid by linking an existing
// posted verifikat that debits 2440 (no new JE)
| 'link_supplier_invoice_voucher'
// PR #603/#607: allocate 1 bank tx across N customer or supplier invoices
| 'match_batch_allocate'
// PR #606/#610: bulk-book N bank txs into 1 combined verifikat
| 'bulk_book_transactions'
// Bulk-book N selected Underlag (Dokumentinkorgen) against their matched bank
// transactions: one verifikat per item, sharing a category + VAT treatment
| 'bulk_book_inbox_items'
// PR #614: link a single bank tx to an already-posted verifikat (no new JE)
| 'link_transaction_journal_entry'
// Account-keyed reconciliation (bank accounts + skattekonto): link outside
// rows to existing verifikat / clear such a link. No ledger writes.
| 'reconciliation_match'
| 'reconciliation_unmatch'
// Sign-off "avstämt t.o.m. <datum>" on one account (account_reconciliations row).
| 'reconciliation_signoff'
// Book the remainder of a bank selection as a fee/interest/rounding verifikat and link it.
| 'reconciliation_residual'
// Book synced skattekonto rows as posted verifikat (1630 + rule-matched
// counter account), same helper as the HTTP bokfor-batch route. The
// single-row op stores { transaction_id }; the batch op stores { ids }.
| 'book_skattekonto_row'
| 'book_skattekonto_rows'
// PR5: Skatteverket filing via MCP. Commit = "send for BankID signing"
// (returns a signing link); the user's signature in the browser files it.
| 'submit_vat_declaration'
| 'submit_agi'
// Dimensions PR3: stage a new dimension value (kostnadsställe/projekt object
// code, SIE #OBJEKT): agents never silently mint reporting values.
| 'create_dimension_value'
// Dimensions PR6: bulk retag of posted-line dimensions via the audited
// retag_line_dimensions RPC (gnubok_tag_journal_lines).
| 'retag_line_dimensions'
// Payroll gap-closure: payslip line edits + absence registration (1.7),
// employee master data (1.8; personnummer encrypted at staging), and
// cutover opening balances for mid-year migrations (2.4).
| 'update_payslip_line'
// Set THIS RUN's base salary for one employee (salary_run_employees.
// monthly_salary, draft only). The per-run column is what the engine reads;
// the employee master's fixed salary stays untouched (variable owner pay).
| 'set_run_salary'
// Draft-only salary-run header edit (payment_date / voucher_series /
// notes), same field set as the v1 PATCH; payment_date is the future
// booking entry date.
| 'update_salary_run'
| 'register_absence'
| 'create_employee'
| 'update_employee'
| 'set_employee_opening_balances'
// Payroll e2e parity with the v1 REST surface: book a calculated run
// (walks review → approved → paid → booked; the staged approval is the
// authorization act) and remove registered absence days. Employee
// archiving needs no own op: update_employee with is_active=false.
| 'book_salary_run'
| 'delete_absence'
// Semesterårsavslut: rolls vacation balances into the next year and may
// post a 2920/2940 drift-adjustment verifikation (Phase 3).
| 'vacation_year_close'
// 'failed_partial' (issue #842, DB CHECK widened in 20260722134114): terminal
// state for ops whose executor posted an irreversible side-effect (voucher,
// credit note) and then failed a later step. Not re-committable, not pending
// work; result_data.posted_ids carries the ids of what WAS posted.
export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected' | 'failed_partial'
// 'agent_chat' = the in-app AI chat (DB CHECK widened in migration
// 20260519090000_actor_type_agent_chat).
export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'agent_chat'
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
/** True when the company has ever received an item in the document inbox. */
hasInboxItems: boolean
}
export type InitialSetupPath = 'migration' | 'bank' | 'fresh'
export interface InitialSetupState {
path: InitialSetupPath | null
completedAt: string | null
dismissedAt: string | null
}
// ============================================================
// 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'
| 'skatteinbetalning'
| 'inkomstdeklaration_ef'
| 'inkomstdeklaration_ab'
| 'arsredovisning'
| 'arsstamma'
| 'periodisk_sammanstallning'
| 'kontrolluppgifter'
| 'rot_rut_begaran'
| 'oss_quarterly'
| 'ioss_monthly'
| 'intrastat_monthly'
| 'punktskatt_monthly'
| 'fyllnadsinbetalning'
| 'kvarskatt'
export type TaxAssessmentDecisionType = 'final' | 'reassessment'
export interface TaxAssessmentNotice {
id: string
company_id: string
user_id: string | null
fiscal_period_id: string
decision_type: TaxAssessmentDecisionType
decision_date: string
payment_due_date: string
archived_at: string | null
created_at: string
updated_at: string
fiscal_period?: Pick<FiscalPeriod, 'id' | 'name' | 'period_start' | 'period_end'>
}
// 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
// Durable opt-out for system deadlines: hidden everywhere, never
// recreated by the generator or the backfill cron.
dismissed_at: string | null
linked_report_type: string | null
linked_report_period: Record<string, unknown> | null
tax_assessment_notice_id: string | null
// Relations
customer?: Customer
}
// ============================================================
// Push Notification Types
// ============================================================
// 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
missing_underlag_enabled: boolean
email_digest_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'
| 'skv_kvittens'
| 'skv_connection_expired'
| 'bookkeeping_digest'
// ============================================================
// 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
}
// ============================================================
// Invoice Inbox Types
// ============================================================
// 'processing' is the staged-upload in-flight state: the row exists (instant
// receipt ack) but AI extraction has not landed yet; extracted_data is NULL
// until the deferred worker (or the sweep cron) flips it to 'received'.
export type InboxItemStatus = 'received' | 'processing' | 'error'
export type InboxItemSource = 'email' | 'upload' | 'whatsapp' | 'mail_hunt' | 'peppol'
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 type CompanyInboundDomainStatus = 'pending' | 'verified' | 'failed'
// A DNS record the user must publish to verify their custom inbound domain
// (verbatim from the Resend domains API).
export interface InboundDomainDnsRecord {
record: string
name: string
value: string
type: string
ttl: string
status: string
priority?: number
}
export interface CompanyInboundDomain {
id: string
company_id: string
domain: string
status: CompanyInboundDomainStatus
resend_domain_id: string | null
dns_records: InboundDomainDnsRecord[] | null
verified_at: string | null
last_checked_at: string | null
created_at: string
updated_at: string
}
export type CompanySendingDomainStatus = 'pending' | 'verified' | 'failed'
// A DNS record the user must publish to verify their custom sending domain
// (verbatim from the Resend domains API; same shape as the inbound records).
export type SendingDomainDnsRecord = InboundDomainDnsRecord
// Opt-in per-company sender identity for invoice email. Only a row with
// status = 'verified' AND enabled = true changes the From header; everything
// else falls back to the platform sender.
export interface CompanySendingDomain {
id: string
company_id: string
domain: string
status: CompanySendingDomainStatus
sender_local_part: string
sender_name: string | null
enabled: boolean
resend_domain_id: string | null
dns_records: SendingDomainDnsRecord[] | null
verified_at: string | null
last_checked_at: string | null
created_at: string
updated_at: string
}
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
// Sender-declared kind from the +lev / +ver plus-address tag (migration
// 20260901210000). Wins over extracted_data.documentKind in the inbox UI.
kind_hint?: 'supplier_invoice' | 'receipt' | 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
// WhatsApp channel (migration 20260802092000). whatsapp_message_id links
// back to the delivering chat message; channel_context holds verified
// human answers from the chat (kept OUT of extracted_data on purpose:
// retry-extraction overwrites that container wholesale).
whatsapp_message_id?: string | null
channel_context?: InboxChannelContext | 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
}
// Chat-sourced context attached to an inbox item. `raw_answer` + timestamps
// double as the Skatteverket representation documentation trail.
export interface InboxChannelContext {
/**
* Which intake wrote this. 'mail_hunt' rows carry the mail_* fields below;
* everything else on this type belongs to the WhatsApp branch and is absent
* on them.
*/
channel: 'whatsapp' | 'mail_hunt' | 'peppol'
/** Set by lib/invoices/peppol-inbox-delivery.ts: provenance of a received e-invoice. */
peppol_provider?: string | null
/** The provider's id for the received document (Qvalia integrationId). */
peppol_document_id?: string | null
peppol_document_type?: 'Invoice' | 'CreditNote' | null
peppol_sender_endpoint?: string | null
/** Archived exact UBL XML, when the inbox document is a rendering (embedded PDF) instead. */
peppol_xml_document_id?: string | null
/** Set by lib/receipt-hunt/ingest.ts: which mailbox the receipt came out of. */
mail_mailbox?: string | null
mail_provider?: 'gmail' | 'microsoft' | null
mail_subject?: string | null
mail_from?: string | null
mail_received_at?: string | null
caption?: string | null
company_selected_via?: 'button' | 'list' | 'numbered' | 'pin' | 'default' | 'single'
representation?: {
participants: { name: string; company: string | null }[]
purpose: string | null
event_date: string | null
raw_answer: string
answered_at: string
/** True when the user answered `nej` (or the LLM read a denial): the
* receipt is NOT representation and the question is settled. */
denied?: boolean
}
user_note?: string | null
/** What the user actually typed when answering a context question, kept
* next to the LLM paraphrase in user_note. The paraphrase is what renders;
* this is the durable human answer, mirroring the representation branch
* (whatsapp_messages.body_text is purged at 90 days, so it is no trail). */
context_answer?: {
raw_answer: string
answered_at: string
}
quality?: {
resend_requested_at: string
resent?: boolean
/** Set on the OLD item when a re-sent, sharper file created a fresh item
* (WORM archive + anchored-doc invariant forbid swapping the document
* out from under the original). */
superseded?: boolean
}
pending_question?: {
type: 'representation' | 'context' | 'resend'
asked_at: string
status: 'open' | 'answered' | 'moved_to_app'
}
}
// ============================================================
// WhatsApp Channel Types (migrations 20260802090000/091000)
// ============================================================
export interface WhatsAppPhoneLink {
id: string
user_id: string
phone_hash: string
phone_enc: string
phone_masked: string
wa_profile_name: string | null
default_company_id: string | null
last_company_id: string | null
verified_at: string
revoked_at: string | null
muted_at: string | null
last_message_at: string | null
created_at: string
updated_at: string
}
export type WhatsAppConversationState =
| 'idle'
| 'awaiting_company'
| 'awaiting_representation'
| 'awaiting_context'
| 'awaiting_resend'
export interface WhatsAppConversation {
id: string
phone_link_id: string
state: WhatsAppConversationState
context: Record<string, unknown>
company_id: string | null
service_window_expires_at: string | null
debounce_until: string | null
pending_ack: boolean
last_inbound_at: string | null
last_outbound_at: string | null
created_at: string
updated_at: string
}
export type WhatsAppMessageProcessingStatus =
| 'received'
| 'processing'
| 'done'
| 'skipped'
| 'error'
export interface WhatsAppMessage {
id: string
direction: 'inbound' | 'outbound'
wamid: string | null
sender_phone_hash: string | null
phone_link_id: string | null
conversation_id: string | null
message_type: string
body_text: string | null
media_id: string | null
media_mime: string | null
media_sha256: string | null
media_filename: string | null
raw_payload: Record<string, unknown> | null
processing_status: WhatsAppMessageProcessingStatus
attempts: number
error_message: string | null
inbox_item_id: string | null
delivery_status: string | null
correlation_id: string | null
/** When a combined burst ack (M4/M5) covered this ingested row.
* NULL = not yet acked (the burst winner's work queue). */
acked_at: string | null
created_at: string
updated_at: string
}
// ============================================================
// 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
}
// ============================================================
// VAT Declaration Types (Momsdeklaration)
// ============================================================
// VAT period type
export type VatPeriodType = MomsPeriod
// 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
/**
* Period debit/credit totals for the two reverse-charge INPUT VAT accounts,
* keyed by account number: 2645 (beräknad ingående moms på förvärv från
* utlandet) and 2647 (ingående moms, omvänd betalningsskyldighet i Sverige).
*
* Carried so a caller that reads the declaration over HTTP can hand
* `runVatDeclarationChecks` its optional per-account totals and get the sharp
* RC_INPUT_VAT_MISMATCH comparison (rutor 30-32 against 2645/2647) instead of
* the ruta 48 fallback, which ordinary debiterad ingående moms on 2641 masks.
* Only this pair travels, not the whole totals map: the check reads nothing
* else, and the response stays small rather than publishing every VAT account
* balance in the period.
*
* Optional because it crosses a JSON boundary. A client parsing a response
* from an older deploy must fall back to the ruta 48 form instead of reading
* absent accounts as zero, which would invert the check into a false alarm.
* Rebuild the map with `rcInputTotalsFromDeclaration()`
* (lib/reports/vat-declaration.ts), never by hand.
*/
rcInputAccountTotals?: Record<string, { debit: number; credit: number }>
/**
* Net debit balance of the reverse-charge BASIS accounts (44xx/45xx),
* grouped per momssats: r25/r12/r6. Carried so a caller that reads the
* declaration over HTTP can hand `withRcBasisGapFindings` its downgrade
* evidence (lib/reports/vat-filing-gate.ts): rutor 20-24 are partitioned by
* purchase type, not rate, so the per-rate identity against rutor 30-32 is
* only computable from these account-level figures.
*
* Optional because it crosses a JSON boundary: a client parsing a response
* from an older deploy must keep the blocking per-voucher behavior rather
* than fabricate zeros, which would read as "no basis booked at any rate"
* and block correct periods. Produced by `rcBasisTotalsByRate()`, never by
* hand.
*/
rcBasisByRate?: { r25: number; r12: number; r6: number }
// 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
}
}
}
// 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
}
// ============================================================
// Document Archive Types
// ============================================================
export type DocumentUploadSource =
| 'camera'
| 'file_upload'
| 'email'
| 'e_invoice'
| 'scan'
| 'api'
| 'system'
| 'whatsapp'
/** Fetched by the receipt hunt out of a connected mailbox. */
| 'mail_hunt'
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
}
// ============================================================
// 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'
| 'COMMITTED_AT_OVERRIDE'
| 'RESET_SNAPSHOT'
export interface AuditLogEntry {
id: string
// Nullable in the database and genuinely null in practice: write_audit_log()
// falls back to auth.uid(), which is NULL for a service-role or global write
// (the company-less salary_payroll_config rows are the standing example).
user_id: string | null
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' | 'agent_chat' | 'system' | null
actor_label: string | null
old_state: Record<string, unknown> | null
new_state: Record<string, unknown> | null
description: string | null
created_at: string
}
// ============================================================
// Voucher Gap Detection
// ============================================================
export interface VoucherGap {
gap_start: number
gap_end: number
series: string
}
export interface SequenceMismatch {
series: string
sequenceCounter: number
actualMax: number
}
// ============================================================
// Year-End Closing Types (Årsbokslut)
// ============================================================
/**
* Stable machine codes for year-end readiness blockers. One code per
* blockers.push site in validateYearEndReadiness: the wizard matches on
* these to attach remediation links, so codes must never be renamed once
* shipped. The Swedish message stays the display text.
*/
export type YearEndBlockerCode =
| 'PERIOD_NOT_FOUND'
| 'PERIOD_NOT_ENDED'
| 'PERIOD_ALREADY_CLOSED'
| 'CLOSING_ENTRY_EXISTS'
| 'DRAFT_ENTRIES'
| 'UNEXPLAINED_VOUCHER_GAP'
| 'SEQUENCE_COUNTER_BEHIND'
| 'TRIAL_BALANCE_UNBALANCED'
| 'CONTINUITY_MISMATCH'
| 'NEXT_PERIOD_HAS_IB'
| 'KONTANTMETOD_CUTOFF_REQUIRED'
| 'KONTANTMETOD_CUTOFF_CHECK_FAILED'
| 'UNBOOKED_TRANSACTIONS'
| 'UNBOOKED_CHECK_FAILED'
export interface YearEndBlocker {
code: YearEndBlockerCode
/** Swedish, user-facing: bokslut is a stays-Swedish surface. */
message: string
}
export interface YearEndValidation {
ready: boolean
/** Blocking errors with stable machine codes. */
blockers: YearEndBlocker[]
/** Blocker messages only; mirrors `blockers`. Kept so existing consumers
* of the string list (v1 compliance check, MCP tool) stay unchanged. */
errors: string[]
warnings: string[]
draftCount: number
voucherGaps: VoucherGap[]
unexplainedGaps: VoucherGap[]
sequenceMismatches: SequenceMismatch[]
trialBalanceBalanced: boolean
/**
* Bank transactions in the period with no verifikat (untriaged +
* business-confirmed-but-unbooked). Blocking: lockPeriod refuses to lock
* over them, so surfacing the count here stops executeYearEndClosing from
* aborting mid-flow at the lock step. Optional: absent on the early
* period-not-found return.
*/
unbookedTransactionCount?: number
}
export interface YearEndPreview {
netResult: number
closingAccount: string
closingAccountName: string
closingLines: CreateJournalEntryLineInput[]
resultAccountSummary: { account_number: string; account_name: string; amount: number }[]
currencyRevaluation: CurrencyRevaluationPreview | null
/**
* True when an aktiebolag is about to close a profit year with no tax
* account (89xx except 8999) among the accounts being closed. Advisory
* only, never a blocker: zero tax is legitimate with underskottsavdrag.
*/
bolagsskattMissing: boolean
}
export interface YearEndResult {
closingEntry: JournalEntry
nextPeriod: FiscalPeriod
openingBalanceEntry: JournalEntry
revaluationEntry: JournalEntry | null
/**
* Year-open omföring av föregående års resultat (Dr 2099 / Cr 2098) posted
* into the new period so 2099 "Årets resultat" starts the year at zero.
* Aktiebolag only; null for enskild firma or when 2099 carried no balance.
* The further disposition 2098 → 2091/2898 is the stämma's decision and is
* intentionally left to a separate step.
*/
resultAppropriationEntry: JournalEntry | null
/**
* True when the year-open omföring (2099 → 2098) was attempted but threw.
* The close + IB are already valid and immutable, so the failure is
* non-fatal to the year-end itself, but it leaves 2099 carrying the prior
* result into the new period, which is non-compliant. Surfaced so the UI can
* alert the user (and an alertable log line fires server-side); the
* retroactive catch-up script (scripts/repair-result-appropriation.ts) then
* posts the missing omföring. False on success or when there was nothing to do.
*/
resultAppropriationFailed: boolean
/**
* 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'
/** Read type includes historical per-asset tax-method values retained on
* disposed rows. New and active assets may only be written as linear. */
export type DepreciationMethod =
| 'linear'
| 'declining_balance_30'
| 'declining_balance_20'
| 'restvardesavskrivning_25'
export type WritableDepreciationMethod = 'linear'
export type AssetDisposalType = 'sale' | 'scrap' | 'business_transfer'
export type AssetJamkningDirection = 'increase' | 'decrease' | 'none' | 'transferred'
/**
* 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
/** Deprecated legacy field. New tax depreciation is pooled per fiscal
* period and ordinary per-asset depreciation is linear. */
restvarde_target: number | null
disposed_at: string | null
disposed_proceeds: number | null
/** How the asset left the register. Null for legacy disposal records. */
disposal_type?: AssetDisposalType | null
/** Posted voucher that atomically completed the disposal. */
disposal_journal_entry_id?: string | 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
/** Absolute input VAT adjustment under ML (2023:200), chapter 15. */
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
/** Current-law adjustment metadata. Old month fields remain for legacy rows. */
jamkning_direction?: AssetJamkningDirection | null
jamkning_remaining_years?: number | null
jamkning_total_years?: number | null
jamkning_original_deduction_percent?: number | null
jamkning_new_deduction_percent?: 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
}
// ============================================================
// 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
}
// ============================================================
// 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
}
// ============================================================
// 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
/**
* Payment rail the source already knows structurally (e.g. the Stripe feed's
* balance-transaction type). Beats every ingest-side heuristic; leave unset
* to let classifyTransactionMethod() derive it from codes/description/MCC.
*/
transaction_method?: TransactionMethod | null
/** ISO 20022 bank transaction code from PSD2, verbatim (e.g. PMNT-CCRD-POSD). */
bank_transaction_code?: string | null
/** ASPSP-proprietary transaction code from PSD2, verbatim. */
proprietary_bank_transaction_code?: 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
/** The bank_file_imports batch id to stamp on every inserted row
* (transactions.bank_file_import_id). Set by the bank-file import paths
* so "undo this import" can scope its bulk delete to exactly this batch.
* Omitted by every other caller (PSD2 sync, MCP): those rows stay NULL. */
bankFileImportId?: string
}
/** 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 }
/**
* SHADOW-MODE counter: rows that an enforcing same-feed scope-drift dedup rule
* WOULD have treated as re-imports (IBAN-drift re-imports the external_id
* check misses). These are still imported: the field only measures how often
* the rule would fire, so it can be validated on real data before enforcement.
*/
shadow_scope_drift_candidates?: number
/**
* SHADOW-MODE counter: rows that an enforcing date-drift dedup rule WOULD have
* treated as re-imports: a twin with the same öre and an account-compatible,
* bridging (or cross-channel count-symmetric) match one day away, which the
* exact-date content bridge misses. Still imported; the field only measures
* how often the rule would fire, for validation before any enforcement.
*/
shadow_date_drift_candidates?: number
}
// ── Webshop orders (Orders page; synced by the woocommerce/shopify extensions) ──
export type WebshopPlatform = 'woocommerce' | 'shopify'
export type WebshopOrderRowType = 'order' | 'refund'
/** One VAT rate bucket of an order, in the order's currency. */
export interface WebshopVatBreakdownLine {
/** Percent as a number (25, 12, 6, 0). */
rate: number
net: number
tax: number
}
/** One order line, in the order's currency. */
export interface WebshopOrderLineItem {
name: string
quantity: number
total: number
total_tax: number
/** Percent; null when the rate could not be resolved from tax_lines. */
vat_rate: number | null
}
/** Row shape of public.webshop_orders. */
export interface WebshopOrder {
id: string
company_id: string
user_id: string
platform: WebshopPlatform
/** Normalized store host(+path); the identity frozen into external_id. */
store_scope: string
store_label: string | null
/** Soft pointer to the platform's *_connections row (no FK). */
connection_id: string | null
row_type: WebshopOrderRowType
parent_order_id: string | null
/** Frozen feed scheme: woo_{scope}_order_{id} / woo_{scope}_refund_{id}. */
external_id: string
platform_order_id: string
order_number: string
/** Raw platform status (pending/processing/completed/refunded/...). */
status: string
is_paid: boolean
order_date: string
paid_date: string | null
currency: string
/** Gross incl. tax and shipping; negative on refund rows. */
total: number
total_tax: number
/** Null until the FX rate resolves; booking is blocked while null. */
total_sek: number | null
exchange_rate: number | null
vat_breakdown: WebshopVatBreakdownLine[]
line_items: WebshopOrderLineItem[]
customer_name: string | null
customer_company: string | null
customer_email: string | null
/** Best effort; must be user-confirmed before use in legal fields. */
customer_orgnr: string | null
/** Billing country, ISO 3166-1 alpha-2; drives the export/EU 0%-sale hint. */
customer_country: string | null
payment_method: string | null
payment_method_title: string | null
gateway_reference: string | null
/** Order rows: informational sum of refunds seen so far. */
refunded_total: number
journal_entry_id: string | null
invoice_id: string | null
/** Same money event already imported by the legacy transactions feed. */
legacy_transaction_id: string | null
/** Financial delta arrived from the store after booking froze this row. */
remote_changed_after_freeze: boolean
/** User marked the row as booked/handled outside the integration. */
manually_booked_at: string | null
manually_booked_by: string | null
/** Optional informational reference to the existing verifikat. */
manually_booked_journal_entry_id: string | null
created_at: string
updated_at: string
}
/** Per-payment-method booking policy in webshop_store_settings. */
export type WebshopPaymentMethodPolicy =
| { mode: 'book'; account: string }
| { mode: 'invoice' }
/** Row shape of public.webshop_store_settings. */
export interface WebshopStoreSettings {
id: string
company_id: string
user_id: string
platform: WebshopPlatform
store_scope: string
payment_method_account_map: Record<string, WebshopPaymentMethodPolicy>
created_at: string
updated_at: string
}
// ── Invoice extraction (used by invoice-inbox extension and core utils) ──
export type ExtractedDocumentKind =
| 'receipt'
| 'supplier_invoice'
| 'government_letter'
| 'other'
export type ExtractedPaymentMethod = 'card' | 'swish' | 'cash' | 'invoice' | 'other'
export type ExtractedMerchantCategory =
| 'restaurant'
| 'cafe'
| 'taxi'
| 'parking'
| 'fuel'
| 'grocery'
| 'hotel'
| 'other'
export type ExtractedLegibility = 'good' | 'partial' | 'unreadable'
export interface InvoiceExtractionResult {
// Classification fields (2026-08): optional because extractions stored
// before they existed lack them. They route UI emphasis and clarifying
// questions only: never bookings.
documentKind?: ExtractedDocumentKind | null
merchantCategory?: ExtractedMerchantCategory | null
legibility?: ExtractedLegibility | null
purchaseTime?: string | null
payment?: { method: ExtractedPaymentMethod | null; cardLast4: string | null } | null
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
// Service/coverage window the invoice charges for: drives the
// periodisering prefill. Optional: extractions from before the field
// existed lack it.
servicePeriodStart?: string | null
servicePeriodEnd?: string | null
}
lineItems: ExtractedInvoiceLineItem[]
totals: {
subtotal: number | null
vatAmount: number | null
total: number | null
// Öresavrundning line on Swedish receipts; negative when rounded down.
roundingAmount?: number | null
}
vatBreakdown: VatBreakdownItem[]
// Amounts visible on non-invoice documents (bankintyg, avtal, contracts)
// with no invoice-style total. Matching hint only, never booked. Optional:
// extractions from before the field existed lack it.
prominentAmounts?: ProminentAmount[]
// 'prominent' = totals.total was promoted from the document's single
// prominent amount (promoteSingleProminentAmount), not read off an invoice.
// Matching treats such a total as fallback-grade; cleared when a user edits
// totals.total.
totalSource?: 'prominent' | null
confidence: number
suggestedTemplateId?: string
// Set by the caller (not the model) when a long PDF was sliced before
// extraction: fields were read from `analyzed` of `total` pages (the first
// pages plus the last, where totals usually sit).
pages?: { total: number; analyzed: number }
}
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
}
/** One amount printed on a non-invoice document, with the document's own label. */
export interface ProminentAmount {
amount: number
label: string | null
}
// 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
}
/** Top expense accounts (BAS classes 4-7) for the period, largest first. */
topExpenseAccounts: { account_number: string; account_name: string; total: number }[]
topSuppliers: { supplier_id: string; supplier_name: string; total: number }[]
/**
* Foreign-currency supplier invoices excluded from `topSuppliers` because
* they had neither a SEK total nor an exchange rate. Same contract as
* `unconverted_fx_count` on the supplier ledger: excluded rows are counted,
* not silently dropped.
*/
topSuppliersUnconvertedFxCount: 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 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' | 'unpaid_leave' | 'vacation' | 'semesterersattning'
| 'traktamente_taxfree' | 'traktamente_taxable'
| 'mileage_taxfree' | 'mileage_taxable'
| 'net_deduction_advance' | 'net_deduction_union' | 'net_deduction_benefit_payment'
| 'net_deduction_other'
| 'oresavrundning'
| '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
// Arbetsschema-lite: weekly schedule driving the hourly/daily divisors
// (173/21 at the defaults). employment_degree keeps prorating base salary;
// these ONLY drive divisors.
hours_per_week: number
workdays_per_week: 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
// Dimensions PR8: bag ({sie_dim_no: code}) applied to this employee's P&L
// cost lines when a salary run is booked. jsonb DEFAULT '{}'. Optional in
// TS for pre-migration fixtures.
default_dimensions?: Record<string, string>
is_active: boolean
created_at: string
updated_at: string
}
/**
* An employee as returned by the read surfaces (`/api/salary/employees`,
* `/api/salary/employees/{id}`, `/api/salary/runs/{id}`,
* `/api/salary/runs/{id}/employees/{employeeId}`) and by the v1 REST write
* responses.
*
* `personnummer` is deliberately ABSENT: the column holds AES-256-GCM
* ciphertext, and the display form is carried under the separate, read-only
* `personnummer_masked` key. Returning the mask under the writable key name
* would let a client that reads an object and writes it back post the mask
* into the encrypt path, so the two names never collide by construction.
*/
export type EmployeeMasked = Omit<Employee, 'personnummer'> & {
personnummer_masked: 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
tax_withheld_override: number | null
net_deductions: number
net_salary: number
avgifter_rate: number
avgifter_amount: number
avgifter_amount_override: number | null
avgifter_basis: number
avgifter_basis_override: number | null
override_reason: string | null
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
}
/**
* A `pending_operations` row a chat conversation staged and nobody has answered
* yet, as returned by GET /api/agent/conversations/[id] and by the /chat/[id]
* server page.
*
* Approval cards ride on streamed events that are never persisted, so this is
* what lets a resumed thread show its still-open proposal instead of silently
* dropping it. `operation_type` is the bare action name as stored
* ('categorize_transaction'), not the prefixed MCP tool name.
*/
export interface StoredStagedOperation {
id: string
operation_type: string
title?: string | null
risk_level?: string | null
preview_data?: unknown
params?: Record<string, unknown> | null
}
// ============================================================
// Körjournal (mileage trips)
// ============================================================
export type MileageVehicleType = 'own_car' | 'company_car_fossil' | 'company_car_electric'
export type MileageTripStatus = 'draft' | 'booked'
/** A `mileage_trips` row: one business trip in the körjournal. */
export interface MileageTrip {
id: string
company_id: string
user_id: string
employee_id: string | null
trip_date: string
vehicle_type: MileageVehicleType
vehicle_registration: string | null
odometer_start: number | null
odometer_end: number | null
distance_km: number
from_location: string
to_location: string
purpose: string
visited: string | null
is_round_trip: boolean
status: MileageTripStatus
journal_entry_id: string | null
salary_run_id: string | null
notes: string | null
created_via: 'manual' | 'mcp' | 'import'
created_at: string
updated_at: string
}
export interface CreateMileageTripInput {
trip_date: string
vehicle_type?: MileageVehicleType
vehicle_registration?: string | null
odometer_start?: number | null
odometer_end?: number | null
distance_km: number
from_location: string
to_location: string
purpose: string
visited?: string | null
is_round_trip?: boolean
employee_id?: string | null
notes?: string | null
created_via?: 'manual' | 'mcp' | 'import'
}
/** Per-vehicle-type aggregation of draft trips for a period. */
export interface MileagePeriodSummary {
vehicle_type: MileageVehicleType
trip_count: number
total_km: number
total_mil: number
rate_per_mil: number
amount: number
}