diff --git a/.claude/rules/bookkeeping.md b/.claude/rules/bookkeeping.md index e18f8248..829c59f5 100644 --- a/.claude/rules/bookkeeping.md +++ b/.claude/rules/bookkeeping.md @@ -10,7 +10,7 @@ paths: # Bookkeeping Domain Reference -For Swedish accounting-law questions, use the domain skills (`swedish-vat`, `swedish-accounting-compliance`, `swedish-year-end-closing`, etc.). The accounting guard rails in the root `CLAUDE.md` always apply. +For Swedish accounting-law questions, use the domain skills (`swedish-vat`, `swedish-accounting-compliance`, `swedish-year-end-closing`, etc.). The Hard Rules in the root `CLAUDE.md` always apply. ## Core Services (`lib/core/`) diff --git a/.claude/skills/loop-verify/SKILL.md b/.claude/skills/loop-verify/SKILL.md index 581155cd..41f5e8a1 100644 --- a/.claude/skills/loop-verify/SKILL.md +++ b/.claude/skills/loop-verify/SKILL.md @@ -10,7 +10,7 @@ the way our CI would. If any step fails, fix and rerun from the top — do not p verified work. ## 0. Guard rails first (hard stops — no fix is worth breaking these) -Read [Accounting Guard Rails](../../../CLAUDE.md#accounting-guard-rails). A change is **rejected outright** if it: +Read [Hard Rules](../../../CLAUDE.md#hard-rules). A change is **rejected outright** if it: - edits or deletes a `posted` journal entry (use `reverseEntry`/`correctEntry` — storno, never edit), - makes an entry that doesn't balance, or sets a voucher number manually, - writes to a locked/closed period, or deletes a retained document, diff --git a/CLAUDE.md b/CLAUDE.md index df776db6..bf966d12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,196 +1,103 @@ # CLAUDE.md — Accounted -## Project Overview +Swedish accounting SaaS: double-entry bookkeeping under Swedish accounting law (Bokföringslagen) for sole traders (enskild firma) and limited companies (aktiebolag). Multi-tenant: users belong to companies via `company_members`; `teams` group companies for consultants. -Accounted is a Swedish-focused accounting SaaS for sole traders (enskild firma) and limited companies (aktiebolag). It implements double-entry bookkeeping compliant with Swedish accounting law (Bokföringslagen), including VAT handling, tax reporting, and 7-year document retention. Multi-tenant: each user can own or be a member of multiple companies, optionally grouped into teams (for consultants). - -**Tech stack**: Next.js 16.1.5 (App Router), React 19.2.3, TypeScript 5 (strict), Zod 4, Supabase (PostgreSQL + RLS + email/password + TOTP MFA auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting, Docker (self-hosted). - -**Integrations**: Enable Banking (PSD2), TIC Identity, Anthropic SDK, AWS Bedrock, OpenAI, Resend, Sentry, Svix, web-push, Upstash Redis, Google Drive, JSZip, sharp, Framer Motion, Recharts, PDF.js, `@react-pdf/renderer`, xlsx, fuse.js, ics. - -**Path alias**: `@/*` maps to the project root. **Language**: All code, comments, and commit messages in English. **License**: AGPL-3.0-or-later. +**Stack**: Next.js 16 (App Router), React 19, TypeScript 5 strict, Zod 4, Supabase (Postgres + RLS + auth), Tailwind 4 + shadcn/ui. Vercel-hosted is the primary target; Docker self-hosted must keep working but never at hosted's expense. Path alias `@/*` = repo root. All code, comments, and commits in English. --- +## Hard Rules + +The accounting rules are Swedish law, enforced by DB triggers. Code that violates them fails at runtime; code that works around the triggers breaks legal compliance. Never do either. + +1. **Never edit or delete a posted journal entry.** Committed vouchers are immutable. Cancel with `reverseEntry()`; correct with `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`). Storno, never edit. +2. **All journal writes go through `lib/bookkeeping/engine.ts`.** Never insert into journal tables directly: voucher numbers are assigned atomically by the `commit_journal_entry` RPC and must stay sequential, and gaps require documented explanations (BFNAR 2013:2 — `voucher_gap_explanations`). +3. **Every entry balances**: `sum(debits) === sum(credits)`, both `> 0`. +4. **Respect period locks.** DB triggers block writes to closed/locked periods and behind the company lock date. Don't work around them — fix the flow that tried to write there. +5. **Never delete documents linked to posted entries** — 7-year retention is a legal requirement. +6. **Money math is `Math.round(x * 100) / 100`.** Never `toFixed()` — it returns strings and rounds incorrectly, causing öre-level drift that breaks entry balance. +7. **Account numbers are strings** (`'1930'`, never `1930`). They are identifiers, not quantities; arithmetic on them is always a bug. + +General prohibitions: + +- **Never modify an existing migration** — schemas already shipped; create a new migration. Never touch the enforcement triggers (migration 017); they are legally required. +- **Core code must never import from `@/extensions/`.** CI builds core with zero extensions enabled; a direct import breaks that build. Extensions cannot use dynamic imports (the registry generates static imports via `setup:extensions`). +- **Don't add dependencies without asking.** This is an AGPL-3.0 project; license compatibility matters, and the dependency surface is audited. +- **Don't "finish" the gnubok → Accounted rename.** Wire-format identifiers keep the old name on purpose: `gnubok-company-id` cookie, `gnubok_sk_`/`gnubok_inv_` prefixes, `gnubok-mcp` npm package. Renaming them breaks live sessions, API keys, and invites. +- **Treat `.env.local` as pointing at the production database.** Never run seed/cleanup/repair scripts against it without explicit confirmation. +- **Keep the diff scoped to the request.** No drive-by refactors of untouched code. +- Never create a NUL/nul file: `\Accounted\NUL`. + +## When Uncertain + +- **Stop and ask; do not guess.** Especially for anything touching posted entries, the production database, money math, or Swedish tax law. +- **Swedish domain questions are never answered from training data.** Load the matching `swedish-*` skill (vat, accounting-compliance, invoice-compliance, payroll, year-end-closing, sie-import-export, sru-filing, financial-reporting, asset-accounting, project-accounting, tax-planning, e-invoicing). +- Scaffolding has skills — use them instead of improvising: `/erp-api-route` (API routes), `/supabase-migration` (migrations), `/create-extension` (extensions), `/frontend-design` (new UI), `vercel:deploy` (deployment). + +## Definition of Done + +A change is done when all of these hold — iterate until they do: + +1. `npm run lint` is clean and `npm test` passes (`npx vitest run ` while iterating). +2. New or changed logic in `lib/` or `app/api/` has tests: auth 401, validation 400, 404, happy path; mock `@/lib/supabase/server`. +3. Any change to a trigger, RPC, RLS policy, or DEFERRABLE constraint ships with a `*.pg.test.ts` (`npm run test:pg`). +4. New UI strings exist in **both** `messages/sv.json` and `messages/en.json`. +5. If you edited an atom `SKILL.md`, `npm run skills:generate` was run (CI's `skills:check` fails otherwise). +6. `npm run check:guards` passes if you touched API routes. +7. Commit is conventional (`feat:`/`fix:`/`refactor:`/`test:`/`docs:`), atomic, branched from `main`. + ## Commands ```bash -npm run dev # Start dev server (runs setup:extensions first) +npm run dev # Dev server (runs setup:extensions first) npm run build # Production build (runs setup:extensions first) npm run lint # ESLint -npm test # Run all Vitest tests -npx vitest run # Run tests in a specific directory +npm test # All Vitest tests +npx vitest run # Tests in one directory npm run test:pg # pg-real tests against real Postgres +npm run check:guards # Ratchet guard (e.g. no hand-rolled route auth) npm run setup:extensions # Regenerate extension registry from extensions.config.json -npm run skills:generate # Regenerate agent_atom_registry seed migration after editing an atom SKILL.md -npm run skills:check # CI guard: fail if an atom SKILL.md changed without regenerating the seed migration +npm run skills:generate # Regenerate agent_atom_registry seed after editing an atom SKILL.md ``` ---- +## Architecture -## Key Architectural Relationships - -- **Multi-tenant model**: `companies` owns all business data. `company_members` links users to companies (owner/admin/member/viewer). `teams` group companies. Context resolved via `gnubok-company-id` cookie in `lib/supabase/middleware.ts`. -- **All journal entry creation** routes through `lib/bookkeeping/engine.ts`. Lifecycle: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC). `createJournalEntry()` does both. Reversal: `reverseEntry()`. Correction: `correctEntry()` in `lib/core/bookkeeping/storno-service.ts`. -- **API routes** emitting events must call `ensureInitialized()` (`lib/init.ts`) at module level to load extensions and wire handlers. -- **Event bus** (`lib/events/bus.ts`) is a module-level singleton using `Promise.allSettled`. 50+ event types in `lib/events/types.ts`. Persisted to `event_log` table (30-day TTL). -- **Supabase clients**: browser (`client.ts`), server cookies (`createClient()`), service role (`createServiceClient()`), cookieless service role for API keys (`createServiceClientNoCookies()`). Pagination: `fetchAllRows()`. -- **Extension system**: Opt-in via `extensions.config.json`. Core runs with zero extensions. -- **Types**: Shared types in `types/index.ts` (~3,100 lines). Import via `import type { T } from '@/types'`. Event types in `lib/events/types.ts`. Extension types in `lib/extensions/types.ts`. -- **Error messages**: `lib/errors/get-error-message.ts` maps to Swedish (Zod → Postgres → HTTP → fallback). - ---- +- **Journal entry lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC); `createJournalEntry()` does both. Everything accounting-shaped routes through this engine. +- **Tenancy**: every business table has `company_id`. Active company resolves in `lib/supabase/middleware.ts`: `gnubok-company-id` cookie → `user_preferences.active_company_id` → first membership. RLS uses `user_company_ids()`; queries still filter by `company_id` explicitly (defense in depth — service-role paths have no RLS). +- **Auth**: Supabase email+password + TOTP MFA, enforced **application-side**, not in RLS. `NEXT_PUBLIC_REQUIRE_MFA=true` on hosted; `NEXT_PUBLIC_SELF_HOSTED=true` disables MFA. API routes wrap `withRouteContext` — it is the only path that enforces MFA, so never hand-roll `supabase.auth.getUser()` in a route. +- **Events**: `lib/events/bus.ts` is a module-level singleton. Any route that emits events must call `ensureInitialized()` (`lib/init.ts`) at module level — otherwise extension handlers are never wired and events silently go nowhere. +- **Supabase clients**: browser `client.ts`, server `createClient()`, service role `createServiceClient()`, cookieless service role `createServiceClientNoCookies()` (lives in `lib/auth/api-keys.ts`; for API-key/MCP paths). Paginate with `fetchAllRows()` — PostgREST silently caps at 1000 rows. +- **Extensions**: opt-in plugins in `extensions/general//`; `extensions.config.json` is the source of truth for what's enabled. Core must run with zero extensions. +- **MCP server**: the bookkeeping engine is exposed as 100+ MCP tools (`extensions/general/mcp-server/`), authenticated by `gnubok_sk_` API keys (SHA-256, scoped, default 100 RPM per key). +- **Types**: import from `@/types` (`types/index.ts`); event types in `lib/events/types.ts`. +- **User-facing errors are Swedish**: map through `lib/errors/get-error-message.ts`. +- **Cron**: hosted cron jobs live in `vercel.json`, authenticated via `verifyCronSecret()` (`lib/auth/cron.ts`). ## Repository Map -- `lib/bookkeeping/` — Engine, entry generators, mapping, templates, BAS data -- `lib/core/` — Period, year-end, storno, tax codes, audit, documents -- `lib/events/` — Bus singleton, event types, event log handler -- `lib/auth/` — API keys, require-auth/write, MFA, OAuth codes, invite tokens, cron, BankID -- `lib/supabase/` — Clients, middleware, `fetchAllRows` pagination -- `lib/api/` — Zod validation (`validateBody`/`validateQuery`), schemas -- `lib/reports/` — Report generators (balance sheet, income statement, trial balance, GL, AR/supplier ledger, VAT declaration, SIE, INK2, NE-bilaga, KPI, salary, vacation, …) -- `lib/invoices/`, `lib/transactions/`, `lib/import/` (SIE/bank/opening balance), `lib/documents/` (matchers) -- `lib/providers/` — Fortnox, Bokio, Briox, BL, Visma (OAuth, retry, consent) -- `lib/salary/` — Payroll engine, tax tables, AGI, KU, payslips, löneväxling, personnummer -- `lib/reconciliation/`, `lib/tax/`, `lib/vat/` (VIES, MOMS box), `lib/deadlines/`, `lib/currency/` (Riksbanken), `lib/skatteverket/`, `lib/bankgiro/` (Luhn), `lib/calendar/` (ICS) -- `lib/utils.ts` (`cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()`), `lib/logger.ts` -- `app/(dashboard)/*` — pages; `app/api/*` — API routes; `supabase/migrations/` — schema; `extensions/general/*` — opt-in extensions -- Path-scoped detail lives in `.claude/rules/` (see **Path-scoped rules** below). - ---- - -## Multi-Tenant Architecture - -- **companies**: Business unit. All business data has a `company_id` column. -- **company_members**: Roles `owner`/`admin`/`member`/`viewer`, source `direct`|`team`. -- **teams**: Consultant grouping. Team members auto-sync to company_members via DB triggers. -- **user_preferences**: Stores `active_company_id` and `locale`. - -**Context resolution** (`lib/supabase/middleware.ts`): cookie → `user_preferences.active_company_id` → first membership. RLS uses `user_company_ids()` helper. - -**Invitations**: `company_invitations`/`team_invitations` with `gnubok_inv_` tokens (SHA-256, 7-day TTL). See `lib/auth/invite-tokens.ts`. - ---- - -## Authentication - -Supabase Auth: email+password (primary), magic link (fallback), TOTP MFA. MFA enforced **application-side** (middleware + API routes), not in RLS. - -- `NEXT_PUBLIC_SELF_HOSTED=true` → MFA never enforced -- `NEXT_PUBLIC_REQUIRE_MFA=true` → middleware redirects to `/mfa/enroll` or `/mfa/verify` until AAL2 - -**API route auth** (`lib/auth/require-auth.ts`): `requireAuth()` returns `{ user, supabase, error }`, enforces MFA on hosted. -**API keys** (`lib/auth/api-keys.ts`): SHA-256 hashed, `gnubok_sk_` prefix. Scoped via `TOOL_SCOPE_MAP`. Rate limited 100 RPM via `validate_and_increment_api_key` RPC. -**Cron auth** (`lib/auth/cron.ts`): `verifyCronSecret()` constant-time comparison. - ---- - -## Core Bookkeeping Engine - -The engine (`lib/bookkeeping/engine.ts`) is the most critical system. All accounting flows route through it. - -**Lifecycle**: `createDraftEntry()` → `commitEntry()` (atomic voucher via `commit_journal_entry` RPC). `createJournalEntry()` does both. `reverseEntry()` for storno; `correctEntry()` (`lib/core/bookkeeping/storno-service.ts`) for corrections. - -**Engine files**: `transaction-entries.ts`, `invoice-entries.ts` (with `generatePerRateLines()` for mixed-rate), `supplier-invoice-entries.ts`, `vat-entries.ts`, `currency-revaluation.ts`, `mapping-engine.ts`, `booking-templates.ts`/`counterparty-templates.ts`, `propose-payment-lines.ts`/`propose-send-lines.ts`, `handlers/supplier-invoice-handler.ts`. - -**BAS data** (`lib/bookkeeping/bas-data/`): Full BAS 2026 chart by class (1–8) + SRU mapping. - -Key BAS accounts, VAT treatments, VAT declaration rutor, and `lib/core/` services are in `.claude/rules/bookkeeping.md`. For accounting-law questions use the Swedish domain skills. - ---- - -## Accounting Guard Rails - -These rules exist for legal compliance, enforced by database triggers. **Never violate them.** - -1. **Committed entries are immutable.** Once `status: 'posted'`, cannot be edited or deleted (DB trigger). -2. **Never delete posted entries.** Use `reverseEntry()` (storno) to cancel. -3. **Every entry must balance.** `sum(debits) === sum(credits)`, both `> 0`. -4. **Voucher numbers are sequential.** Assigned atomically via `commit_journal_entry` DB RPC. Never set manually. -5. **Voucher gap documentation.** BFNAR 2013:2 requires documented explanations for gaps (`voucher_gap_explanations` table, `detect_voucher_gaps` RPC). -6. **Period lock enforcement.** DB trigger blocks writes to closed/locked periods. Company-wide lock date enforced via `enforce_company_lock_date()` trigger. -7. **7-year document retention.** DB triggers prevent deletion of documents linked to posted entries. -8. **Storno, never edit.** Use `correctEntry()` from `lib/core/bookkeeping/storno-service.ts`. -9. **Use `Math.round(x * 100) / 100`** for monetary calculations. Never `toFixed()`. -10. **Always use engine functions.** Never insert directly into journal tables. -11. **Account numbers are strings.** `'1930'`, never `1930`. - ---- - -## Extension System - -Extensions are opt-in plugins in `extensions/general//`, controlled by `extensions.config.json`. Core runs with zero extensions. `npm run setup:extensions` generates static imports in `lib/extensions/_generated/` (auto via `predev`/`prebuild`). Extensions **cannot** use dynamic imports. - -**Enabled** (`extensions.config.json`): `enable-banking` (PSD2), `email` (Resend), `arcim-migration`, `tic` (org lookup), `mcp-server`, `cloud-backup` (Google Drive), `skatteverket`, `invoice-inbox`, `document-extraction`. **Present but disabled**: `calendar`, `push-notifications`, `example-logger` (plus the `_example-branding` template). - -- **Registration** (`lib/extensions/registry.ts`): Singleton. `register()` wires handlers. `get(id)`, `getAll()`, `getByCapability(key)`. -- **Context** (`lib/extensions/context-factory.ts`): `ExtensionContext` = `userId`, `companyId`, `extensionId`, `supabase`, `emit()`, `settings`, `storage`, `log`, `services`. -- **Creating**: use the `/create-extension` skill, or `npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "..."`. - ---- - -## MCP Server & API Keys - -Accounted exposes its bookkeeping engine as an MCP server (`extensions/general/mcp-server/`) for Claude Desktop/Code — 90+ tools, JSON-RPC 2.0, endpoint `/api/extensions/ext/mcp-server/mcp`, OAuth 2.1 for Claude connectors. npm bridge: `packages/gnubok-mcp` (`npx gnubok-mcp`). - -**API keys** (`lib/auth/api-keys.ts`, `api_keys` table): SHA-256, `gnubok_sk_` prefix, scoped via `TOOL_SCOPE_MAP`, 100 RPM via `validate_and_increment_api_key` RPC. `createServiceClientNoCookies()` — all queries filter by `company_id` (defense in depth). - -Tool authoring conventions, the staged-operation completion-signal pattern, and OAuth details are in `.claude/rules/mcp-server.md`. - ---- +- `lib/bookkeeping/` — engine, entry generators, mapping, templates, BAS 2026 data (`bas-data/`) +- `lib/core/` — period, year-end, storno, tax codes, audit, documents +- `lib/events/`, `lib/auth/`, `lib/supabase/`, `lib/api/` (Zod `validateBody`/`validateQuery`) +- `lib/reports/` — balance sheet, income statement, trial balance, GL, ledgers, VAT, SIE, INK2, NE-bilaga, salary, … +- `lib/invoices/`, `lib/transactions/`, `lib/import/`, `lib/documents/`, `lib/salary/`, `lib/reconciliation/`, `lib/tax/`, `lib/vat/`, `lib/providers/` (Fortnox/Bokio/Briox/BL/Visma), `lib/skatteverket/`, `lib/currency/`, `lib/bankgiro/`, `lib/deadlines/`, `lib/calendar/` +- `lib/utils.ts` — `cn()`, `formatCurrency()`, `formatDate()`, `formatOrgNumber()`; `lib/logger.ts` +- `app/(dashboard)/*` pages; `app/api/*` routes; `supabase/migrations/` schema; `extensions/general/*` plugins ## Testing -**Framework**: Vitest 4, `node` env, tests in `__tests__/`. Scope: `lib/` and `app/api/`. No component/E2E tests. +Vitest 4, `node` env, tests in `__tests__/`, scope `lib/` + `app/api/` (no component/E2E tests). Helpers in `tests/helpers.ts`: `createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest()`, `parseJsonResponse()`, plus fixture factories (`makeTransaction`, `makeJournalEntry`, `makeInvoice`, …). `vi.clearAllMocks()` + `eventBus.clear()` in `beforeEach`. Trigger/RPC/RLS behavior is tested in `*.pg.test.ts` against real Postgres, not with mocks. -**Helpers** (`tests/helpers.ts`): `createMockSupabase()`, `createQueuedMockSupabase()`, `createMockRequest()`, `parseJsonResponse()`, `createMockRouteParams()`, plus fixture factories (`makeTransaction`, `makeJournalEntry`, `makeInvoice`, `makeCustomer`, `makeSupplier`, `makeSupplierInvoice`, `makeFiscalPeriod`, etc.). +## Detail Loads On Demand -**Patterns**: Always mock `@/lib/supabase/server`. `vi.clearAllMocks()` + `eventBus.clear()` in `beforeEach`. Test auth (401), validation (400), 404, 500, happy path. +Don't duplicate these here — they auto-load when you touch matching paths: -**pg-real**: Parallel Vitest project for triggers/RPCs/RLS using real Postgres. File convention `*.pg.test.ts`. **Required**: any PR touching a trigger/RPC/RLS/DEFERRABLE must include or extend a `*.pg.test.ts`. (Details in `.claude/rules/database.md`.) +- `.claude/rules/design.md` — design system, locked tokens (`app/**`, `components/**`) +- `.claude/rules/i18n.md` — sv/en conventions, "stays Swedish" surfaces +- `.claude/rules/api-routes.md` — `withRouteContext` route pattern, endpoint map (`app/api/**`) +- `.claude/rules/database.md` — migration rules, key tables/RPCs/triggers, pg-real (`supabase/migrations/**`) +- `.claude/rules/mcp-server.md` — MCP tool authoring, staged-operation pattern +- `.claude/rules/bookkeeping.md` — BAS accounts, VAT treatments/rutor, `lib/core/` services ---- +## Decision Log -## Skills, Git & CI - -**Skills**: Use `/frontend-design` for new UI, `vercel:deploy` for deployment, `/supabase-migration` for new migrations, `/erp-api-route` for new API routes, `/create-extension` for new extensions. Use the Swedish domain skills (`swedish-vat`, `swedish-accounting-compliance`, `swedish-invoice-compliance`, `swedish-payroll`, `swedish-year-end-closing`, `swedish-sie-import-export`, `swedish-sru-filing`, `swedish-financial-reporting`, `swedish-asset-accounting`, `swedish-project-accounting`, `swedish-tax-planning`, `swedish-e-invoicing`) for accounting domain questions. The `swedish-*`, `industry/*`, and `modifier/*` skills also ship as product atoms (see `.claude/rules/database.md`). - -**Git**: Conventional commits (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`). Atomic commits, branch from `main`. - -**CI**: -- `.github/workflows/core-build.yml` — resets extensions to empty, runs build + test, verifies no core code imports from `@/extensions/` directly. -- `.github/workflows/swedish-compliance-review.yml` — Swedish accounting compliance review on PRs touching bookkeeping/reports/tax logic. -- `.github/workflows/docker-publish.yml` — pushes images to GHCR (`erp-mafia/erp-base`) on main. - ---- - -## Deployment - -- **Vercel (hosted)**: Cron jobs in `vercel.json` (deadline status, invoice reminders, tax deadlines, enable-banking sync, document verify, sandbox cleanup, event log cleanup, cloud-backup auto-sync). -- **Docker (self-hosted)**: 4-stage Node 22 Alpine `Dockerfile` (standalone output) + `docker-compose.yml` (app + supercronic cron). `docker-entrypoint.sh` validates env vars and replaces build-time placeholders in `.next/static/`. Extension presets: `docker/extensions.{self-hosted,hosted}.json`. - -**Environment variables**: -- **Required**: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET` -- **Auth**: `NEXT_PUBLIC_REQUIRE_MFA` (set `true` on hosted), `NEXT_PUBLIC_SELF_HOSTED` (set `true` for Docker) -- **Extension-specific** (only when enabled): `ENABLE_BANKING_APP_ID`/`ENABLE_BANKING_APP_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `RESEND_API_KEY`, `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` -- **Optional**: `SENTRY_DSN`, `SENTRY_AUTH_TOKEN` - ---- - -## Path-scoped rules (`.claude/rules/`) - -Topic detail loads automatically when Claude touches matching files: - -- `design.md` — design context & locked design-system tokens (`app/**`, `components/**`) -- `i18n.md` — bilingual sv/en conventions + "stays Swedish" surfaces (UI + `lib/email|invoices|reports|salary`) -- `api-routes.md` — API route pattern + endpoint map (`app/api/**`) -- `database.md` — migration rules, key tables/RPCs/triggers, `agent_atom_registry` (`supabase/migrations/**`) -- `mcp-server.md` — MCP tool authoring conventions (`extensions/general/mcp-server/**`) -- `bookkeeping.md` — BAS accounts, VAT treatments/rutor, `lib/core/` services (`lib/bookkeeping|core|reports|vat|invoices|salary`) - ---- - -## Other - -Never create a NUL/nul file: `\Accounted\NUL`. +When you make a non-obvious choice — picked approach A over B, declined a dependency, stopped because a rule here forbade something — append one line to `dev_docs/DECISIONS.md`: `[YYYY-MM-DD] `. Check that file before re-litigating a past decision. diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index b757c2d2..22f7ca04 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -933,14 +933,6 @@ export default function PendingOperationsPage() { - {/* First-time reviewers haven't necessarily used the AI chat that staged - these — say what the buttons do and that ignoring a proposal is safe. */} - {activeTab === 'pending' && ( -

- {t('explainer')} {t('auto_expiry_note')} -

- )} - {showBulkControls && bulkEligible.length > 0 && ( diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index b0cd3d0b..b69a94dc 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useMemo, useRef, useCallback } from 'react' +import Link from 'next/link' import { AnimatePresence } from 'framer-motion' import { useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' @@ -1836,7 +1837,15 @@ export default function TransactionsPage() { } setBatchProgress(null) if (failures.length === 0) { - toast({ title: 'Klart', description: `${successes} transaktioner ignorerade` }) + toast({ + title: 'Klart', + description: `${successes} transaktioner ignorerade`, + action: ( + + Bankavstämning + + ), + }) } else { toast({ title: 'Delvis klart', @@ -2075,6 +2084,12 @@ export default function TransactionsPage() {
+ {/* The ignore flows tell users to "återställ under Bankavstämning" — + this is the path there. Bankavstämning has no nav entry of its own, + so without a link here the copy points at an unreachable place. */} +
diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index 32a85b45..e1ba37c2 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -24,7 +24,7 @@ export async function POST(request: Request) { const validation = await validateBody(request, RunReconciliationSchema) if (!validation.success) return validation.response - const { date_from, date_to, account_number, dry_run } = validation.data + const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data const accountNumber = account_number ?? '1930' @@ -59,6 +59,10 @@ export async function POST(request: Request) { // a secondary same-currency account must scope strictly to its own id. includeUnassigned: Boolean(cashAccount?.is_primary), dryRun: dry_run ?? false, + applyOnly: selected_matches?.map((m) => ({ + transactionId: m.transaction_id, + journalEntryId: m.journal_entry_id, + })), }) return NextResponse.json({ diff --git a/app/api/reconciliation/bank/unlink/route.ts b/app/api/reconciliation/bank/unlink/route.ts index f66d1091..dde836a9 100644 --- a/app/api/reconciliation/bank/unlink/route.ts +++ b/app/api/reconciliation/bank/unlink/route.ts @@ -23,7 +23,7 @@ export async function POST(request: Request) { if (!validation.success) return validation.response const { transaction_id } = validation.data - const result = await unlinkReconciliation(supabase, companyId, transaction_id) + const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id) if (!result.success) { return NextResponse.json({ error: result.error }, { status: 400 }) diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts index 99a020ca..08399533 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts @@ -41,16 +41,22 @@ const { runRecMock, statusMock } = vi.hoisted(() => ({ }, ], applied: 1, - errors: [], + errors: 0, }), + // The REAL ReconciliationStatus shape from lib/reconciliation. The mock used + // to return the registry's invented shape (matched_transactions, bank_balance, + // …), which hid that documented and actual payloads had drifted apart. statusMock: vi.fn().mockResolvedValue({ - matched_transactions: 100, - unmatched_transactions: 5, - unmatched_gl_lines: 2, - total_unmatched_amount: 1500, - bank_balance: 50000, - gl_balance: 48500, + bank_transaction_total: 48500, + gl_1930_balance: 98500, + gl_1930_period_movement: 47000, + gl_1930_opening_balance: 51500, + gl_1930_correction_adjustment: 0, difference: 1500, + is_reconciled: false, + matched_count: 100, + unmatched_transaction_count: 5, + unmatched_gl_line_count: 2, }), })) @@ -261,8 +267,12 @@ describe('GET /reconciliation/bank/status', () => { ) expect(res.status).toBe(200) const body = await res.json() - expect(body.data.matched_transactions).toBe(100) - expect(body.data.unmatched_transactions).toBe(5) + // Passthrough of the lib's ReconciliationStatus — assert the real field + // names so a registry/actual drift can never hide behind the mock again. + expect(body.data.matched_count).toBe(100) + expect(body.data.unmatched_transaction_count).toBe(5) + expect(body.data.bank_transaction_total).toBe(48500) + expect(body.data.is_reconciled).toBe(false) }) it('rejects invalid date filter', async () => { diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts index 43aa5d86..d65d8eb4 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts @@ -55,7 +55,10 @@ const MatchOut = z.object({ const RunResponse = z.object({ matches: z.array(MatchOut), applied: z.number().int(), - errors: z.array(z.string()), + // Count of matches that failed to apply (DB error or lost an optimistic-lock + // race). Documented as z.array(z.string()) until 2026-07 — the lib has always + // returned a number. + errors: z.number().int(), }) registerEndpoint({ @@ -73,12 +76,13 @@ registerEndpoint({ 'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.', 'account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930.', 'Idempotency-Key is mandatory.', - 'matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).', + 'A non-dry run applies EVERY match found, including fuzzy ones at confidence 0.75 — there is no internal confidence threshold. Dry-run first and review matches.confidence before applying.', + 'The 366-day window bound only applies when BOTH date_from and date_to are set; a single-sided or absent window scans full history.', ], example: { request: { date_from: '2026-05-01', date_to: '2026-05-31' }, response: { - data: { matches: [], applied: 0, errors: [] }, + data: { matches: [], applied: 0, errors: 0 }, meta: { request_id: 'req_…', api_version: '2026-05-12' }, }, }, diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts index dcee40e4..46b102b7 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts @@ -12,14 +12,27 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' +// Mirrors ReconciliationStatus from lib/reconciliation/bank-reconciliation.ts — +// the handler passes that object straight through. This schema previously +// documented a different, invented shape (matched_transactions, bank_balance, +// total_unmatched_amount, …) that the endpoint never returned; any client coded +// against it read undefined for every field except difference. const StatusResponse = z.object({ - matched_transactions: z.number().int(), - unmatched_transactions: z.number().int(), - unmatched_gl_lines: z.number().int(), - total_unmatched_amount: z.number(), - bank_balance: z.number(), - gl_balance: z.number(), + /** Sum of bank-feed transactions in the window (the bank side). */ + bank_transaction_total: z.number(), + /** Full ledger balance on the account incl. opening balance — matches the balance sheet. */ + gl_1930_balance: z.number(), + /** Ledger movement excluding opening balance — what `difference` compares against. */ + gl_1930_period_movement: z.number(), + gl_1930_opening_balance: z.number(), + /** Net storno/correction activity in the window. Informational; included in the movement. */ + gl_1930_correction_adjustment: z.number(), + /** bank_transaction_total − gl_1930_period_movement. */ difference: z.number(), + is_reconciled: z.boolean(), + matched_count: z.number().int(), + unmatched_transaction_count: z.number().int(), + unmatched_gl_line_count: z.number().int(), }) registerEndpoint({ @@ -35,18 +48,22 @@ registerEndpoint({ 'Running the matcher — that\'s POST `/reconciliation/bank/run`. Per-transaction detail — use the transaction list with `?status=unbooked`.', pitfalls: [ 'A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.', - 'total_unmatched_amount is the absolute sum — positive even when the unmatched rows include both credits and debits.', + 'difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference.', + 'is_reconciled means |difference| < 0.01 for the window — an aggregate check, not a per-transaction guarantee.', ], example: { response: { data: { - matched_transactions: 142, - unmatched_transactions: 3, - unmatched_gl_lines: 2, - total_unmatched_amount: 1850.0, - bank_balance: 50000, - gl_balance: 48150, - difference: 1850, + bank_transaction_total: 48150, + gl_1930_balance: 98150, + gl_1930_period_movement: 48150, + gl_1930_opening_balance: 50000, + gl_1930_correction_adjustment: 0, + difference: 0, + is_reconciled: true, + matched_count: 142, + unmatched_transaction_count: 3, + unmatched_gl_line_count: 2, }, meta: { request_id: 'req_…', api_version: '2026-05-12' }, }, diff --git a/components/common/CommandPalette.tsx b/components/common/CommandPalette.tsx index 586110ea..c98cc422 100644 --- a/components/common/CommandPalette.tsx +++ b/components/common/CommandPalette.tsx @@ -57,6 +57,7 @@ const PAGE_ENTRIES: Entry[] = [ { id: 'rapport-moms', label: 'Visa rapport: Momsdeklaration', icon: BarChart3, href: '/reports/vat-declaration', keywords: 'rapport moms vat deklaration' }, { id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions' }, { id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' }, + { id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' }, { id: 'importera', label: 'Importera', icon: Upload, href: '/import' }, { id: 'granskning', label: 'Granskning', icon: ClipboardCheck, href: '/pending', keywords: 'pending review' }, { id: 'löner', label: 'Löner', icon: HandCoins, href: '/salary' }, diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 6d54a1bd..a056070a 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -33,6 +33,10 @@ import { Tags, ChevronsUpDown, Sparkles, + Percent, + Landmark, + CalendarClock, + FileCheck, } from 'lucide-react' import { getBranding } from '@/lib/branding/service' import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' @@ -105,20 +109,29 @@ type NavLabelKey = | 'import' | 'salary' | 'employees' + | 'vat_declaration' + | 'skattekonto' + | 'deadlines' + | 'year_end' | 'help' | 'settings' -// New nav layout (May 2026): +// Nav layout (July 2026 — interaction-mode grouping, dev_docs/nav_ia_redesign.md +// Phase 0): same routes as before, regrouped by what the user is doing. // top-of-sidebar — CompanySwitcher (active company / org context). -// top section — flat, no dropdown: Hem (agent), Underlag, -// Transaktioner, Granskning. -// four dropdowns — Försäljning, Inköp, Redovisning, Personal. +// top section — flat, no dropdown: Hem, Assistent. Hem doubles as +// the "what needs me" surface until a dedicated +// /inbox exists. +// four dropdowns — Arbeta (produce: the bookkeeping funnel first, +// then invoices, supplier invoices, payroll), +// Analys (KPI + reports), Data (master-data +// registers + import/export), Skatt & bokslut +// (statutory: VAT, tax account, deadlines, year-end). // bottom-left popover — signed-in user's name + initial, opens upward // to Inställningar, Hjälp, Support, Logga ut. -// Help + Settings are NOT in `navItems` anymore; they live in the account -// popover. KPI moved from main to redovisning. Pending stays visible at all -// times — the inline badge carries the count. -type GroupKey = 'top' | 'försäljning' | 'inköp' | 'redovisning' | 'personal' +// Help + Settings are NOT in `navItems`; they live in the account popover. +// Pending (Granskning) stays visible at all times — the badge carries the count. +type GroupKey = 'top' | 'arbeta' | 'analys' | 'data' | 'skatt' interface NavItem { href: string @@ -143,30 +156,42 @@ const navItems: NavItem[] = [ // Top section — flat list, always visible, no header { href: '/', labelKey: 'home', icon: Home, group: 'top' }, { href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' }, - { href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'top' }, - { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'top' }, - { href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'top' }, - // Försäljning dropdown - { href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'försäljning' }, - { href: '/customers', labelKey: 'customers', icon: Users, group: 'försäljning' }, - { href: '/articles', labelKey: 'articles', icon: Tag, group: 'försäljning' }, - // Inköp dropdown - { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'inköp' }, - { href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'inköp' }, - // Redovisning dropdown - { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'redovisning' }, - { href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' }, - { href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'redovisning' }, - { href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'redovisning', requiresDimensions: true }, - { href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' }, - { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' }, - { href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' }, - // Personal — "Beta" badge while we validate the end-to-end salary + AGI flow. + // Arbeta — everything the user produces. The bookkeeping funnel leads + // (Bokföring · Underlag · Transaktioner · Granskning — kept as separate + // rows until the unified workspace lands), then the transactional flows. + // Löner: "Beta" badge while we validate the end-to-end salary + AGI flow. // employerOnly: shown to aktiebolag and to any employer (pays_salaries), so an // enskild firma that hires staff gets payroll. Owner self-payroll stays // blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #782 - { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'personal', employerOnly: true, betaBadge: true }, - { href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'personal', employerOnly: true, betaBadge: true }, + { href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'arbeta' }, + { href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta' }, + { href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' }, + { href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' }, + { href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' }, + { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' }, + { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true, betaBadge: true }, + // Analys — read the numbers. KPI stays a separate row until the fused + // Rapporter surface (nav_ia_redesign §F) is built. + { href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'analys' }, + { href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'analys' }, + // Data — master-data registers + data plumbing. Anställda is a register + // (you edit an employee rarely, you run payroll monthly), so it lives here + // while the Löner flow stays in Arbeta. + { href: '/customers', labelKey: 'customers', icon: Users, group: 'data' }, + { href: '/suppliers', labelKey: 'suppliers', icon: Building2, group: 'data' }, + { href: '/articles', labelKey: 'articles', icon: Tag, group: 'data' }, + { href: '/salary/employees', labelKey: 'employees', icon: Users, group: 'data', employerOnly: true, betaBadge: true }, + { href: '/assets', labelKey: 'assets', icon: Package, group: 'data' }, + { href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'data' }, + { href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'data', requiresDimensions: true }, + { href: '/import', labelKey: 'import', icon: Upload, group: 'data' }, + // Skatt & bokslut — everything submitted to the state. Rescues the + // previously nav-orphaned /skattekonto and /deadlines, and promotes the + // VAT declaration out of the report catalog. + { href: '/reports/vat-declaration', labelKey: 'vat_declaration', icon: Percent, group: 'skatt' }, + { href: '/skattekonto', labelKey: 'skattekonto', icon: Landmark, group: 'skatt' }, + { href: '/deadlines', labelKey: 'deadlines', icon: CalendarClock, group: 'skatt' }, + { href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt' }, ] // Map known extension hrefs to nav translation keys so sidebar labels translate. @@ -178,10 +203,10 @@ function extensionLabelKey(href: string): string | null { } const groupLabelKey: Record, string> = { - försäljning: 'group_sales', - inköp: 'group_purchases', - redovisning: 'group_accounting', - personal: 'group_personnel', + arbeta: 'group_work', + analys: 'group_analysis', + data: 'group_data', + skatt: 'group_tax', } // Best single-character initial we can show in the bottom-left account @@ -227,10 +252,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa // Personal hidden). type ExpandableGroup = Exclude const [manualCollapsed, setManualCollapsed] = useState>({ - försäljning: false, - inköp: false, - redovisning: false, - personal: false, + arbeta: false, + analys: false, + data: false, + skatt: false, }) const toggleGroup = (g: ExpandableGroup) => setManualCollapsed((prev) => ({ ...prev, [g]: !prev[g] })) @@ -257,6 +282,15 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa if (href === '/salary') { return pathname === '/salary' || pathname.startsWith('/salary/runs') } + // Bokslut (/bookkeeping/year-end) and Moms (/reports/vat-declaration) + // have their own rows under Skatt & bokslut — carve them out of their + // parent routes so exactly one row lights up. + if (href === '/bookkeeping') { + return pathname.startsWith('/bookkeeping') && !pathname.startsWith('/bookkeeping/year-end') + } + if (href === '/reports') { + return pathname.startsWith('/reports') && !pathname.startsWith('/reports/vat-declaration') + } return pathname.startsWith(href) } @@ -385,10 +419,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa ) const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = [ - { key: 'försäljning', items: filteredItems.filter((i) => i.group === 'försäljning') }, - { key: 'inköp', items: filteredItems.filter((i) => i.group === 'inköp') }, - { key: 'redovisning', items: filteredItems.filter((i) => i.group === 'redovisning') }, - { key: 'personal', items: filteredItems.filter((i) => i.group === 'personal') }, + { key: 'arbeta', items: filteredItems.filter((i) => i.group === 'arbeta') }, + { key: 'analys', items: filteredItems.filter((i) => i.group === 'analys') }, + { key: 'data', items: filteredItems.filter((i) => i.group === 'data') }, + { key: 'skatt', items: filteredItems.filter((i) => i.group === 'skatt') }, ] // A group is expanded when the user hasn't manually collapsed it OR @@ -430,17 +464,12 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa