Files
accounted/CLAUDE.md
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

9.6 KiB
Raw Blame History

CLAUDE.md: Accounted

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.

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 use em dashes (—) or en dashes () in code, comments, commit messages, or docs. Use a colon, comma, semicolon, or plain hyphen instead, whichever fits the sentence. Exception: a dash character that is the literal subject being parsed, matched, or documented (e.g. mojibake byte-mapping tables, a date-range separator regex) stays as-is; don't launder those into a colon.
  • 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

npm run dev              # Dev server (runs setup:extensions first)
npm run build            # Production build (runs setup:extensions first)
npm run lint             # ESLint
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 after editing an atom SKILL.md

Architecture

  • 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 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

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.

Detail Loads On Demand

Don't duplicate these here; they auto-load when you touch matching paths:

  • .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

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 DECISIONS.md (repo root): [YYYY-MM-DD] <decision>: <why>. Check that file before re-litigating a past decision.