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:
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (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/<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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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<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
@@ -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
@@ -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."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user