fix(reconciliation): Bankavstämning phase 0 — correctness + feedback batch (+ nav IA regrouping) (#879)

* feat(nav): interaction-mode sidebar grouping — Arbeta/Analys/Data/Skatt & bokslut

Nav IA redesign phase 0 (dev_docs/nav_ia_redesign.md): same routes,
regrouped by what the user is doing. CLAUDE.md restructured around Hard
Rules (doc references updated); pending-page explainer removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): correctness + feedback batch for Bankavstämning (phase 0)

Engine: fetchAllRows pagination on status/run/RPC fetches (silent 1000-row
cap corrupted totals), optimistic-lock guards on manualLink + apply,
unlink audit rows attributed to the acting user (was: company UUID),
selected_matches partial apply intersected with a fresh match run.

View: silent in-place refresh instead of a full-page skeleton per action,
checkbox-gated apply with confidence badges (fuzzy unticked) in chunks of
500, honest result toasts, dry-run errors surfaced, ranked per-row picker
candidates pinned to the applied date window, currency-correct amounts
(bank side in account currency, GL side SEK), voucher links, translated
source types, colored differens, dirty-date-filter guard.

Discovery: year-end preflight 404 href fixed (/reconciliation/bank never
existed), ⌘K palette entry, real links from the transactions page.

v1: status registry schema now matches the actual ReconciliationStatus
payload, errors documented as a count, false ~0.85-threshold pitfall
replaced, route test mocks the real shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 11:21:38 +02:00
committed by GitHub
parent 59ecaee650
commit ea236cbcdf
20 changed files with 1124 additions and 563 deletions
+1 -1
View File
@@ -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/`)
+1 -1
View File
@@ -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,
+76 -169
View File
@@ -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 <dir>` 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 <dir> # Run tests in a specific directory
npm test # All Vitest tests
npx vitest run <dir> # 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/<name>/`; `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 (18) + 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/<name>/`, 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] <decision> — <why>`. Check that file before re-litigating a past decision.
-8
View File
@@ -933,14 +933,6 @@ export default function PendingOperationsPage() {
</DropdownMenu>
</div>
{/* 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' && (
<p className="text-xs text-muted-foreground">
{t('explainer')} {t('auto_expiry_note')}
</p>
)}
<DataList>
{showBulkControls && bulkEligible.length > 0 && (
<DataListHeader>
+16 -1
View File
@@ -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: (
<ToastAction altText="Öppna Bankavstämning" asChild>
<Link href="/reports/bank-reconciliation">Bankavstämning</Link>
</ToastAction>
),
})
} else {
toast({
title: 'Delvis klart',
@@ -2075,6 +2084,12 @@ export default function TransactionsPage() {
<div className="flex flex-wrap items-center gap-2">
<BankSyncStatusChip />
<BankSyncNowButton />
{/* 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. */}
<Button asChild variant="ghost" size="sm" className="ml-auto h-9 text-sm text-muted-foreground">
<Link href="/reports/bank-reconciliation">Bankavstämning </Link>
</Button>
</div>
<BankSyncSinceLastVisit />
+5 -1
View File
@@ -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({
+1 -1
View File
@@ -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 })
@@ -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 () => {
@@ -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' },
},
},
@@ -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' },
},
+1
View File
@@ -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' },
+97 -63
View File
@@ -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<Exclude<GroupKey, 'top'>, 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<GroupKey, 'top'>
const [manualCollapsed, setManualCollapsed] = useState<Record<ExpandableGroup, boolean>>({
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
<CompanySwitcher />
</div>
<nav className="px-3" aria-label={tNav('main_navigation')}>
{/* Top section: flat, no header. Hem, Underlag, Transaktioner, Granskning. */}
{/* Top section: flat, no header. Hem, Assistent. */}
<div className="mb-4 space-y-px">
{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
})}
</div>
{/* 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
)}
/>
<span className="flex-1">{tNav(item.labelKey)}</span>
{decorBadge}
{decorBadge ? decorBadge : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)
const baseClass = cn(
@@ -557,11 +596,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
)
})}
{/* 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 */}
<div className="px-2">
{/* Top items (Hem, Underlag, Transaktioner, Granskning) */}
{/* Top items (Hem, Assistent) */}
<div className="space-y-0.5">
{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
})}
</div>
{/* AR / AP / Personal / Accounting groups (mobile) */}
{/* Arbeta / Analys / Data / Skatt & bokslut groups (mobile) */}
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
<div key={key}>
<div className="flex items-center gap-3 my-1.5 px-3">
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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(),
})
// ============================================================
+4 -1
View File
@@ -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',
})
}
@@ -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', () => {
@@ -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<string, unknown>[] = []
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<object> = {
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<string, unknown>) => {
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')
})
})
// ============================================================
+140 -62
View File
@@ -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 transactionjournal-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<Transaction>(({ 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<StatusTxRow>(({ 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<GlLineRow>(({ 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<UnlinkedGLLine[]> {
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<UnlinkedGLLine>(({ 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<GLLineForMatching[]> {
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<GLLineForMatching>(({ 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,
}))
+7 -6
View File
@@ -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."
},
+7 -6
View File
@@ -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."
},