Files
accounted/CLAUDE.md
T
Jakob Wennberg 54d8b2cda5 fix: pin search_path on all DB functions and remove dashboard subtitle
- Add migration 051 to SET search_path = public on all 24 custom
  functions, preventing search_path injection attacks
- Remove dashboard subtitle (status summary line)
- Update CLAUDE.md with new migration reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:18:01 +01:00

359 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md — erp-base
## Project Overview
erp-base 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 (Bokforingslagen), including VAT handling, tax reporting, and 7-year document retention.
**Tech stack**: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + magic link auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting.
**Integrations**: Enable Banking (PSD2), Anthropic SDK, LangChain, OpenAI (embeddings), Resend (email), web-push (VAPID).
**Path alias**: `@/*` maps to the project root. **Language**: All code, comments, and commit messages in English.
---
## Commands
```bash
npm run dev # Start 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 run setup:extensions # Regenerate extension registry from extensions.config.json
```
---
## Architecture
```
app/
(auth)/ Login, auth callback
(onboarding)/ 6-step setup wizard
(dashboard)/ Authenticated routes (invoices, customers, transactions,
bookkeeping, reports, suppliers, supplier-invoices,
receipts, deadlines, settings, help, import, extensions)
(public)/ Public invoice action links (no auth)
api/ API routes organized by domain
components/
ui/ shadcn/ui primitives
bookkeeping/ Chart of accounts, journal entry form/list/review
chat/ ChatWidget, ChatPanel
extensions/ Extension marketplace UI, general/ workspace components
transactions/ Transaction list, categorization, booking, VAT treatment
(+ customers/, dashboard/, deadlines/, import/, invoices/,
onboarding/, reports/, settings/, suppliers/)
extensions/general/ Config-driven extensions (see Extension System below)
lib/
api/ Zod validation schemas (schemas.ts) and helpers (validate.ts)
bookkeeping/ Core journal entry engine and all entry generators
engine.ts Draft/commit workflow, balance validation, voucher numbering
invoice-entries.ts Sales invoice journal entries (per-line VAT rates)
transaction-entries.ts Bank transaction journal entries
supplier-invoice-entries.ts Purchase invoice journal entries
category-mapping.ts Category-to-BAS-account mapping
mapping-engine.ts Rule-based auto-categorization (MCC, merchant patterns)
bas-reference.ts BAS account catalog (~180 accounts)
handlers/ Booking handler functions
core/
bookkeeping/ Period service, storno reversal, year-end closing
documents/ Document archive (upload, versioning, SHA-256 integrity)
audit/ Audit trail service
tax/ Tax code service
email/ EmailService interface + NoopEmailService default
events/ Event bus (bus.ts, types.ts) — singleton, core emits, extensions subscribe
extensions/ Extension system (loader, registry, types, hooks, context-factory)
_generated/ Code-generated files (DO NOT EDIT)
import/ SIE parser, bank file parser (10 Swedish bank formats)
invoices/ VAT rules, invoice matching, PDF template, reminders
reconciliation/ Bank reconciliation engine (4-pass matching)
reports/ Financial reports (trial balance, income statement, balance sheet,
VAT declaration, SIE export, general ledger, NE-bilaga, SRU export)
supabase/ Client setup (client.ts = browser, server.ts = server)
tax/ Tax calculations, deadlines, Swedish holidays
vat/ VIES validation, moms box mapping
init.ts Extension loader (idempotent, called by API routes)
types/index.ts Canonical type definitions (single source of truth)
types/chat.ts Chat types
tests/helpers.ts Mock factories and fixture builders
supabase/migrations/ SQL migration files (45 files)
extensions.config.json Extension opt-in configuration
```
### Key Relationships
- **All journal entry creation** routes through `lib/bookkeeping/engine.ts` via `createJournalEntry()`.
- **API routes** that emit events must call `ensureInitialized()` (from `lib/init.ts`) at module level.
- **Event bus** (`lib/events/bus.ts`) is a module-level singleton. Handlers run via `Promise.allSettled`.
- **Supabase clients**: browser (`lib/supabase/client.ts`), server with cookies (`createClient()` from `server.ts`), service role (`createServiceClient()`).
- **Extension system**: Opt-in via `extensions.config.json`. Core builds and runs with zero extensions.
- **NE-bilaga and SRU export** are core reports (in `lib/reports/`), not extensions.
---
## Core Bookkeeping Engine
The engine (`lib/bookkeeping/engine.ts`) is the most critical system. All accounting flows route through it.
### Journal Entry Lifecycle
1. **`createDraftEntry(userId, input)`** — Creates `status: 'draft'`, `voucher_number: 0`. Validates balance. Emits `journal_entry.drafted`.
2. **`commitEntry(userId, entryId)`** — Assigns voucher number via DB RPC (concurrent-safe). Sets `status: 'posted'`. Emits `journal_entry.committed`.
3. **`createJournalEntry(userId, input)`** — Draft + commit in one call. This is what all entry generators use.
4. **`reverseEntry(userId, entryId)`** — Storno reversal: swaps debit/credit, links via `reverses_id`/`reversed_by_id`.
### Entry Generators
| Function | File | Purpose |
|----------|------|---------|
| `createInvoiceJournalEntry()` | `invoice-entries.ts` | Debit 1510, Credit 30xx + 26xx VAT (per-line VAT rates) |
| `createInvoicePaymentJournalEntry()` | `invoice-entries.ts` | Debit 1930, Credit 1510 |
| `createCreditNoteJournalEntry()` | `invoice-entries.ts` | Reverses original invoice entry |
| `createTransactionJournalEntry()` | `transaction-entries.ts` | Maps bank transactions via MappingResult |
| `createSupplierInvoiceRegistrationEntry()` | `supplier-invoice-entries.ts` | Debit expense + 2641, Credit 2440 |
| `createSupplierInvoicePaymentEntry()` | `supplier-invoice-entries.ts` | Debit 2440, Credit 1930 |
### Key BAS Accounts
`1510` Accounts receivable | `1930` Business bank account | `2013` Private withdrawals (EF) | `2440` Accounts payable | `2611`/`2621`/`2631` Output VAT 25%/12%/6% | `2641` Input VAT | `2645` Calculated input VAT (EU) | `2893` Shareholder loan (AB) | `3001`/`3002`/`3003` Revenue 25%/12%/6% | `3305`/`3308` Export/EU service revenue
### VAT Treatments
`standard_25`, `reduced_12`, `reduced_6`, `reverse_charge`, `export`, `exempt`
Invoice items support individual `vat_rate` values (mixed-rate invoices). `generatePerRateLines()` in `invoice-entries.ts` groups by rate. Use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoices/vat-rules.ts`.
### Bank Reconciliation
`lib/reconciliation/bank-reconciliation.ts` — 4-pass matching on account 1930:
1. `auto_exact` (0.95) — exact amount + exact date
2. `auto_reference` (0.90) — exact amount + reference match
3. `auto_date_range` (0.85) — exact amount + date ±3 days
4. `auto_fuzzy` (0.75) — fuzzy amount (±0.01) + exact date
---
## 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 via DB RPC. Never set manually.
5. **Period lock enforcement.** DB trigger blocks writes to closed/locked periods.
6. **7-year document retention.** DB triggers prevent deletion of documents linked to posted entries.
7. **Storno, never edit.** Use `correctEntry()` from `lib/core/bookkeeping/storno-service.ts`.
8. **Use `Math.round(x * 100) / 100`** for monetary calculations. Never `toFixed()`.
9. **Always use engine functions.** Never insert directly into journal tables.
10. **Account numbers are strings.** `'1930'`, never `1930`.
---
## Extension System
Extensions are opt-in plugins controlled by `extensions.config.json`. Core builds and runs with zero extensions.
### How It Works
1. Each extension has a `manifest.json` in `extensions/general/<name>/`.
2. `extensions.config.json` lists enabled extension IDs.
3. `npm run setup:extensions` generates files in `lib/extensions/_generated/` (static imports, workspace map, definitions).
4. `predev`/`prebuild` hooks run this automatically.
### Available Extensions
| Extension | Category | Env Vars Required |
|-----------|----------|-------------------|
| `receipt-ocr` | import | `ANTHROPIC_API_KEY` |
| `ai-categorization` | operations | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` |
| `ai-chat` | operations | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` |
| `push-notifications` | operations | VAPID keys |
| `invoice-inbox` | import | `ANTHROPIC_API_KEY` |
| `calendar` | operations | — |
| `enable-banking` | import | Enable Banking keys |
| `email` | operations | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` |
### Creating Extensions
```bash
npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "..."
```
Then add `"my-ext"` to `extensions.config.json` and run `npm run setup:extensions`.
**Constraints**: Extensions **cannot** use dynamic imports (Next.js bundling).
### Extension Interface
```typescript
interface Extension {
id: string; name: string; version: string; sector?: SectorSlug
// All optional surfaces:
apiRoutes?: ApiRouteDefinition[]
eventHandlers?: ExtensionEventHandler[]
services?: Record<string, (...args: any[]) => Promise<any>>
sidebarItems?: SidebarItem[]
mappingRuleTypes?: MappingRuleTypeDefinition[]
settingsPanel?: SettingsPanelDefinition
onInstall?(ctx: ExtensionContext): Promise<void>
onUninstall?(ctx: ExtensionContext): Promise<void>
}
```
### Extension Context
Handlers receive an `ExtensionContext` with: `userId`, `extensionId`, `supabase` (pre-authenticated), `emit()`, `settings` (JSONB key-value), `storage` (Supabase Storage), `log` (scoped logger), `services`.
### Extension API Routes
Dispatched via catch-all at `app/api/extensions/ext/[...path]/route.ts`.
URL scheme: `/api/extensions/ext/{extensionId}/{routePath}`
Path params extracted as `_paramName` search params (e.g., `/:id``searchParams.get('_id')`).
### Service Provider Patterns
**Interface registration** (email pattern): Core defines interface with noop default in `lib/email/service.ts`. Extension calls `registerEmailService()` at load time. Core uses `getEmailService()` — degrades gracefully.
**Services record** (ai-categorization pattern): Extension exposes functions via `services` property. Core looks up via registry: `extensionRegistry.get('ai-categorization')?.services?.findSimilarTemplates(...)`.
### Event Types (lib/events/types.ts)
`journal_entry.drafted/committed/corrected` | `document.uploaded` | `invoice.created/sent` | `credit_note.created` | `transaction.synced/categorized/reconciled` | `period.locked/year_closed` | `customer.created` | `receipt.extracted/matched/confirmed` | `supplier_invoice.received/extracted/confirmed`
---
## Testing
**Framework**: Vitest 4, `globals: true`, `environment: 'node'`. Tests colocated in `__tests__/` directories.
**Scope**: Business logic in `lib/` and API routes in `app/api/`. No component or E2E tests.
**Test helpers** (`tests/helpers.ts`):
- `createMockSupabase()` / `createQueuedMockSupabase()` — Supabase mocks
- `createMockRequest()`, `parseJsonResponse()`, `createMockRouteParams()` — API route testing
- Fixture factories: `makeTransaction()`, `makeJournalEntry()`, `makeInvoice()`, `makeCustomer()`, `makeSupplier()`, `makeFiscalPeriod()`, `makeReceipt()`, `makeDocumentAttachment()`, `makeCompanySettings()`, etc.
**Patterns**:
- Always mock `@/lib/supabase/server`
- Use `vi.clearAllMocks()` and `eventBus.clear()` in `beforeEach`
- API route tests: mock `@/lib/init` and lib functions, test auth (401), validation (400), not found (404), errors (500), happy path
---
## Database & Migrations
**Location**: `supabase/migrations/` — 45 files, numbered `20240101000001``20240101000045`.
**Next migration**: `20240101000052_*.sql`
### Placeholder Migrations
Some migrations are no-op placeholders to preserve the numbering sequence:
- **012** (`tax_codes`) — Planned but never deployed. The system operates without the `tax_codes` table.
- **023** (`document_version_chain`) — Planned but never deployed. Document versioning columns/functions do not exist in production.
### Migration Rules
1. **Always enable RLS** and create `SELECT/INSERT/UPDATE` policies using `auth.uid() = user_id`
2. **Always add `updated_at` trigger** using `update_updated_at_column()`
3. **UUID primary keys**: `DEFAULT uuid_generate_v4()`
4. **User ownership**: `user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL`
5. **Never modify existing migrations** — create new ones
6. **Never modify enforcement triggers** (migration 017) — legally required
7. **Apply via Supabase MCP tool**: `mcp__plugin_supabase_supabase__apply_migration`
### Key Enforcement Triggers (migration 017)
- `enforce_journal_entry_immutability` — Blocks edits/deletes on posted/reversed entries
- `enforce_journal_entry_line_immutability` — Blocks line modifications on committed entries
- `enforce_period_lock` — Blocks writes to closed/locked fiscal periods
- `block_document_deletion` — Prevents deletion of documents linked to committed entries
- `enforce_retention_journal_entries` — 7-year retention enforcement
- `set_committed_at` — Auto-sets timestamp on draft-to-posted transition
- `calculate_retention_expiry` — Auto-sets `retention_expires_at = period_end + 7 years`
### Recent Migrations
- **Migration 039 (`invoice_inbox`)** — Invoice inbox table with document type classification, AI extraction, supplier/transaction matching, and receipt linking.
- **Migration 040 (`booking_template_embeddings`)** — Booking templates with AI embeddings for suggestion matching.
- **Migration 041 (`user_description_matching`)** — User description matching for transaction categorization.
- **Migration 042 (`prevent_overlapping_fiscal_periods`)** — Exclusion constraint preventing overlapping fiscal periods per user.
- **Migration 043 (`enforce_fiscal_period_month_boundaries`)** — Ensures fiscal periods start/end on month boundaries.
- **Migration 044 (`full_bas_2026`)** — Full BAS 2026 account catalog, K2-excluded flag, and SRU code backfill.
- **Migration 045 (`expand_account_type_untaxed_reserves`)** — Adds `untaxed_reserves` to `chart_of_accounts.account_type` CHECK constraint for BAS 21xx accounts (obeskattade reserver).
- **Migration 051 (`set_search_path_on_functions`)** — Pins `search_path = public` on all 24 custom functions to prevent search_path injection.
---
## Type System
- All shared types live in `types/index.ts` — this is the single source of truth
- Import via `import type { TypeName } from '@/types'`
- When adding new domain types, add them to `types/index.ts`
- Event types are the exception — they live in `lib/events/types.ts` (since they reference domain types)
---
## API Route Patterns
```typescript
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { MySchema } from '@/lib/api/schemas'
ensureInitialized() // Module-level — loads extensions for event emission
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const result = await validateBody(request, MySchema)
if (!result.success) return result.response
// Business logic... always filter by user_id (defense in depth alongside RLS)
// Wrap journal entry creation in try/catch (non-blocking side effect)
return NextResponse.json({ data: result })
}
```
- Dynamic route params: `{ params }: { params: Promise<{ id: string }> }` (Next.js 16)
- Response shapes: `{ data }` for success, `{ error }` for failures
---
## Type System
- All shared types in `types/index.ts` (single source of truth). Import via `import type { T } from '@/types'`
- Event types in `lib/events/types.ts`
---
## Skills, Git & CI
**Skills**: Always use `/frontend-design` for new UI. Use `langchain` for AI features. Use `vercel:deploy` for deployment.
**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.
---
## Deployment
Hosted on **Vercel**. Cron jobs in `vercel.json` (banking sync daily 05:00, deadlines 06:00, reminders 08:00, push notifications 09:00, tax deadlines yearly Jan 2, document verify weekly Sunday 03:00).
**Core env vars**: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET`. Extension env vars only needed when that extension is enabled.
## Other
Never create a NUL/nul file: \erp-base\NUL