diff --git a/.claude/rules/bookkeeping.md b/.claude/rules/bookkeeping.md
index e18f8248..829c59f5 100644
--- a/.claude/rules/bookkeeping.md
+++ b/.claude/rules/bookkeeping.md
@@ -10,7 +10,7 @@ paths:
# Bookkeeping Domain Reference
-For Swedish accounting-law questions, use the domain skills (`swedish-vat`, `swedish-accounting-compliance`, `swedish-year-end-closing`, etc.). The accounting guard rails in the root `CLAUDE.md` always apply.
+For Swedish accounting-law questions, use the domain skills (`swedish-vat`, `swedish-accounting-compliance`, `swedish-year-end-closing`, etc.). The Hard Rules in the root `CLAUDE.md` always apply.
## Core Services (`lib/core/`)
diff --git a/.claude/skills/loop-verify/SKILL.md b/.claude/skills/loop-verify/SKILL.md
index 581155cd..41f5e8a1 100644
--- a/.claude/skills/loop-verify/SKILL.md
+++ b/.claude/skills/loop-verify/SKILL.md
@@ -10,7 +10,7 @@ the way our CI would. If any step fails, fix and rerun from the top — do not p
verified work.
## 0. Guard rails first (hard stops — no fix is worth breaking these)
-Read [Accounting Guard Rails](../../../CLAUDE.md#accounting-guard-rails). A change is **rejected outright** if it:
+Read [Hard Rules](../../../CLAUDE.md#hard-rules). A change is **rejected outright** if it:
- edits or deletes a `posted` journal entry (use `reverseEntry`/`correctEntry` — storno, never edit),
- makes an entry that doesn't balance, or sets a voucher number manually,
- writes to a locked/closed period, or deletes a retained document,
diff --git a/CLAUDE.md b/CLAUDE.md
index df776db6..bf966d12 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,196 +1,103 @@
# CLAUDE.md — Accounted
-## Project Overview
+Swedish accounting SaaS: double-entry bookkeeping under Swedish accounting law (Bokföringslagen) for sole traders (enskild firma) and limited companies (aktiebolag). Multi-tenant: users belong to companies via `company_members`; `teams` group companies for consultants.
-Accounted is a Swedish-focused accounting SaaS for sole traders (enskild firma) and limited companies (aktiebolag). It implements double-entry bookkeeping compliant with Swedish accounting law (Bokföringslagen), including VAT handling, tax reporting, and 7-year document retention. Multi-tenant: each user can own or be a member of multiple companies, optionally grouped into teams (for consultants).
-
-**Tech stack**: Next.js 16.1.5 (App Router), React 19.2.3, TypeScript 5 (strict), Zod 4, Supabase (PostgreSQL + RLS + email/password + TOTP MFA auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting, Docker (self-hosted).
-
-**Integrations**: Enable Banking (PSD2), TIC Identity, Anthropic SDK, AWS Bedrock, OpenAI, Resend, Sentry, Svix, web-push, Upstash Redis, Google Drive, JSZip, sharp, Framer Motion, Recharts, PDF.js, `@react-pdf/renderer`, xlsx, fuse.js, ics.
-
-**Path alias**: `@/*` maps to the project root. **Language**: All code, comments, and commit messages in English. **License**: AGPL-3.0-or-later.
+**Stack**: Next.js 16 (App Router), React 19, TypeScript 5 strict, Zod 4, Supabase (Postgres + RLS + auth), Tailwind 4 + shadcn/ui. Vercel-hosted is the primary target; Docker self-hosted must keep working but never at hosted's expense. Path alias `@/*` = repo root. All code, comments, and commits in English.
---
+## Hard Rules
+
+The accounting rules are Swedish law, enforced by DB triggers. Code that violates them fails at runtime; code that works around the triggers breaks legal compliance. Never do either.
+
+1. **Never edit or delete a posted journal entry.** Committed vouchers are immutable. Cancel with `reverseEntry()`; correct with `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`). Storno, never edit.
+2. **All journal writes go through `lib/bookkeeping/engine.ts`.** Never insert into journal tables directly: voucher numbers are assigned atomically by the `commit_journal_entry` RPC and must stay sequential, and gaps require documented explanations (BFNAR 2013:2 — `voucher_gap_explanations`).
+3. **Every entry balances**: `sum(debits) === sum(credits)`, both `> 0`.
+4. **Respect period locks.** DB triggers block writes to closed/locked periods and behind the company lock date. Don't work around them — fix the flow that tried to write there.
+5. **Never delete documents linked to posted entries** — 7-year retention is a legal requirement.
+6. **Money math is `Math.round(x * 100) / 100`.** Never `toFixed()` — it returns strings and rounds incorrectly, causing öre-level drift that breaks entry balance.
+7. **Account numbers are strings** (`'1930'`, never `1930`). They are identifiers, not quantities; arithmetic on them is always a bug.
+
+General prohibitions:
+
+- **Never modify an existing migration** — schemas already shipped; create a new migration. Never touch the enforcement triggers (migration 017); they are legally required.
+- **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`).
+- **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited.
+- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites.
+- **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation.
+- **Keep the diff scoped to the request.** No drive-by refactors of untouched code.
+- Never create a NUL/nul file: `\Accounted\NUL`.
+
+## When Uncertain
+
+- **Stop and ask; do not guess.** Especially for anything touching posted entries, the production database, money math, or Swedish tax law.
+- **Swedish domain questions are never answered from training data.** Load the matching `swedish-*` skill (vat, accounting-compliance, invoice-compliance, payroll, year-end-closing, sie-import-export, sru-filing, financial-reporting, asset-accounting, project-accounting, tax-planning, e-invoicing).
+- Scaffolding has skills — use them instead of improvising: `/erp-api-route` (API routes), `/supabase-migration` (migrations), `/create-extension` (extensions), `/frontend-design` (new UI), `vercel:deploy` (deployment).
+
+## Definition of Done
+
+A change is done when all of these hold — iterate until they do:
+
+1. `npm run lint` is clean and `npm test` passes (`npx vitest run
` while iterating).
+2. New or changed logic in `lib/` or `app/api/` has tests: auth 401, validation 400, 404, happy path; mock `@/lib/supabase/server`.
+3. Any change to a trigger, RPC, RLS policy, or DEFERRABLE constraint ships with a `*.pg.test.ts` (`npm run test:pg`).
+4. New UI strings exist in **both** `messages/sv.json` and `messages/en.json`.
+5. If you edited an atom `SKILL.md`, `npm run skills:generate` was run (CI's `skills:check` fails otherwise).
+6. `npm run check:guards` passes if you touched API routes.
+7. Commit is conventional (`feat:`/`fix:`/`refactor:`/`test:`/`docs:`), atomic, branched from `main`.
+
## Commands
```bash
-npm run dev # Start dev server (runs setup:extensions first)
+npm run dev # Dev server (runs setup:extensions first)
npm run build # Production build (runs setup:extensions first)
npm run lint # ESLint
-npm test # Run all Vitest tests
-npx vitest run # Run tests in a specific directory
+npm test # All Vitest tests
+npx vitest run # Tests in one directory
npm run test:pg # pg-real tests against real Postgres
+npm run check:guards # Ratchet guard (e.g. no hand-rolled route auth)
npm run setup:extensions # Regenerate extension registry from extensions.config.json
-npm run skills:generate # Regenerate agent_atom_registry seed migration after editing an atom SKILL.md
-npm run skills:check # CI guard: fail if an atom SKILL.md changed without regenerating the seed migration
+npm run skills:generate # Regenerate agent_atom_registry seed after editing an atom SKILL.md
```
----
+## Architecture
-## Key Architectural Relationships
-
-- **Multi-tenant model**: `companies` owns all business data. `company_members` links users to companies (owner/admin/member/viewer). `teams` group companies. Context resolved via `gnubok-company-id` cookie in `lib/supabase/middleware.ts`.
-- **All journal entry creation** routes through `lib/bookkeeping/engine.ts`. Lifecycle: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC). `createJournalEntry()` does both. Reversal: `reverseEntry()`. Correction: `correctEntry()` in `lib/core/bookkeeping/storno-service.ts`.
-- **API routes** emitting events must call `ensureInitialized()` (`lib/init.ts`) at module level to load extensions and wire handlers.
-- **Event bus** (`lib/events/bus.ts`) is a module-level singleton using `Promise.allSettled`. 50+ event types in `lib/events/types.ts`. Persisted to `event_log` table (30-day TTL).
-- **Supabase clients**: browser (`client.ts`), server cookies (`createClient()`), service role (`createServiceClient()`), cookieless service role for API keys (`createServiceClientNoCookies()`). Pagination: `fetchAllRows()`.
-- **Extension system**: Opt-in via `extensions.config.json`. Core runs with zero extensions.
-- **Types**: Shared types in `types/index.ts` (~3,100 lines). Import via `import type { T } from '@/types'`. Event types in `lib/events/types.ts`. Extension types in `lib/extensions/types.ts`.
-- **Error messages**: `lib/errors/get-error-message.ts` maps to Swedish (Zod → Postgres → HTTP → fallback).
-
----
+- **Journal entry lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC); `createJournalEntry()` does both. Everything accounting-shaped routes through this engine.
+- **Tenancy**: every business table has `company_id`. Active company resolves in `lib/supabase/middleware.ts`: `gnubok-company-id` cookie → `user_preferences.active_company_id` → first membership. RLS uses `user_company_ids()`; queries still filter by `company_id` explicitly (defense in depth — service-role paths have no RLS).
+- **Auth**: Supabase email+password + TOTP MFA, enforced **application-side**, not in RLS. `NEXT_PUBLIC_REQUIRE_MFA=true` on hosted; `NEXT_PUBLIC_SELF_HOSTED=true` disables MFA. API routes wrap `withRouteContext` — it is the only path that enforces MFA, so never hand-roll `supabase.auth.getUser()` in a route.
+- **Events**: `lib/events/bus.ts` is a module-level singleton. Any route that emits events must call `ensureInitialized()` (`lib/init.ts`) at module level — otherwise extension handlers are never wired and events silently go nowhere.
+- **Supabase clients**: browser `client.ts`, server `createClient()`, service role `createServiceClient()`, cookieless service role `createServiceClientNoCookies()` (lives in `lib/auth/api-keys.ts`; for API-key/MCP paths). Paginate with `fetchAllRows()` — PostgREST silently caps at 1000 rows.
+- **Extensions**: opt-in plugins in `extensions/general//`; `extensions.config.json` is the source of truth for what's enabled. Core must run with zero extensions.
+- **MCP server**: the bookkeeping engine is exposed as 100+ MCP tools (`extensions/general/mcp-server/`), authenticated by `gnubok_sk_` API keys (SHA-256, scoped, default 100 RPM per key).
+- **Types**: import from `@/types` (`types/index.ts`); event types in `lib/events/types.ts`.
+- **User-facing errors are Swedish**: map through `lib/errors/get-error-message.ts`.
+- **Cron**: hosted cron jobs live in `vercel.json`, authenticated via `verifyCronSecret()` (`lib/auth/cron.ts`).
## Repository Map
-- `lib/bookkeeping/` — Engine, entry generators, mapping, templates, BAS data
-- `lib/core/` — Period, year-end, storno, tax codes, audit, documents
-- `lib/events/` — Bus singleton, event types, event log handler
-- `lib/auth/` — API keys, require-auth/write, MFA, OAuth codes, invite tokens, cron, BankID
-- `lib/supabase/` — Clients, middleware, `fetchAllRows` pagination
-- `lib/api/` — Zod validation (`validateBody`/`validateQuery`), schemas
-- `lib/reports/` — Report generators (balance sheet, income statement, trial balance, GL, AR/supplier ledger, VAT declaration, SIE, INK2, NE-bilaga, KPI, salary, vacation, …)
-- `lib/invoices/`, `lib/transactions/`, `lib/import/` (SIE/bank/opening balance), `lib/documents/` (matchers)
-- `lib/providers/` — Fortnox, Bokio, Briox, BL, Visma (OAuth, retry, consent)
-- `lib/salary/` — Payroll engine, tax tables, AGI, KU, payslips, löneväxling, personnummer
-- `lib/reconciliation/`, `lib/tax/`, `lib/vat/` (VIES, MOMS box), `lib/deadlines/`, `lib/currency/` (Riksbanken), `lib/skatteverket/`, `lib/bankgiro/` (Luhn), `lib/calendar/` (ICS)
-- `lib/utils.ts` (`cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()`), `lib/logger.ts`
-- `app/(dashboard)/*` — pages; `app/api/*` — API routes; `supabase/migrations/` — schema; `extensions/general/*` — opt-in extensions
-- Path-scoped detail lives in `.claude/rules/` (see **Path-scoped rules** below).
-
----
-
-## Multi-Tenant Architecture
-
-- **companies**: Business unit. All business data has a `company_id` column.
-- **company_members**: Roles `owner`/`admin`/`member`/`viewer`, source `direct`|`team`.
-- **teams**: Consultant grouping. Team members auto-sync to company_members via DB triggers.
-- **user_preferences**: Stores `active_company_id` and `locale`.
-
-**Context resolution** (`lib/supabase/middleware.ts`): cookie → `user_preferences.active_company_id` → first membership. RLS uses `user_company_ids()` helper.
-
-**Invitations**: `company_invitations`/`team_invitations` with `gnubok_inv_` tokens (SHA-256, 7-day TTL). See `lib/auth/invite-tokens.ts`.
-
----
-
-## Authentication
-
-Supabase Auth: email+password (primary), magic link (fallback), TOTP MFA. MFA enforced **application-side** (middleware + API routes), not in RLS.
-
-- `NEXT_PUBLIC_SELF_HOSTED=true` → MFA never enforced
-- `NEXT_PUBLIC_REQUIRE_MFA=true` → middleware redirects to `/mfa/enroll` or `/mfa/verify` until AAL2
-
-**API route auth** (`lib/auth/require-auth.ts`): `requireAuth()` returns `{ user, supabase, error }`, enforces MFA on hosted.
-**API keys** (`lib/auth/api-keys.ts`): SHA-256 hashed, `gnubok_sk_` prefix. Scoped via `TOOL_SCOPE_MAP`. Rate limited 100 RPM via `validate_and_increment_api_key` RPC.
-**Cron auth** (`lib/auth/cron.ts`): `verifyCronSecret()` constant-time comparison.
-
----
-
-## Core Bookkeeping Engine
-
-The engine (`lib/bookkeeping/engine.ts`) is the most critical system. All accounting flows route through it.
-
-**Lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC). `createJournalEntry()` does both. `reverseEntry()` for storno; `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`) for corrections.
-
-**Engine files**: `transaction-entries.ts`, `invoice-entries.ts` (with `generatePerRateLines()` for mixed-rate), `supplier-invoice-entries.ts`, `vat-entries.ts`, `currency-revaluation.ts`, `mapping-engine.ts`, `booking-templates.ts`/`counterparty-templates.ts`, `propose-payment-lines.ts`/`propose-send-lines.ts`, `handlers/supplier-invoice-handler.ts`.
-
-**BAS data** (`lib/bookkeeping/bas-data/`): Full BAS 2026 chart by class (1–8) + SRU mapping.
-
-Key BAS accounts, VAT treatments, VAT declaration rutor, and `lib/core/` services are in `.claude/rules/bookkeeping.md`. For accounting-law questions use the Swedish domain skills.
-
----
-
-## Accounting Guard Rails
-
-These rules exist for legal compliance, enforced by database triggers. **Never violate them.**
-
-1. **Committed entries are immutable.** Once `status: 'posted'`, cannot be edited or deleted (DB trigger).
-2. **Never delete posted entries.** Use `reverseEntry()` (storno) to cancel.
-3. **Every entry must balance.** `sum(debits) === sum(credits)`, both `> 0`.
-4. **Voucher numbers are sequential.** Assigned atomically via `commit_journal_entry` DB RPC. Never set manually.
-5. **Voucher gap documentation.** BFNAR 2013:2 requires documented explanations for gaps (`voucher_gap_explanations` table, `detect_voucher_gaps` RPC).
-6. **Period lock enforcement.** DB trigger blocks writes to closed/locked periods. Company-wide lock date enforced via `enforce_company_lock_date()` trigger.
-7. **7-year document retention.** DB triggers prevent deletion of documents linked to posted entries.
-8. **Storno, never edit.** Use `correctEntry()` from `lib/core/bookkeeping/storno-service.ts`.
-9. **Use `Math.round(x * 100) / 100`** for monetary calculations. Never `toFixed()`.
-10. **Always use engine functions.** Never insert directly into journal tables.
-11. **Account numbers are strings.** `'1930'`, never `1930`.
-
----
-
-## Extension System
-
-Extensions are opt-in plugins in `extensions/general//`, controlled by `extensions.config.json`. Core runs with zero extensions. `npm run setup:extensions` generates static imports in `lib/extensions/_generated/` (auto via `predev`/`prebuild`). Extensions **cannot** use dynamic imports.
-
-**Enabled** (`extensions.config.json`): `enable-banking` (PSD2), `email` (Resend), `arcim-migration`, `tic` (org lookup), `mcp-server`, `cloud-backup` (Google Drive), `skatteverket`, `invoice-inbox`, `document-extraction`. **Present but disabled**: `calendar`, `push-notifications`, `example-logger` (plus the `_example-branding` template).
-
-- **Registration** (`lib/extensions/registry.ts`): Singleton. `register()` wires handlers. `get(id)`, `getAll()`, `getByCapability(key)`.
-- **Context** (`lib/extensions/context-factory.ts`): `ExtensionContext` = `userId`, `companyId`, `extensionId`, `supabase`, `emit()`, `settings`, `storage`, `log`, `services`.
-- **Creating**: use the `/create-extension` skill, or `npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "..."`.
-
----
-
-## MCP Server & API Keys
-
-Accounted exposes its bookkeeping engine as an MCP server (`extensions/general/mcp-server/`) for Claude Desktop/Code — 90+ tools, JSON-RPC 2.0, endpoint `/api/extensions/ext/mcp-server/mcp`, OAuth 2.1 for Claude connectors. npm bridge: `packages/gnubok-mcp` (`npx gnubok-mcp`).
-
-**API keys** (`lib/auth/api-keys.ts`, `api_keys` table): SHA-256, `gnubok_sk_` prefix, scoped via `TOOL_SCOPE_MAP`, 100 RPM via `validate_and_increment_api_key` RPC. `createServiceClientNoCookies()` — all queries filter by `company_id` (defense in depth).
-
-Tool authoring conventions, the staged-operation completion-signal pattern, and OAuth details are in `.claude/rules/mcp-server.md`.
-
----
+- `lib/bookkeeping/` — engine, entry generators, mapping, templates, BAS 2026 data (`bas-data/`)
+- `lib/core/` — period, year-end, storno, tax codes, audit, documents
+- `lib/events/`, `lib/auth/`, `lib/supabase/`, `lib/api/` (Zod `validateBody`/`validateQuery`)
+- `lib/reports/` — balance sheet, income statement, trial balance, GL, ledgers, VAT, SIE, INK2, NE-bilaga, salary, …
+- `lib/invoices/`, `lib/transactions/`, `lib/import/`, `lib/documents/`, `lib/salary/`, `lib/reconciliation/`, `lib/tax/`, `lib/vat/`, `lib/providers/` (Fortnox/Bokio/Briox/BL/Visma), `lib/skatteverket/`, `lib/currency/`, `lib/bankgiro/`, `lib/deadlines/`, `lib/calendar/`
+- `lib/utils.ts` — `cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()`; `lib/logger.ts`
+- `app/(dashboard)/*` pages; `app/api/*` routes; `supabase/migrations/` schema; `extensions/general/*` plugins
## Testing
-**Framework**: Vitest 4, `node` env, tests in `__tests__/`. Scope: `lib/` and `app/api/`. No component/E2E tests.
+Vitest 4, `node` env, tests in `__tests__/`, scope `lib/` + `app/api/` (no component/E2E tests). Helpers in `tests/helpers.ts`: `createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest()`, `parseJsonResponse()`, plus fixture factories (`makeTransaction`, `makeJournalEntry`, `makeInvoice`, …). `vi.clearAllMocks()` + `eventBus.clear()` in `beforeEach`. Trigger/RPC/RLS behavior is tested in `*.pg.test.ts` against real Postgres, not with mocks.
-**Helpers** (`tests/helpers.ts`): `createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest()`, `parseJsonResponse()`, `createMockRouteParams()`, plus fixture factories (`makeTransaction`, `makeJournalEntry`, `makeInvoice`, `makeCustomer`, `makeSupplier`, `makeSupplierInvoice`, `makeFiscalPeriod`, etc.).
+## Detail Loads On Demand
-**Patterns**: Always mock `@/lib/supabase/server`. `vi.clearAllMocks()` + `eventBus.clear()` in `beforeEach`. Test auth (401), validation (400), 404, 500, happy path.
+Don't duplicate these here — they auto-load when you touch matching paths:
-**pg-real**: Parallel Vitest project for triggers/RPCs/RLS using real Postgres. File convention `*.pg.test.ts`. **Required**: any PR touching a trigger/RPC/RLS/DEFERRABLE must include or extend a `*.pg.test.ts`. (Details in `.claude/rules/database.md`.)
+- `.claude/rules/design.md` — design system, locked tokens (`app/**`, `components/**`)
+- `.claude/rules/i18n.md` — sv/en conventions, "stays Swedish" surfaces
+- `.claude/rules/api-routes.md` — `withRouteContext` route pattern, endpoint map (`app/api/**`)
+- `.claude/rules/database.md` — migration rules, key tables/RPCs/triggers, pg-real (`supabase/migrations/**`)
+- `.claude/rules/mcp-server.md` — MCP tool authoring, staged-operation pattern
+- `.claude/rules/bookkeeping.md` — BAS accounts, VAT treatments/rutor, `lib/core/` services
----
+## Decision Log
-## Skills, Git & CI
-
-**Skills**: Use `/frontend-design` for new UI, `vercel:deploy` for deployment, `/supabase-migration` for new migrations, `/erp-api-route` for new API routes, `/create-extension` for new extensions. Use the Swedish domain skills (`swedish-vat`, `swedish-accounting-compliance`, `swedish-invoice-compliance`, `swedish-payroll`, `swedish-year-end-closing`, `swedish-sie-import-export`, `swedish-sru-filing`, `swedish-financial-reporting`, `swedish-asset-accounting`, `swedish-project-accounting`, `swedish-tax-planning`, `swedish-e-invoicing`) for accounting domain questions. The `swedish-*`, `industry/*`, and `modifier/*` skills also ship as product atoms (see `.claude/rules/database.md`).
-
-**Git**: Conventional commits (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`). Atomic commits, branch from `main`.
-
-**CI**:
-- `.github/workflows/core-build.yml` — resets extensions to empty, runs build + test, verifies no core code imports from `@/extensions/` directly.
-- `.github/workflows/swedish-compliance-review.yml` — Swedish accounting compliance review on PRs touching bookkeeping/reports/tax logic.
-- `.github/workflows/docker-publish.yml` — pushes images to GHCR (`erp-mafia/erp-base`) on main.
-
----
-
-## Deployment
-
-- **Vercel (hosted)**: Cron jobs in `vercel.json` (deadline status, invoice reminders, tax deadlines, enable-banking sync, document verify, sandbox cleanup, event log cleanup, cloud-backup auto-sync).
-- **Docker (self-hosted)**: 4-stage Node 22 Alpine `Dockerfile` (standalone output) + `docker-compose.yml` (app + supercronic cron). `docker-entrypoint.sh` validates env vars and replaces build-time placeholders in `.next/static/`. Extension presets: `docker/extensions.{self-hosted,hosted}.json`.
-
-**Environment variables**:
-- **Required**: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET`
-- **Auth**: `NEXT_PUBLIC_REQUIRE_MFA` (set `true` on hosted), `NEXT_PUBLIC_SELF_HOSTED` (set `true` for Docker)
-- **Extension-specific** (only when enabled): `ENABLE_BANKING_APP_ID`/`ENABLE_BANKING_APP_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `RESEND_API_KEY`, `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`
-- **Optional**: `SENTRY_DSN`, `SENTRY_AUTH_TOKEN`
-
----
-
-## Path-scoped rules (`.claude/rules/`)
-
-Topic detail loads automatically when Claude touches matching files:
-
-- `design.md` — design context & locked design-system tokens (`app/**`, `components/**`)
-- `i18n.md` — bilingual sv/en conventions + "stays Swedish" surfaces (UI + `lib/email|invoices|reports|salary`)
-- `api-routes.md` — API route pattern + endpoint map (`app/api/**`)
-- `database.md` — migration rules, key tables/RPCs/triggers, `agent_atom_registry` (`supabase/migrations/**`)
-- `mcp-server.md` — MCP tool authoring conventions (`extensions/general/mcp-server/**`)
-- `bookkeeping.md` — BAS accounts, VAT treatments/rutor, `lib/core/` services (`lib/bookkeeping|core|reports|vat|invoices|salary`)
-
----
-
-## Other
-
-Never create a NUL/nul file: `\Accounted\NUL`.
+When you make a non-obvious choice — picked approach A over B, declined a dependency, stopped because a rule here forbade something — append one line to `dev_docs/DECISIONS.md`: `[YYYY-MM-DD] — `. Check that file before re-litigating a past decision.
diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx
index b757c2d2..22f7ca04 100644
--- a/app/(dashboard)/pending/page.tsx
+++ b/app/(dashboard)/pending/page.tsx
@@ -933,14 +933,6 @@ export default function PendingOperationsPage() {
- {/* First-time reviewers haven't necessarily used the AI chat that staged
- these — say what the buttons do and that ignoring a proposal is safe. */}
- {activeTab === 'pending' && (
-
- {t('explainer')} {t('auto_expiry_note')}
-
- )}
-
{showBulkControls && bulkEligible.length > 0 && (
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index b0cd3d0b..b69a94dc 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
+import Link from 'next/link'
import { AnimatePresence } from 'framer-motion'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -1836,7 +1837,15 @@ export default function TransactionsPage() {
}
setBatchProgress(null)
if (failures.length === 0) {
- toast({ title: 'Klart', description: `${successes} transaktioner ignorerade` })
+ toast({
+ title: 'Klart',
+ description: `${successes} transaktioner ignorerade`,
+ action: (
+
+ Bankavstämning
+
+ ),
+ })
} else {
toast({
title: 'Delvis klart',
@@ -2075,6 +2084,12 @@ export default function TransactionsPage() {
+ {/* The ignore flows tell users to "återställ under Bankavstämning" —
+ this is the path there. Bankavstämning has no nav entry of its own,
+ so without a link here the copy points at an unreachable place. */}
+
+ Bankavstämning →
+
diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts
index 32a85b45..e1ba37c2 100644
--- a/app/api/reconciliation/bank/run/route.ts
+++ b/app/api/reconciliation/bank/run/route.ts
@@ -24,7 +24,7 @@ export async function POST(request: Request) {
const validation = await validateBody(request, RunReconciliationSchema)
if (!validation.success) return validation.response
- const { date_from, date_to, account_number, dry_run } = validation.data
+ const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data
const accountNumber = account_number ?? '1930'
@@ -59,6 +59,10 @@ export async function POST(request: Request) {
// a secondary same-currency account must scope strictly to its own id.
includeUnassigned: Boolean(cashAccount?.is_primary),
dryRun: dry_run ?? false,
+ applyOnly: selected_matches?.map((m) => ({
+ transactionId: m.transaction_id,
+ journalEntryId: m.journal_entry_id,
+ })),
})
return NextResponse.json({
diff --git a/app/api/reconciliation/bank/unlink/route.ts b/app/api/reconciliation/bank/unlink/route.ts
index f66d1091..dde836a9 100644
--- a/app/api/reconciliation/bank/unlink/route.ts
+++ b/app/api/reconciliation/bank/unlink/route.ts
@@ -23,7 +23,7 @@ export async function POST(request: Request) {
if (!validation.success) return validation.response
const { transaction_id } = validation.data
- const result = await unlinkReconciliation(supabase, companyId, transaction_id)
+ const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts
index 99a020ca..08399533 100644
--- a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts
+++ b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts
@@ -41,16 +41,22 @@ const { runRecMock, statusMock } = vi.hoisted(() => ({
},
],
applied: 1,
- errors: [],
+ errors: 0,
}),
+ // The REAL ReconciliationStatus shape from lib/reconciliation. The mock used
+ // to return the registry's invented shape (matched_transactions, bank_balance,
+ // …), which hid that documented and actual payloads had drifted apart.
statusMock: vi.fn().mockResolvedValue({
- matched_transactions: 100,
- unmatched_transactions: 5,
- unmatched_gl_lines: 2,
- total_unmatched_amount: 1500,
- bank_balance: 50000,
- gl_balance: 48500,
+ bank_transaction_total: 48500,
+ gl_1930_balance: 98500,
+ gl_1930_period_movement: 47000,
+ gl_1930_opening_balance: 51500,
+ gl_1930_correction_adjustment: 0,
difference: 1500,
+ is_reconciled: false,
+ matched_count: 100,
+ unmatched_transaction_count: 5,
+ unmatched_gl_line_count: 2,
}),
}))
@@ -261,8 +267,12 @@ describe('GET /reconciliation/bank/status', () => {
)
expect(res.status).toBe(200)
const body = await res.json()
- expect(body.data.matched_transactions).toBe(100)
- expect(body.data.unmatched_transactions).toBe(5)
+ // Passthrough of the lib's ReconciliationStatus — assert the real field
+ // names so a registry/actual drift can never hide behind the mock again.
+ expect(body.data.matched_count).toBe(100)
+ expect(body.data.unmatched_transaction_count).toBe(5)
+ expect(body.data.bank_transaction_total).toBe(48500)
+ expect(body.data.is_reconciled).toBe(false)
})
it('rejects invalid date filter', async () => {
diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts
index 43aa5d86..d65d8eb4 100644
--- a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts
+++ b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts
@@ -55,7 +55,10 @@ const MatchOut = z.object({
const RunResponse = z.object({
matches: z.array(MatchOut),
applied: z.number().int(),
- errors: z.array(z.string()),
+ // Count of matches that failed to apply (DB error or lost an optimistic-lock
+ // race). Documented as z.array(z.string()) until 2026-07 — the lib has always
+ // returned a number.
+ errors: z.number().int(),
})
registerEndpoint({
@@ -73,12 +76,13 @@ registerEndpoint({
'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.',
'account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930.',
'Idempotency-Key is mandatory.',
- 'matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).',
+ 'A non-dry run applies EVERY match found, including fuzzy ones at confidence 0.75 — there is no internal confidence threshold. Dry-run first and review matches.confidence before applying.',
+ 'The 366-day window bound only applies when BOTH date_from and date_to are set; a single-sided or absent window scans full history.',
],
example: {
request: { date_from: '2026-05-01', date_to: '2026-05-31' },
response: {
- data: { matches: [], applied: 0, errors: [] },
+ data: { matches: [], applied: 0, errors: 0 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts
index dcee40e4..46b102b7 100644
--- a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts
+++ b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts
@@ -12,14 +12,27 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
+// Mirrors ReconciliationStatus from lib/reconciliation/bank-reconciliation.ts —
+// the handler passes that object straight through. This schema previously
+// documented a different, invented shape (matched_transactions, bank_balance,
+// total_unmatched_amount, …) that the endpoint never returned; any client coded
+// against it read undefined for every field except difference.
const StatusResponse = z.object({
- matched_transactions: z.number().int(),
- unmatched_transactions: z.number().int(),
- unmatched_gl_lines: z.number().int(),
- total_unmatched_amount: z.number(),
- bank_balance: z.number(),
- gl_balance: z.number(),
+ /** Sum of bank-feed transactions in the window (the bank side). */
+ bank_transaction_total: z.number(),
+ /** Full ledger balance on the account incl. opening balance — matches the balance sheet. */
+ gl_1930_balance: z.number(),
+ /** Ledger movement excluding opening balance — what `difference` compares against. */
+ gl_1930_period_movement: z.number(),
+ gl_1930_opening_balance: z.number(),
+ /** Net storno/correction activity in the window. Informational; included in the movement. */
+ gl_1930_correction_adjustment: z.number(),
+ /** bank_transaction_total − gl_1930_period_movement. */
difference: z.number(),
+ is_reconciled: z.boolean(),
+ matched_count: z.number().int(),
+ unmatched_transaction_count: z.number().int(),
+ unmatched_gl_line_count: z.number().int(),
})
registerEndpoint({
@@ -35,18 +48,22 @@ registerEndpoint({
'Running the matcher — that\'s POST `/reconciliation/bank/run`. Per-transaction detail — use the transaction list with `?status=unbooked`.',
pitfalls: [
'A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.',
- 'total_unmatched_amount is the absolute sum — positive even when the unmatched rows include both credits and debits.',
+ 'difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference.',
+ 'is_reconciled means |difference| < 0.01 for the window — an aggregate check, not a per-transaction guarantee.',
],
example: {
response: {
data: {
- matched_transactions: 142,
- unmatched_transactions: 3,
- unmatched_gl_lines: 2,
- total_unmatched_amount: 1850.0,
- bank_balance: 50000,
- gl_balance: 48150,
- difference: 1850,
+ bank_transaction_total: 48150,
+ gl_1930_balance: 98150,
+ gl_1930_period_movement: 48150,
+ gl_1930_opening_balance: 50000,
+ gl_1930_correction_adjustment: 0,
+ difference: 0,
+ is_reconciled: true,
+ matched_count: 142,
+ unmatched_transaction_count: 3,
+ unmatched_gl_line_count: 2,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
diff --git a/components/common/CommandPalette.tsx b/components/common/CommandPalette.tsx
index 586110ea..c98cc422 100644
--- a/components/common/CommandPalette.tsx
+++ b/components/common/CommandPalette.tsx
@@ -57,6 +57,7 @@ const PAGE_ENTRIES: Entry[] = [
{ id: 'rapport-moms', label: 'Visa rapport: Momsdeklaration', icon: BarChart3, href: '/reports/vat-declaration', keywords: 'rapport moms vat deklaration' },
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions' },
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
+ { id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' },
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
{ id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' },
{ id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' },
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index 6d54a1bd..a056070a 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -33,6 +33,10 @@ import {
Tags,
ChevronsUpDown,
Sparkles,
+ Percent,
+ Landmark,
+ CalendarClock,
+ FileCheck,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -105,20 +109,29 @@ type NavLabelKey =
| 'import'
| 'salary'
| 'employees'
+ | 'vat_declaration'
+ | 'skattekonto'
+ | 'deadlines'
+ | 'year_end'
| 'help'
| 'settings'
-// New nav layout (May 2026):
+// Nav layout (July 2026 — interaction-mode grouping, dev_docs/nav_ia_redesign.md
+// Phase 0): same routes as before, regrouped by what the user is doing.
// top-of-sidebar — CompanySwitcher (active company / org context).
-// top section — flat, no dropdown: Hem (agent), Underlag,
-// Transaktioner, Granskning.
-// four dropdowns — Försäljning, Inköp, Redovisning, Personal.
+// top section — flat, no dropdown: Hem, Assistent. Hem doubles as
+// the "what needs me" surface until a dedicated
+// /inbox exists.
+// four dropdowns — Arbeta (produce: the bookkeeping funnel first,
+// then invoices, supplier invoices, payroll),
+// Analys (KPI + reports), Data (master-data
+// registers + import/export), Skatt & bokslut
+// (statutory: VAT, tax account, deadlines, year-end).
// bottom-left popover — signed-in user's name + initial, opens upward
// to Inställningar, Hjälp, Support, Logga ut.
-// Help + Settings are NOT in `navItems` anymore; they live in the account
-// popover. KPI moved from main to redovisning. Pending stays visible at all
-// times — the inline badge carries the count.
-type GroupKey = 'top' | 'försäljning' | 'inköp' | 'redovisning' | 'personal'
+// Help + Settings are NOT in `navItems`; they live in the account popover.
+// Pending (Granskning) stays visible at all times — the badge carries the count.
+type GroupKey = 'top' | 'arbeta' | 'analys' | 'data' | 'skatt'
interface NavItem {
href: string
@@ -143,30 +156,42 @@ const navItems: NavItem[] = [
// Top section — flat list, always visible, no header
{ href: '/', labelKey: 'home', icon: Home, group: 'top' },
{ href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' },
- { href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'top' },
- { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'top' },
- { href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'top' },
- // Försäljning dropdown
- { href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'försäljning' },
- { href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' },
- { href: '/articles', labelKey: 'articles', icon: Tag, group: 'försäljning' },
- // Inköp dropdown
- { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'inköp' },
- { href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp' },
- // Redovisning dropdown
- { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'redovisning' },
- { href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' },
- { href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'redovisning' },
- { href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'redovisning', requiresDimensions: true },
- { href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' },
- { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
- { href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
- // Personal — "Beta" badge while we validate the end-to-end salary + AGI flow.
+ // Arbeta — everything the user produces. The bookkeeping funnel leads
+ // (Bokföring · Underlag · Transaktioner · Granskning — kept as separate
+ // rows until the unified workspace lands), then the transactional flows.
+ // Löner: "Beta" badge while we validate the end-to-end salary + AGI flow.
// employerOnly: shown to aktiebolag and to any employer (pays_salaries), so an
// enskild firma that hires staff gets payroll. Owner self-payroll stays
// blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #782
- { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', employerOnly: true, betaBadge: true },
- { href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', employerOnly: true, betaBadge: true },
+ { href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'arbeta' },
+ { href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta' },
+ { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' },
+ { href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' },
+ { href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
+ { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
+ { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true, betaBadge: true },
+ // Analys — read the numbers. KPI stays a separate row until the fused
+ // Rapporter surface (nav_ia_redesign §F) is built.
+ { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' },
+ { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' },
+ // Data — master-data registers + data plumbing. Anställda is a register
+ // (you edit an employee rarely, you run payroll monthly), so it lives here
+ // while the Löner flow stays in Arbeta.
+ { href: '/customers', labelKey: 'customers', icon: Users, group: 'data' },
+ { href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'data' },
+ { href: '/articles', labelKey: 'articles', icon: Tag, group: 'data' },
+ { href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', employerOnly: true, betaBadge: true },
+ { href: '/assets', labelKey: 'assets', icon: Package, group: 'data' },
+ { href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data' },
+ { href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', requiresDimensions: true },
+ { href: '/import', labelKey: 'import', icon: Upload, group: 'data' },
+ // Skatt & bokslut — everything submitted to the state. Rescues the
+ // previously nav-orphaned /skattekonto and /deadlines, and promotes the
+ // VAT declaration out of the report catalog.
+ { href: '/reports/vat-declaration', labelKey: 'vat_declaration', icon: Percent, group: 'skatt' },
+ { href: '/skattekonto', labelKey: 'skattekonto', icon: Landmark, group: 'skatt' },
+ { href: '/deadlines', labelKey: 'deadlines', icon: CalendarClock, group: 'skatt' },
+ { href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt' },
]
// Map known extension hrefs to nav translation keys so sidebar labels translate.
@@ -178,10 +203,10 @@ function extensionLabelKey(href: string): string | null {
}
const groupLabelKey: Record, string> = {
- försäljning: 'group_sales',
- inköp: 'group_purchases',
- redovisning: 'group_accounting',
- personal: 'group_personnel',
+ arbeta: 'group_work',
+ analys: 'group_analysis',
+ data: 'group_data',
+ skatt: 'group_tax',
}
// Best single-character initial we can show in the bottom-left account
@@ -227,10 +252,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
// Personal hidden).
type ExpandableGroup = Exclude
const [manualCollapsed, setManualCollapsed] = useState>({
- försäljning: false,
- inköp: false,
- redovisning: false,
- personal: false,
+ arbeta: false,
+ analys: false,
+ data: false,
+ skatt: false,
})
const toggleGroup = (g: ExpandableGroup) =>
setManualCollapsed((prev) => ({ ...prev, [g]: !prev[g] }))
@@ -257,6 +282,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
if (href === '/salary') {
return pathname === '/salary' || pathname.startsWith('/salary/runs')
}
+ // Bokslut (/bookkeeping/year-end) and Moms (/reports/vat-declaration)
+ // have their own rows under Skatt & bokslut — carve them out of their
+ // parent routes so exactly one row lights up.
+ if (href === '/bookkeeping') {
+ return pathname.startsWith('/bookkeeping') && !pathname.startsWith('/bookkeeping/year-end')
+ }
+ if (href === '/reports') {
+ return pathname.startsWith('/reports') && !pathname.startsWith('/reports/vat-declaration')
+ }
return pathname.startsWith(href)
}
@@ -385,10 +419,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)
const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = [
- { key: 'försäljning', items: filteredItems.filter((i) => i.group === 'försäljning') },
- { key: 'inköp', items: filteredItems.filter((i) => i.group === 'inköp') },
- { key: 'redovisning', items: filteredItems.filter((i) => i.group === 'redovisning') },
- { key: 'personal', items: filteredItems.filter((i) => i.group === 'personal') },
+ { key: 'arbeta', items: filteredItems.filter((i) => i.group === 'arbeta') },
+ { key: 'analys', items: filteredItems.filter((i) => i.group === 'analys') },
+ { key: 'data', items: filteredItems.filter((i) => i.group === 'data') },
+ { key: 'skatt', items: filteredItems.filter((i) => i.group === 'skatt') },
]
// A group is expanded when the user hasn't manually collapsed it OR
@@ -430,17 +464,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
- {/* Top section: flat, no header. Hem, Underlag, Transaktioner, Granskning. */}
+ {/* Top section: flat, no header. Hem, Assistent. */}
{topItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
- const badge =
- item.href === '/transactions' && liveUncategorizedTransactionCount > 0
- ? liveUncategorizedTransactionCount
- : item.href === '/pending' && pendingOperationsCount > 0
- ? pendingOperationsCount
- : null
+ const badge: number | null = null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
@@ -487,7 +516,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
})}
- {/* Collapsible groups: Försäljning, Inköp, Redovisning, Personal */}
+ {/* Collapsible groups: Arbeta, Analys, Data, Skatt & bokslut */}
{sidebarGroups
.filter(({ items }) => items.length > 0)
.map(({ key, items }) => {
@@ -512,6 +541,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
+ const badge =
+ item.href === '/transactions' && liveUncategorizedTransactionCount > 0
+ ? liveUncategorizedTransactionCount
+ : item.href === '/pending' && pendingOperationsCount > 0
+ ? pendingOperationsCount
+ : null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
<>
@@ -524,7 +559,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)}
/>
{tNav(item.labelKey)}
- {decorBadge}
+ {decorBadge ? decorBadge : badge !== null && (
+
+ {badge > 99 ? '99+' : badge}
+
+ )}
>
)
const baseClass = cn(
@@ -557,11 +596,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)
})}
- {/* Extension nav items land in Redovisning since the
- current extensions (TIC workspace, etc.) are
- accounting-adjacent. Future categorised extensions
- can opt into a different group via their manifest. */}
- {key === 'redovisning' &&
+ {/* Extension nav items land in Arbeta since extension
+ workspaces are work surfaces. Future categorised
+ extensions can opt into a different group via
+ their manifest. */}
+ {key === 'arbeta' &&
visibleExtensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
@@ -795,17 +834,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{/* Navigation */}
- {/* Top items (Hem, Underlag, Transaktioner, Granskning) */}
+ {/* Top items (Hem, Assistent) */}
{topItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
- const badge =
- item.href === '/transactions' && liveUncategorizedTransactionCount > 0
- ? liveUncategorizedTransactionCount
- : item.href === '/pending' && pendingOperationsCount > 0
- ? pendingOperationsCount
- : null
+ const badge: number | null = null
const decorBadge = renderBadge(item, 'mobile')
const content = (
<>
@@ -846,7 +880,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
})}
- {/* AR / AP / Personal / Accounting groups (mobile) */}
+ {/* Arbeta / Analys / Data / Skatt & bokslut groups (mobile) */}
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx
index c498bc22..4a8bae61 100644
--- a/components/reports/BankReconciliationView.tsx
+++ b/components/reports/BankReconciliationView.tsx
@@ -1,8 +1,10 @@
'use client'
+import Link from 'next/link'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
@@ -42,6 +44,42 @@ const METHOD_LABELS: Record
= {
manual: 'Manuell',
}
+// journal_entries.source_type values that can appear on a bank-account GL line,
+// mapped to Swedish. Falls back to the raw value for anything unmapped so a new
+// enum value degrades to today's behaviour instead of an empty cell.
+const SOURCE_TYPE_LABELS: Record = {
+ manual: 'Manuell',
+ import: 'Import',
+ bank_transaction: 'Banktransaktion',
+ invoice_paid: 'Kundfaktura betald',
+ invoice_cash_payment: 'Kontantfaktura',
+ supplier_invoice_paid: 'Leverantörsfaktura betald',
+ supplier_invoice_cash_payment: 'Leverantörsfaktura (kontant)',
+ salary_payment: 'Löneutbetalning',
+ system: 'System',
+ inbox_item: 'Inkorgsunderlag',
+ currency_revaluation: 'Valutaomvärdering',
+ year_end: 'Bokslut',
+ reminder_fee: 'Påminnelseavgift',
+}
+
+// Same thresholds as MatchVerifikationPicker's confidenceBadge — the dry-run
+// preview must read identically to the per-row picker.
+function confidenceLabel(confidence: number): {
+ label: string
+ variant: 'success' | 'secondary' | 'outline'
+} {
+ if (confidence >= 0.85) return { label: 'Stark', variant: 'success' }
+ if (confidence >= 0.6) return { label: 'Trolig', variant: 'secondary' }
+ return { label: 'Svag', variant: 'outline' }
+}
+
+/** Pre-tick strong matches; fuzzy (0.75) stays unticked for explicit opt-in. */
+const PRESELECT_CONFIDENCE = 0.85
+
+const matchKey = (transactionId: string, journalEntryId: string) =>
+ `${transactionId}:${journalEntryId}`
+
// One-click bookings for transactions with no upstream invoice/voucher to match
// against — the common "stuck on the unmatched list" cause (small ränteintäkter,
// bankavgifter, valutakursdifferenser). These reuse the existing bank_finance
@@ -175,6 +213,14 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
}, [dateTo])
const [dryRunResults, setDryRunResults] = useState(null)
+ // Which preview rows apply on "Tillämpa". Strong matches (≥0.85) are
+ // pre-ticked; fuzzy ones require an explicit opt-in tick.
+ const [selectedPairs, setSelectedPairs] = useState>(new Set())
+ // The date window the on-screen lists were last fetched with. Preview/apply
+ // read THIS window (not the live inputs) so they can never run against a
+ // different window than the lists the user is looking at; a mismatch between
+ // typed and applied dates renders a "klicka Filtrera" hint instead.
+ const [appliedDates, setAppliedDates] = useState<{ from: string; to: string } | null>(null)
const [runLoading, setRunLoading] = useState(false)
const [applyLoading, setApplyLoading] = useState(false)
const [linkLoading, setLinkLoading] = useState(null)
@@ -200,6 +246,17 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
// True when the unmatched list hit the API's 500-row cap — surfaced so a long
// date range doesn't silently hide rows and let the user think they're done.
const [unmatchedTruncated, setUnmatchedTruncated] = useState(false)
+ // Per-transaction ranked match candidates, lazily fetched when the row's
+ // picker is first focused. Passing transaction_id to /unmatched-entries makes
+ // the server rank candidates and attach confidence — the same intelligence
+ // MatchVoucherDialog gets on the Transactions page. Keyed by transaction id;
+ // cleared whenever the lists refetch (the candidate set may have changed).
+ const [rankedCandidates, setRankedCandidates] = useState>({})
+ const rankedFetchInFlight = useRef>(new Set())
+ // Bumped whenever fetchAll clears the ranked cache — an in-flight ranked
+ // response from before the clear must not repopulate the fresh cache, or
+ // that row's picker would show pre-refetch candidates until the next reload.
+ const rankedGenerationRef = useRef(0)
// Aborts the previous in-flight load when the account/date filters change, so
// a slow stale response can't overwrite the freshly-selected account's data
// (the intermittent "flips between accounts" bug).
@@ -220,6 +277,12 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
// that still needs one.
const unmatchedGlLines = glLines.filter((l) => !(l.linked_transaction_count ?? 0))
+ // The typed dates differ from what the lists (and preview/apply) run against.
+ // Surfaced as a hint so a user can't edit a date, skip Filtrera, and believe
+ // the preview covered the window they typed.
+ const datesDirty =
+ appliedDates !== null && (appliedDates.from !== dateFrom || appliedDates.to !== dateTo)
+
useEffect(() => {
let cancelled = false
fetch('/api/cash-accounts')
@@ -235,7 +298,7 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
}
}, [])
- const fetchAll = useCallback(async () => {
+ const fetchAll = useCallback(async (opts?: { silent?: boolean }) => {
// Cancel any in-flight load — it may be for a different account. Without
// this, switching accounts quickly lets an older response land last and
// overwrite the current account's data.
@@ -244,8 +307,26 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
fetchAbortRef.current = controller
const { signal } = controller
- setLoading(true)
+ // Only wholesale reloads (mount, account/period switch, Filtrera) show the
+ // skeleton. Row mutations refresh silently and update in place — the old
+ // behaviour unmounted the entire page on EVERY link/unlink/quick-book,
+ // losing scroll position and flashing 30 skeletons for 30 matches.
+ if (!opts?.silent) {
+ setLoading(true)
+ // A wholesale reload means the window/account/candidate set may have
+ // changed — a preview computed for the previous window must not leave an
+ // enabled "Tillämpa" button behind. Silent row-mutation refetches keep
+ // the preview (same window; the intersection guard on apply covers rows
+ // that got linked meanwhile).
+ setDryRunResults(null)
+ setSelectedPairs(new Set())
+ }
setError(null)
+ // The candidate pool changes with the data — drop stale per-row rankings
+ // and invalidate any ranked fetch already in flight.
+ setRankedCandidates({})
+ rankedFetchInFlight.current.clear()
+ rankedGenerationRef.current++
try {
const fromValue = dateFromRef.current
const toValue = dateToRef.current
@@ -293,6 +374,8 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
setUnmatchedTx(unmatchedData.data || [])
setMatchedTx(matchedData.data || [])
setUnmatchedTruncated(Boolean(unmatchedData.has_more))
+ // Record the window these lists represent — preview/apply run against it.
+ setAppliedDates({ from: fromValue, to: toValue })
// Refresh the ignored list whenever the main lists refresh.
// Deliberately NOT filtered by account or currency — if a user ignored
@@ -366,25 +449,46 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
useEffect(() => {
setSelectedMatch({})
setDryRunResults(null)
+ setSelectedPairs(new Set())
}, [accountNumber])
const handleDryRun = async () => {
setRunLoading(true)
setDryRunResults(null)
+ setSelectedPairs(new Set())
try {
const res = await fetch('/api/reconciliation/bank/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- date_from: dateFrom || undefined,
- date_to: dateTo || undefined,
+ // The APPLIED window — never the live inputs, which may not match the
+ // lists on screen until the user clicks Filtrera.
+ date_from: appliedDates?.from || undefined,
+ date_to: appliedDates?.to || undefined,
account_number: accountNumber,
dry_run: true,
}),
})
const result = await res.json()
+ if (!res.ok || result.error) {
+ // An error envelope parses as JSON, so the catch below never fires for
+ // it — without this check the button just stopped spinning and NOTHING
+ // rendered, leaving the user staring at an unchanged page.
+ setError(
+ typeof result.error === 'string' ? result.error : 'Kunde inte köra förhandsgranskning',
+ )
+ return
+ }
if (result.data?.matches) {
- setDryRunResults(result.data.matches)
+ const matches = result.data.matches as DryRunMatch[]
+ setDryRunResults(matches)
+ setSelectedPairs(
+ new Set(
+ matches
+ .filter((m) => m.confidence >= PRESELECT_CONFIDENCE)
+ .map((m) => matchKey(m.transaction_id, m.journal_entry_id)),
+ ),
+ )
}
} catch {
setError('Kunde inte köra förhandsgranskning')
@@ -393,28 +497,142 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
}
}
+ const toggleMatchSelection = (key: string) => {
+ setSelectedPairs((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ return next
+ })
+ }
+
+ // Matches RunReconciliationSchema's selected_matches .max(500). A first
+ // reconciliation after a year of imports can preview (and pre-tick) far more
+ // than 500 matches — applied in sequential chunks so the flow never dead-ends
+ // on the payload cap. Chunking is safe: the server intersects each chunk with
+ // a fresh match run, so pairs applied by an earlier chunk simply drop out of
+ // later ones and unselected pairs are never applied.
+ const APPLY_CHUNK_SIZE = 500
+
const handleApply = async () => {
+ if (!dryRunResults || selectedPairs.size === 0) return
+ const selected = dryRunResults.filter((m) =>
+ selectedPairs.has(matchKey(m.transaction_id, m.journal_entry_id)),
+ )
+ const requested = selected.length
setApplyLoading(true)
+ let applied = 0
+ let failed = false
+ let failMessage: string | undefined
try {
- await fetch('/api/reconciliation/bank/run', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- date_from: dateFrom || undefined,
- date_to: dateTo || undefined,
- account_number: accountNumber,
- dry_run: false,
- }),
- })
- setDryRunResults(null)
- await fetchAll()
+ for (let i = 0; i < selected.length; i += APPLY_CHUNK_SIZE) {
+ const chunk = selected.slice(i, i + APPLY_CHUNK_SIZE)
+ const res = await fetch('/api/reconciliation/bank/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ date_from: appliedDates?.from || undefined,
+ date_to: appliedDates?.to || undefined,
+ account_number: accountNumber,
+ dry_run: false,
+ selected_matches: chunk.map((m) => ({
+ transaction_id: m.transaction_id,
+ journal_entry_id: m.journal_entry_id,
+ })),
+ }),
+ })
+ const result = await res.json()
+ if (!res.ok || result.error) {
+ failed = true
+ failMessage = typeof result.error === 'string' ? result.error : undefined
+ break
+ }
+ applied += result.data?.applied ?? 0
+ }
} catch {
- setError('Kunde inte tillämpa matchningar')
+ failed = true
+ }
+
+ // Report what actually happened — the old flow cleared the preview and
+ // said nothing, even when the API had failed outright.
+ if (failed) {
+ toast({
+ variant: 'destructive',
+ title:
+ applied > 0
+ ? `${applied} av ${requested} matchningar tillämpade — resten misslyckades`
+ : 'Kunde inte tillämpa matchningarna',
+ description: failMessage,
+ })
+ } else if (applied === requested) {
+ toast({
+ variant: 'success',
+ title: `${applied} ${applied === 1 ? 'matchning tillämpad' : 'matchningar tillämpade'}`,
+ })
+ } else {
+ toast({
+ variant: 'destructive',
+ title: `${applied} av ${requested} matchningar tillämpade`,
+ description:
+ 'Resten kunde inte tillämpas — underlaget kan ha ändrats sedan förhandsgranskningen. Kör en ny förhandsgranskning.',
+ })
+ }
+ // Refetch whenever anything may have been written; on a clean failure with
+ // zero applied the preview survives so the user can retry.
+ try {
+ if (!failed || applied > 0) {
+ setDryRunResults(null)
+ setSelectedPairs(new Set())
+ await fetchAll({ silent: true })
+ }
} finally {
setApplyLoading(false)
}
}
+ /**
+ * Lazily fetch ranked, confidence-scored candidates for one transaction the
+ * first time its picker is focused. The endpoint ranks and attaches
+ * confidence when transaction_id is passed — without it every row shows the
+ * same unranked list and no Stark/Trolig/Svag badges (the intelligence the
+ * Transactions page's MatchVoucherDialog has had all along).
+ */
+ const ensureRankedCandidates = useCallback(
+ async (transactionId: string) => {
+ if (rankedCandidates[transactionId] || rankedFetchInFlight.current.has(transactionId)) {
+ return
+ }
+ rankedFetchInFlight.current.add(transactionId)
+ const generation = rankedGenerationRef.current
+ try {
+ const params = new URLSearchParams()
+ // The APPLIED window — the same one the lists and preview run against.
+ // Reading the live refs here would let a typed-but-not-filtered date
+ // silently give this row a different candidate set than the tables on
+ // screen. Refs are only the pre-first-load fallback.
+ const from = appliedDates?.from ?? dateFromRef.current
+ const to = appliedDates?.to ?? dateToRef.current
+ if (from) params.set('date_from', from)
+ if (to) params.set('date_to', to)
+ params.set('account_number', accountNumber)
+ params.set('transaction_id', transactionId)
+ if (includeMatched) params.set('include_matched', 'true')
+ const res = await fetch(`/api/reconciliation/bank/unmatched-entries?${params}`)
+ const json = await res.json()
+ // Discard if fetchAll cleared the cache while we were in flight —
+ // committing would pin pre-refetch candidates on this row.
+ if (res.ok && Array.isArray(json.data) && rankedGenerationRef.current === generation) {
+ setRankedCandidates((prev) => ({ ...prev, [transactionId]: json.data }))
+ }
+ } catch {
+ // Non-critical — the picker falls back to the unranked shared list.
+ } finally {
+ rankedFetchInFlight.current.delete(transactionId)
+ }
+ },
+ [accountNumber, includeMatched, rankedCandidates, appliedDates],
+ )
+
const handleManualLink = async (transactionId: string) => {
const journalEntryId = selectedMatch[transactionId]
if (!journalEntryId) return
@@ -431,18 +649,25 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
}),
})
const result = await res.json()
- if (result.error) {
- setError(result.error)
+ if (!res.ok || result.error) {
+ // Row-level failures surface next to where the user is working — the
+ // old top-of-page banner was off-screen when acting on row 40.
+ toast({
+ variant: 'destructive',
+ title: 'Kunde inte matcha transaktionen',
+ description: typeof result.error === 'string' ? result.error : undefined,
+ })
} else {
setSelectedMatch((prev) => {
const next = { ...prev }
delete next[transactionId]
return next
})
- await fetchAll()
+ toast({ variant: 'success', title: 'Transaktionen matchades mot verifikationen' })
+ await fetchAll({ silent: true })
}
} catch {
- setError('Kunde inte matcha transaktion')
+ toast({ variant: 'destructive', title: 'Kunde inte matcha transaktionen' })
} finally {
setLinkLoading(null)
}
@@ -457,13 +682,18 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
body: JSON.stringify({ transaction_id: transactionId }),
})
const result = await res.json()
- if (result.error) {
- setError(result.error)
+ if (!res.ok || result.error) {
+ toast({
+ variant: 'destructive',
+ title: 'Kunde inte avmatcha transaktionen',
+ description: typeof result.error === 'string' ? result.error : undefined,
+ })
} else {
- await fetchAll()
+ toast({ variant: 'success', title: 'Matchningen togs bort' })
+ await fetchAll({ silent: true })
}
} catch {
- setError('Kunde inte avmatcha transaktion')
+ toast({ variant: 'destructive', title: 'Kunde inte avmatcha transaktionen' })
} finally {
setUnlinkLoading(null)
}
@@ -485,13 +715,21 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
body: JSON.stringify({ journal_entry_id: journalEntryId }),
})
const result = await res.json()
- if (result.error) {
- setError(result.error)
+ if (!res.ok || result.error) {
+ toast({
+ variant: 'destructive',
+ title: 'Kunde inte markera verifikationen som ingående balans',
+ description: typeof result.error === 'string' ? result.error : undefined,
+ })
} else {
- await fetchAll()
+ toast({ variant: 'success', title: 'Verifikationen markerades som ingående balans' })
+ await fetchAll({ silent: true })
}
} catch {
- setError('Kunde inte markera verifikationen som ingående balans')
+ toast({
+ variant: 'destructive',
+ title: 'Kunde inte markera verifikationen som ingående balans',
+ })
} finally {
setMarkLoading(null)
}
@@ -520,16 +758,25 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
})
const result = await res.json()
if (!res.ok || result.error) {
- setError(result.error?.message || result.error || 'Kunde inte bokföra transaktionen')
+ toast({
+ variant: 'destructive',
+ title: 'Kunde inte bokföra transaktionen',
+ description: result.error?.message || (typeof result.error === 'string' ? result.error : undefined),
+ })
return
}
if (result.journal_entry_error) {
- setError(result.journal_entry_error)
+ toast({
+ variant: 'destructive',
+ title: 'Kunde inte bokföra transaktionen',
+ description: result.journal_entry_error,
+ })
return
}
- await fetchAll()
+ toast({ variant: 'success', title: 'Transaktionen bokfördes' })
+ await fetchAll({ silent: true })
} catch {
- setError('Kunde inte bokföra transaktionen')
+ toast({ variant: 'destructive', title: 'Kunde inte bokföra transaktionen' })
} finally {
setActionLoading(null)
}
@@ -543,7 +790,7 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
// "Ignorerade transaktioner" card is the third.
const ok = await confirm({
title: 'Ignorera transaktionen?',
- description: `${tx.description} — ${formatCurrency(tx.amount)} (${formatDate(tx.date)}) försvinner från avstämningen utan att bokföras. Du kan återställa den från "Ignorerade transaktioner" nedan när som helst.`,
+ description: `${tx.description} — ${formatCurrency(tx.amount, tx.currency)} (${formatDate(tx.date)}) försvinner från avstämningen utan att bokföras. Du kan återställa den från "Ignorerade transaktioner" nedan när som helst.`,
confirmLabel: 'Ignorera',
cancelLabel: 'Avbryt',
variant: 'warning',
@@ -557,13 +804,17 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
})
const result = await res.json()
if (!res.ok || result.error) {
- setError(result.error || 'Kunde inte ignorera transaktionen')
+ toast({
+ variant: 'destructive',
+ title: 'Kunde inte ignorera transaktionen',
+ description: typeof result.error === 'string' ? result.error : undefined,
+ })
return
}
- await fetchAll()
+ await fetchAll({ silent: true })
toast({
title: 'Transaktionen ignorerad',
- description: `${tx.description} — ${formatCurrency(tx.amount)}`,
+ description: `${tx.description} — ${formatCurrency(tx.amount, tx.currency)}`,
action: (
Banktransaktioner i perioden
- {formatCurrency(status.bank_transaction_total)}
+
+ {formatCurrency(status.bank_transaction_total, accountCurrency)}
+
+ {/* GL-side figures (bokfört, IB, rättelser, differens) stay in
+ SEK — journal entries are booked in SEK regardless of the
+ cash account's currency. Only the bank-feed total above is in
+ the account's own currency. */}
Bokfört på i perioden
@@ -665,21 +926,29 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
Differens
-
+
{formatCurrency(status.difference)}
{status.gl_1930_opening_balance !== 0 && (
Ingående balans (IB) på :{' '}
- {formatCurrency(status.gl_1930_opening_balance)}
+
+ {formatCurrency(status.gl_1930_opening_balance)}
+
{' '}— räknas inte i avstämningen.
)}
{status.gl_1930_correction_adjustment !== 0 && (
Varav rättelser och stornon på i perioden:{' '}
- {formatCurrency(status.gl_1930_correction_adjustment)}
+
+ {formatCurrency(status.gl_1930_correction_adjustment)}
+
{' '}— ingår i det bokförda beloppet och i avstämningen, precis som i balansräkningen.
)}
@@ -719,21 +988,29 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
className="mt-1"
/>
-
+ fetchAll()} variant={datesDirty ? 'default' : 'outline'}>
Filtrera
-
+
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
{dryRunResults && dryRunResults.length > 0 && (
-
+
- {applyLoading ? 'Tillämpar...' : `Tillämpa ${dryRunResults.length} matchningar`}
+ {applyLoading
+ ? 'Tillämpar...'
+ : `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
)}
+ {datesDirty && (
+
+ Datumfiltret är ändrat men inte tillämpat — klicka Filtrera för att uppdatera
+ listorna innan du förhandsgranskar.
+
+ )}
@@ -742,43 +1019,72 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
- Förhandsgranskning — {dryRunResults.length} matchningar hittade
+ Förhandsgranskning — {dryRunResults.length}{' '}
+ {dryRunResults.length === 1 ? 'matchning hittad' : 'matchningar hittade'}
+
+ Starka träffar är förvalda. Ungefärliga träffar kräver att du bockar i dem själv —
+ granska verifikationen först.
+
-
-
-
- Transaktion
- Datum
- Belopp
- ↔
- Verifikation
- Datum
- Metod
-
-
-
- {dryRunResults.map((m) => (
-
- {m.transaction_description}
- {formatDate(m.transaction_date)}
- {formatAmount(m.transaction_amount)}
- ↔
-
- {formatVoucher(m)}
- {m.entry_description}
-
- {formatDate(m.entry_date)}
-
-
- {METHOD_LABELS[m.method] || m.method}
-
-
+
+
+
+
+
+ Transaktion
+ Datum
+ Belopp
+ ↔
+ Verifikation
+ Datum
+ Metod
+ Träff
- ))}
-
-
+
+
+ {dryRunResults.map((m) => {
+ const key = matchKey(m.transaction_id, m.journal_entry_id)
+ const badge = confidenceLabel(m.confidence)
+ return (
+
+
+ toggleMatchSelection(key)}
+ aria-label={`Tillämpa matchning för ${m.transaction_description}`}
+ />
+
+ {m.transaction_description}
+ {formatDate(m.transaction_date)}
+ {formatAmount(m.transaction_amount)}
+ ↔
+
+
+ {formatVoucher(m)}
+
+ {m.entry_description}
+
+ {formatDate(m.entry_date)}
+
+
+ {METHOD_LABELS[m.method] || m.method}
+
+
+
+ {badge.label}
+
+
+ )
+ })}
+
+
+
)}
@@ -857,7 +1163,7 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
}`}
>
{isPositive ? '+' : ''}
- {formatCurrency(tx.amount)}
+ {formatCurrency(tx.amount, tx.currency)}
@@ -933,9 +1239,16 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
)}
-
+
ensureRankedCandidates(tx.id)}
+ >
setSelectedMatch((prev) => ({ ...prev, [tx.id]: v }))
@@ -974,53 +1287,63 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
Är en manuellt eller importerat bokförd verifikation egentligen en ingående balans? Markera den som IB — då räknas den inte med i avstämningen utan visas separat som ingående balans.
-
-
-
- Ver.nr
- Datum
- Beskrivning
- Belopp
- Typ
-
-
-
-
- {unmatchedGlLines.map((line) => {
- const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
- const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
- return (
-
-
- {formatVoucher(line)}
-
- {formatDate(line.entry_date)}
-
- {line.line_description || line.entry_description}
-
-
- {formatCurrency(amount)}
-
- {line.source_type}
-
- {isRetaggable && (
- handleMarkOpeningBalance(line.journal_entry_id)}
- title="Markera verifikationen som ingående balans — den utesluts då från avstämningen"
+
+
+
+
+ Ver.nr
+ Datum
+ Beskrivning
+ Belopp
+ Typ
+
+
+
+
+ {unmatchedGlLines.map((line) => {
+ const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
+ const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
+ return (
+
+
+
- {markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
-
- )}
-
-
- )
- })}
-
-
+ {formatVoucher(line)}
+
+
+ {formatDate(line.entry_date)}
+
+ {line.line_description || line.entry_description}
+
+
+ {formatCurrency(amount)}
+
+
+ {SOURCE_TYPE_LABELS[line.source_type] ?? line.source_type}
+
+
+ {isRetaggable && (
+ handleMarkOpeningBalance(line.journal_entry_id)}
+ title="Markera verifikationen som ingående balans — den utesluts då från avstämningen"
+ >
+ {markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
+
+ )}
+
+
+ )
+ })}
+
+
+
)}
@@ -1048,41 +1371,43 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
Rader du valt att dölja från avstämningen. De påverkar inte saldot på — de är bara gömda från listan.
-
-
-
- Datum
- Beskrivning
- Valuta
- Belopp
-
-
-
-
- {ignoredTx.map((tx) => (
-
- {formatDate(tx.date)}
- {tx.description}
-
- {tx.currency}
-
-
- {formatCurrency(tx.amount)}
-
-
- handleUnignore(tx.id)}
- >
- {actionLoading === tx.id ? '...' : 'Återställ'}
-
-
+
+
+
+
+ Datum
+ Beskrivning
+ Valuta
+ Belopp
+
- ))}
-
-
+
+
+ {ignoredTx.map((tx) => (
+
+ {formatDate(tx.date)}
+ {tx.description}
+
+ {tx.currency}
+
+
+ {formatCurrency(tx.amount, tx.currency)}
+
+
+ handleUnignore(tx.id)}
+ >
+ {actionLoading === tx.id ? '...' : 'Återställ'}
+
+
+
+ ))}
+
+
+
)}
@@ -1108,48 +1433,62 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
{showMatched && (
-
-
-
- Datum
- Beskrivning
- Belopp
- Metod
-
-
-
-
- {matchedTx.map((tx) => (
-
- {formatDate(tx.date)}
- {tx.description}
-
- {formatCurrency(tx.amount)}
-
-
- {tx.reconciliation_method && (
-
- {METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method}
-
- )}
-
-
- {tx.reconciliation_method && (
- handleUnlink(tx.id)}
- >
-
- {unlinkLoading === tx.id ? '...' : 'Avmatcha'}
-
- )}
-
+
+
+
+
+ Datum
+ Beskrivning
+ Belopp
+ Metod
+ Verifikation
+
- ))}
-
-
+
+
+ {matchedTx.map((tx) => (
+
+ {formatDate(tx.date)}
+ {tx.description}
+
+ {formatCurrency(tx.amount, accountCurrency)}
+
+
+ {tx.reconciliation_method && (
+
+ {METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method}
+
+ )}
+
+
+ {tx.journal_entry_id && (
+
+ Öppna verifikat
+
+ )}
+
+
+ {tx.reconciliation_method && (
+ handleUnlink(tx.id)}
+ >
+
+ {unlinkLoading === tx.id ? '...' : 'Avmatcha'}
+
+ )}
+
+
+ ))}
+
+
+
)}
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 31aa5f4f..26fb2bf9 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -1421,6 +1421,18 @@ export const RunReconciliationSchema = z.object({
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
.optional(),
dry_run: z.boolean().optional(),
+ // Pairs the user ticked in the dry-run preview. When present on an apply
+ // (dry_run false), only these pairs are committed — intersected server-side
+ // with a fresh match run, so a stale or fabricated pair is never applied.
+ selected_matches: z
+ .array(
+ z.object({
+ transaction_id: uuid,
+ journal_entry_id: uuid,
+ }),
+ )
+ .max(500)
+ .optional(),
})
// ============================================================
diff --git a/lib/bokslut/readiness-aggregator.ts b/lib/bokslut/readiness-aggregator.ts
index 4cb02abb..bbfcf3c2 100644
--- a/lib/bokslut/readiness-aggregator.ts
+++ b/lib/bokslut/readiness-aggregator.ts
@@ -126,7 +126,10 @@ export async function buildBokslutReadinessReport(
reconciliation.unmatched_transaction_count > 0
? `${reconciliation.unmatched_transaction_count} banktransaktioner är inte matchade. Avstäm banken innan bokslut.`
: `Bankavstämningen visar en differens på ${reconciliation.difference.toFixed(2)} kr.`,
- href: '/reconciliation/bank',
+ // Bankavstämning's real route — the earlier '/reconciliation/bank' href
+ // pointed at a page that has never existed, so the wizard's "Öppna"
+ // link 404ed.
+ href: '/reports/bank-reconciliation',
})
}
diff --git a/lib/reconciliation/__tests__/auto-reconcile-linked-voucher.test.ts b/lib/reconciliation/__tests__/auto-reconcile-linked-voucher.test.ts
index 861f333e..d18e0be1 100644
--- a/lib/reconciliation/__tests__/auto-reconcile-linked-voucher.test.ts
+++ b/lib/reconciliation/__tests__/auto-reconcile-linked-voucher.test.ts
@@ -55,7 +55,7 @@ function enqueueManualLinkSuccess(
enqueue({ data: makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: null, amount: txAmount, currency: 'SEK' }) })
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
enqueue({ data: [{ debit_amount: Math.max(txAmount, 0), credit_amount: Math.max(-txAmount, 0), account_number: '1930' }] })
- enqueue({ data: null, error: null }) // update
+ enqueue({ data: [{ id: 'tx-1' }] }) // update — .select('id') returns the updated row
}
describe('autoReconcileTransactionForLinkedVoucher', () => {
diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts
index 854a6720..56d02974 100644
--- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts
+++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts
@@ -445,8 +445,8 @@ describe('runReconciliation', () => {
enqueue({ data: [glLine] })
// from('transactions') returns unmatched transactions
enqueue({ data: [tx] })
- // Update transaction with link
- enqueue({ data: null, error: null })
+ // Update transaction with link — .select('id') returns the updated row
+ enqueue({ data: [{ id: 'tx-1' }] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false })
@@ -454,6 +454,90 @@ describe('runReconciliation', () => {
expect(result.applied).toBe(1)
expect(result.errors).toBe(0)
})
+
+ it('counts a conflicted apply (0 rows updated) as an error, not applied', async () => {
+ const { supabase, enqueue } = createQueueMockSupabase()
+
+ const tx = makeTransaction({ id: 'tx-1', amount: -500, date: '2024-06-15', currency: 'SEK' })
+ const glLine: UnlinkedGLLine = makeGLLine({
+ line_id: 'line-1',
+ journal_entry_id: 'je-1',
+ credit_amount: 500,
+ entry_date: '2024-06-15',
+ })
+
+ enqueue({ data: [glLine] })
+ enqueue({ data: [tx] })
+ // Optimistic-lock guard: a concurrent linker got there first — the
+ // .is('journal_entry_id', null) filter matches zero rows.
+ enqueue({ data: [] })
+
+ const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false })
+
+ expect(result.applied).toBe(0)
+ expect(result.errors).toBe(1)
+ })
+
+ it('applies only the pairs in applyOnly, intersected with the fresh match run', async () => {
+ const { supabase, enqueue } = createQueueMockSupabase()
+
+ const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
+ const tx2 = makeTransaction({ id: 'tx-2', amount: -500, date: '2024-06-15', currency: 'SEK' })
+ const line1 = makeGLLine({
+ line_id: 'line-1',
+ journal_entry_id: 'je-1',
+ debit_amount: 1000,
+ entry_date: '2024-06-15',
+ })
+ const line2 = makeGLLine({
+ line_id: 'line-2',
+ journal_entry_id: 'je-2',
+ credit_amount: 500,
+ entry_date: '2024-06-15',
+ })
+
+ enqueue({ data: [line1, line2] })
+ enqueue({ data: [tx1, tx2] })
+ // Only ONE update should run — for the single selected pair.
+ enqueue({ data: [{ id: 'tx-2' }] })
+
+ const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
+ dryRun: false,
+ applyOnly: [
+ { transactionId: 'tx-2', journalEntryId: 'je-2' },
+ // A pair the matcher never proposed must be ignored, not applied.
+ { transactionId: 'tx-99', journalEntryId: 'je-99' },
+ ],
+ })
+
+ expect(result.matches).toHaveLength(1)
+ expect(result.matches[0].transaction.id).toBe('tx-2')
+ expect(result.applied).toBe(1)
+ expect(result.errors).toBe(0)
+ })
+
+ it('ignores applyOnly on dry runs and returns the full match set', async () => {
+ const { supabase, enqueue } = createQueueMockSupabase()
+
+ const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
+ const line1 = makeGLLine({
+ line_id: 'line-1',
+ journal_entry_id: 'je-1',
+ debit_amount: 1000,
+ entry_date: '2024-06-15',
+ })
+
+ enqueue({ data: [line1] })
+ enqueue({ data: [tx1] })
+
+ const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
+ dryRun: true,
+ applyOnly: [],
+ })
+
+ expect(result.matches).toHaveLength(1)
+ expect(result.applied).toBe(0)
+ })
})
// ============================================================
@@ -569,14 +653,31 @@ describe('manualLink', () => {
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
// Line exists on the selected account
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
- // Update succeeds
- enqueue({ data: null, error: null })
+ // Update succeeds — .select('id') returns the updated row
+ enqueue({ data: [{ id: 'tx-1' }] })
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
expect(result.success).toBe(true)
})
+ it('rejects when a concurrent linker won the race (0 rows updated)', async () => {
+ const { supabase, enqueue } = createQueueMockSupabase()
+ const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
+
+ enqueue({ data: tx })
+ enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
+ enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
+ // The .is('journal_entry_id', null) optimistic-lock filter matched nothing:
+ // another session linked the transaction between our read and this write.
+ enqueue({ data: [] })
+
+ const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
+
+ expect(result.success).toBe(false)
+ expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.')
+ })
+
it('succeeds for a bound transaction when the account matches', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({
@@ -591,8 +692,8 @@ describe('manualLink', () => {
enqueue({ data: { ledger_account: '1930' } })
// Line exists on 1930
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
- // Update succeeds
- enqueue({ data: null, error: null })
+ // Update succeeds — .select('id') returns the updated row
+ enqueue({ data: [{ id: 'tx-1' }] })
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
@@ -612,7 +713,7 @@ describe('manualLink', () => {
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
// Update succeeds — note there is NO existing-link lookup in the sequence.
- enqueue({ data: null, error: null })
+ enqueue({ data: [{ id: 'tx-2' }] })
const result = await manualLink(supabase as never, 'company-1', 'tx-2', 'je-1', 'user-1', '1930')
@@ -672,7 +773,7 @@ describe('unlinkReconciliation', () => {
},
})
- const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1')
+ const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
expect(result.success).toBe(false)
expect(result.error).toContain('Cannot unlink')
@@ -692,10 +793,52 @@ describe('unlinkReconciliation', () => {
// Update succeeds
enqueue({ data: null, error: null })
- const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1')
+ const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
expect(result.success).toBe(true)
})
+
+ it('attributes the audit log row to the acting user, not the company', async () => {
+ // Regression: unlinkReconciliation used to pass companyId where
+ // logMatchEvent expects userId, so payment_match_log.user_id recorded the
+ // company UUID (or the insert failed its FK silently).
+ const inserts: Record[] = []
+ const resultQueue: { data: unknown; error: unknown }[] = [
+ {
+ data: { id: 'tx-1', journal_entry_id: 'je-1', reconciliation_method: 'manual' },
+ error: null,
+ },
+ { data: null, error: null }, // update
+ ]
+ const buildChain = (table?: string): unknown => {
+ const handler: ProxyHandler = {
+ get(_target, prop) {
+ if (prop === 'then') {
+ const next = resultQueue.shift() ?? { data: null, error: null }
+ return (resolve: (v: unknown) => void) => resolve(next)
+ }
+ if (prop === 'insert') {
+ return (row: Record) => {
+ if (table === 'payment_match_log') inserts.push(row)
+ return buildChain(table)
+ }
+ }
+ return (..._args: unknown[]) => buildChain(table)
+ },
+ }
+ return new Proxy({}, handler)
+ }
+ const supabase = {
+ from: vi.fn().mockImplementation((table: string) => buildChain(table)),
+ rpc: vi.fn().mockImplementation(() => buildChain()),
+ }
+
+ const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
+
+ expect(result.success).toBe(true)
+ expect(inserts).toHaveLength(1)
+ expect(inserts[0].user_id).toBe('user-1')
+ })
})
// ============================================================
diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts
index ed399236..306a3f33 100644
--- a/lib/reconciliation/bank-reconciliation.ts
+++ b/lib/reconciliation/bank-reconciliation.ts
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import type { Transaction, ReconciliationMethod } from '@/types'
import { eventBus } from '@/lib/events/bus'
import { logMatchEvent } from '@/lib/invoices/match-log'
+import { fetchAllRows } from '@/lib/supabase/fetch-all'
// ============================================================
// Types
@@ -102,6 +103,15 @@ export interface ReconciliationOptions {
* currency-only callers (where cashAccountId is omitted and this is moot).
*/
includeUnassigned?: boolean
+ /**
+ * Apply only these transaction↔journal-entry pairs (ignored on dry runs).
+ * The UI's dry-run preview lets the user untick suspicious matches; a
+ * subsequent apply passes the ticked pairs here so the server never commits
+ * a match the user excluded — and never commits a pair the matcher itself
+ * didn't propose on the re-run, since the filter intersects with the fresh
+ * match set rather than trusting the client's pairs blindly.
+ */
+ applyOnly?: Array<{ transactionId: string; journalEntryId: string }>
}
/**
@@ -251,43 +261,62 @@ export async function runReconciliation(
currency = 'SEK',
cashAccountId,
includeUnassigned = true,
+ applyOnly,
} = options
// Fetch unlinked GL lines via RPC
const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
// Fetch unmatched transactions, scoped to the selected cash account.
- let query = supabase
- .from('transactions')
- .select('*')
- .eq('company_id', companyId)
- .is('journal_entry_id', null)
- .eq('is_ignored', false)
- query = scopeTransactionsToAccount(query, cashAccountId, currency, includeUnassigned)
+ // Paginated: a busy company can exceed PostgREST's silent 1000-row cap, which
+ // would make the matcher skip transactions without any signal. Ordered on id
+ // (unique) so pages never duplicate or skip rows.
+ const transactions = await fetchAllRows(({ from, to }) => {
+ let query = supabase
+ .from('transactions')
+ .select('*')
+ .eq('company_id', companyId)
+ .is('journal_entry_id', null)
+ .eq('is_ignored', false)
+ query = scopeTransactionsToAccount(query, cashAccountId, currency, includeUnassigned)
+ if (dateFrom) query = query.gte('date', dateFrom)
+ if (dateTo) query = query.lte('date', dateTo)
+ return query.order('id').range(from, to)
+ })
- if (dateFrom) query = query.gte('date', dateFrom)
- if (dateTo) query = query.lte('date', dateTo)
-
- const { data: transactions } = await query
-
- if (!transactions || transactions.length === 0 || glLines.length === 0) {
+ if (transactions.length === 0 || glLines.length === 0) {
return { matches: [], applied: 0, errors: 0 }
}
// Run greedy matching, highest confidence first
- const matches = greedyMatch(transactions as Transaction[], glLines, currency)
+ let matches = greedyMatch(transactions, glLines, currency)
if (dryRun) {
return { matches, applied: 0, errors: 0 }
}
+ // When the caller reviewed a dry-run and ticked a subset, apply ONLY pairs
+ // that BOTH the user selected AND the fresh match run still proposes — the
+ // intersection guards against data that changed between preview and apply.
+ if (applyOnly) {
+ const selected = new Set(applyOnly.map((p) => `${p.transactionId}:${p.journalEntryId}`))
+ matches = matches.filter((m) =>
+ selected.has(`${m.transaction.id}:${m.glLine.journal_entry_id}`),
+ )
+ }
+
// Apply matches
let applied = 0
let errors = 0
for (const match of matches) {
try {
- const { error } = await supabase
+ // .is('journal_entry_id', null) is an optimistic-lock guard: if a
+ // concurrent user (or another surface) linked this transaction between
+ // the read above and this write, the update matches zero rows instead of
+ // silently re-pointing an existing link. Same pattern as
+ // lib/transactions/link-journal-entry.ts.
+ const { data: updatedRows, error } = await supabase
.from('transactions')
.update({
journal_entry_id: match.glLine.journal_entry_id,
@@ -296,8 +325,10 @@ export async function runReconciliation(
})
.eq('id', match.transaction.id)
.eq('company_id', companyId)
+ .is('journal_entry_id', null)
+ .select('id')
- if (error) {
+ if (error || !updatedRows || updatedRows.length === 0) {
errors++
} else {
applied++
@@ -353,16 +384,27 @@ export async function getReconciliationStatus(
// user has explicitly said they don't want them surfacing as something to
// reconcile. Scoping by cash account (not just currency) is what stops a
// second same-currency account from inflating bankTotal here.
- let txQuery = supabase
- .from('transactions')
- .select('date, amount, journal_entry_id, reconciliation_method, is_ignored')
- .eq('company_id', companyId)
- txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
-
- if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
- if (dateTo) txQuery = txQuery.lte('date', dateTo)
-
- const { data: transactions } = await txQuery
+ // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at 1000
+ // rows, which would undercount bank_transaction_total for a busy company and
+ // manufacture a phantom, unexplainable difference. Ordered on id (unique) so
+ // pages never duplicate or skip rows across boundaries.
+ type StatusTxRow = {
+ date: string | null
+ amount: number | string | null
+ journal_entry_id: string | null
+ reconciliation_method: string | null
+ is_ignored: boolean | null
+ }
+ const transactions = await fetchAllRows(({ from, to }) => {
+ let txQuery = supabase
+ .from('transactions')
+ .select('date, amount, journal_entry_id, reconciliation_method, is_ignored')
+ .eq('company_id', companyId)
+ txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
+ if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
+ if (dateTo) txQuery = txQuery.lte('date', dateTo)
+ return txQuery.order('id').range(from, to)
+ })
// Get GL bank-account lines. We fetch posted AND reversed entries and count
// them TOGETHER — the exact inclusion rule the trial balance and balance sheet
@@ -374,18 +416,6 @@ export async function getReconciliationStatus(
// the headline bug this widget had (a corrected bank receipt showed one figure
// here and a different one on the balance sheet). source_type is still pulled
// so we can split out the opening balance and surface correction activity.
- let glQuery = supabase
- .from('journal_entry_lines')
- .select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
- .eq('account_number', bankAccount)
- .eq('journal_entries.company_id', companyId)
- .in('journal_entries.status', ['posted', 'reversed'])
-
- if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
- if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
-
- const { data: glLines } = await glQuery
-
type GlEntry = {
id?: string | null
status?: string | null
@@ -410,7 +440,19 @@ export async function getReconciliationStatus(
// posted + reversed = the ledger balance, exactly as the trial balance counts
// it. The .in() filter on the query already excludes draft/cancelled.
- const fetchedLines = (glLines || []) as GlLineRow[]
+ // Paginated for the same 1000-row-cap reason as the transactions above — a
+ // silently truncated GL side would corrupt gl_1930_balance and the difference.
+ const fetchedLines = await fetchAllRows(({ from, to }) => {
+ let glQuery = supabase
+ .from('journal_entry_lines')
+ .select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
+ .eq('account_number', bankAccount)
+ .eq('journal_entries.company_id', companyId)
+ .in('journal_entries.status', ['posted', 'reversed'])
+ if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
+ if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
+ return glQuery.order('id').range(from, to)
+ })
// Floor the window at the most recent opening-balance date on this account
// (issue #751). Everything dated before that IB is prior history the IB entry
@@ -597,8 +639,11 @@ export async function manualLink(
// already-matched voucher when the user opts in via "Visa även matchade
// verifikationer", so this can't happen by accident.
- // Apply link
- const { error: updateError } = await supabase
+ // Apply link. The .is('journal_entry_id', null) guard re-checks the "not
+ // already linked" precondition inside the write itself — the read above is
+ // advisory, and two concurrent linkers would otherwise silently re-point the
+ // row (same optimistic-lock pattern as lib/transactions/link-journal-entry.ts).
+ const { data: updatedRows, error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: journalEntryId,
@@ -607,10 +652,15 @@ export async function manualLink(
})
.eq('id', transactionId)
.eq('company_id', companyId)
+ .is('journal_entry_id', null)
+ .select('id')
if (updateError) {
return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' }
}
+ if (!updatedRows || updatedRows.length === 0) {
+ return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
+ }
try {
eventBus.emit({
@@ -637,7 +687,8 @@ export async function manualLink(
export async function unlinkReconciliation(
supabase: SupabaseClient,
companyId: string,
- transactionId: string
+ transactionId: string,
+ userId: string,
): Promise<{ success: boolean; error?: string }> {
// Fetch transaction
const { data: tx, error: txError } = await supabase
@@ -673,7 +724,7 @@ export async function unlinkReconciliation(
return { success: false, error: 'Failed to unlink transaction' }
}
- logMatchEvent(supabase, companyId, transactionId, 'unmatched', {
+ logMatchEvent(supabase, userId, transactionId, 'unmatched', {
previousState: {
journal_entry_id: tx.journal_entry_id,
reconciliation_method: tx.reconciliation_method,
@@ -900,15 +951,30 @@ export async function fetchUnlinkedGLLines(
dateFrom?: string,
dateTo?: string,
): Promise {
- const { data, error } = await supabase.rpc('get_unlinked_gl_lines', {
- p_company_id: companyId,
- p_account_number: accountNumber,
- p_date_from: dateFrom || null,
- p_date_to: dateTo || null,
- })
-
- if (error || !data) return []
- return data as UnlinkedGLLine[]
+ // Paginated: the RPC returns SETOF and is subject to the same silent
+ // 1000-row PostgREST cap as table selects; truncation here would hide match
+ // candidates and undercount unmatched_gl_line_count. The .order() chain
+ // preserves the RPC's chronological order for consumers (the UI table, the
+ // picker) while the unique line_id tiebreaker keeps pages stable — several
+ // lines of one entry share entry_date/voucher_number. Errors keep the legacy
+ // contract: callers get [] rather than a throw.
+ try {
+ return await fetchAllRows(({ from, to }) =>
+ supabase
+ .rpc('get_unlinked_gl_lines', {
+ p_company_id: companyId,
+ p_account_number: accountNumber,
+ p_date_from: dateFrom || null,
+ p_date_to: dateTo || null,
+ })
+ .order('entry_date')
+ .order('voucher_number')
+ .order('line_id')
+ .range(from, to),
+ )
+ } catch {
+ return []
+ }
}
/** A match candidate that carries how many transactions already point at it. */
@@ -933,17 +999,29 @@ export async function fetchGLLinesForMatching(
dateTo?: string,
includeMatched: boolean = false,
): Promise {
- const { data, error } = await supabase.rpc('get_account_gl_lines_for_matching', {
- p_company_id: companyId,
- p_account_number: accountNumber,
- p_date_from: dateFrom || null,
- p_date_to: dateTo || null,
- p_include_matched: includeMatched,
- })
-
- if (error || !data) return []
+ // Paginated + ordered chronologically with the unique line_id tiebreaker,
+ // for the same reasons as fetchUnlinkedGLLines.
+ let data: GLLineForMatching[]
+ try {
+ data = await fetchAllRows(({ from, to }) =>
+ supabase
+ .rpc('get_account_gl_lines_for_matching', {
+ p_company_id: companyId,
+ p_account_number: accountNumber,
+ p_date_from: dateFrom || null,
+ p_date_to: dateTo || null,
+ p_include_matched: includeMatched,
+ })
+ .order('entry_date')
+ .order('voucher_number')
+ .order('line_id')
+ .range(from, to),
+ )
+ } catch {
+ return []
+ }
// count(*) can arrive as a bigint string over the wire — coerce defensively.
- return (data as GLLineForMatching[]).map((line) => ({
+ return data.map((line) => ({
...line,
linked_transaction_count: Number(line.linked_transaction_count) || 0,
}))
diff --git a/messages/en.json b/messages/en.json
index 820a3305..d8585896 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -93,15 +93,18 @@
"expenses": "Expenses",
"receipts": "Receipts",
"deadlines": "Deadlines",
+ "vat_declaration": "VAT",
+ "skattekonto": "Tax account",
+ "year_end": "Year-end",
"pending": "Pending",
"help": "Help",
"extensions": "Extensions",
"settings": "Settings",
"group_main": "Main",
- "group_sales": "Sales",
- "group_purchases": "Purchases",
- "group_personnel": "Personnel",
- "group_accounting": "Accounting",
+ "group_work": "Work",
+ "group_analysis": "Analytics",
+ "group_data": "Data",
+ "group_tax": "Tax & year-end",
"group_other": "Other",
"group_extensions": "Extensions",
"mitt_konto": "My account",
@@ -279,8 +282,6 @@
"origin_mcp": "Suggested by an AI assistant via {label}",
"origin_api": "Suggested via API integration",
"origin_cron": "Created by a scheduled job",
- "explainer": "Approve executes the bookkeeping operation. Reject just discards the proposal — nothing is booked.",
- "auto_expiry_note": "Unhandled proposals expire automatically after 30 days — doing nothing is safe.",
"badge_auto_expired": "Expired automatically",
"auto_expired_detail": "Expired automatically after 30 days without action. Nothing was booked."
},
diff --git a/messages/sv.json b/messages/sv.json
index a7204314..311d93b6 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -93,15 +93,18 @@
"expenses": "Utlägg",
"receipts": "Kvitton",
"deadlines": "Deadlines",
+ "vat_declaration": "Moms",
+ "skattekonto": "Skattekonto",
+ "year_end": "Bokslut",
"pending": "Väntande",
"help": "Hjälp",
"extensions": "Tillägg",
"settings": "Inställningar",
"group_main": "Huvudmeny",
- "group_sales": "Försäljning",
- "group_purchases": "Inköp",
- "group_personnel": "Personal",
- "group_accounting": "Redovisning",
+ "group_work": "Arbeta",
+ "group_analysis": "Analys",
+ "group_data": "Data",
+ "group_tax": "Skatt & bokslut",
"group_other": "Övrigt",
"group_extensions": "Tillägg",
"mitt_konto": "Mitt konto",
@@ -279,8 +282,6 @@
"origin_mcp": "Föreslaget av AI-assistent via {label}",
"origin_api": "Föreslaget via API-integration",
"origin_cron": "Skapat av automatiskt jobb",
- "explainer": "Godkänn utför bokföringen. Avvisa kastar bara förslaget — inget bokförs.",
- "auto_expiry_note": "Förslag som inte hanteras utgår automatiskt efter 30 dagar — att inte göra något är säkert.",
"badge_auto_expired": "Utgick automatiskt",
"auto_expired_detail": "Utgick automatiskt efter 30 dagar utan åtgärd. Inget bokfördes."
},