Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
18 KiB
CLAUDE.md — gnubok
Project Overview
gnubok 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), JSZip (archive export).
Path alias: @/* maps to the project root. Language: All code, comments, and commit messages in English.
Commands
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), DPA, privacy policy
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)
ai-consent.ts AI consent gate for extensions using third-party AI providers
_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, INK2, SRU export,
full archive ZIP 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.tsviacreateJournalEntry(). - API routes that emit events must call
ensureInitialized()(fromlib/init.ts) at module level. - Event bus (
lib/events/bus.ts) is a module-level singleton. Handlers run viaPromise.allSettled. - Supabase clients: browser (
lib/supabase/client.ts), server with cookies (createClient()fromserver.ts), service role (createServiceClient()). - Extension system: Opt-in via
extensions.config.json. Core builds and runs with zero extensions. - NE-bilaga, INK2 declaration, SRU export, and full archive export are core reports (in
lib/reports/), not extensions. - AI consent gate (
lib/extensions/ai-consent.ts): AI extensions (receipt-ocr,ai-categorization,ai-chat) require user consent before API calls. The extension catch-all route checks consent and returns403 AI_CONSENT_REQUIREDif missing.
Core Bookkeeping Engine
The engine (lib/bookkeeping/engine.ts) is the most critical system. All accounting flows route through it.
Journal Entry Lifecycle
createDraftEntry(userId, input)— Createsstatus: 'draft',voucher_number: 0. Validates balance. Emitsjournal_entry.drafted.commitEntry(userId, entryId)— Assigns voucher number via DB RPC (concurrent-safe). Setsstatus: 'posted'. Emitsjournal_entry.committed.createJournalEntry(userId, input)— Draft + commit in one call. This is what all entry generators use.reverseEntry(userId, entryId)— Storno reversal: swaps debit/credit, links viareverses_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.
VAT Declaration Rutor (SKV 4700)
The VatDeclarationRutor type maps to the Swedish tax authority's momsdeklaration form:
- Ruta 05: Momspliktig försäljning — total domestic taxable sales (all rates combined, from 3001+3002+3003)
- Ruta 06/07: Unused (momspliktiga uttag / vinstmarginalbeskattning), always 0
- Ruta 10/11/12: Utgående moms 25%/12%/6% — output VAT per rate (from 2611/2621/2631)
- Ruta 39/40: EU services / Export (from 3308/3305)
- Ruta 48: Ingående moms — input VAT (from 2641/2645)
- Ruta 49: Moms att betala/återfå = (ruta 10 + 11 + 12) - ruta 48
The VatDeclaration.breakdown.invoices also includes base25/base12/base6 for per-rate revenue breakdown in the UI.
Bank Reconciliation
lib/reconciliation/bank-reconciliation.ts — 4-pass matching on account 1930:
auto_exact(0.95) — exact amount + exact dateauto_reference(0.90) — exact amount + reference matchauto_date_range(0.85) — exact amount + date ±3 daysauto_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.
- Committed entries are immutable. Once
status: 'posted', cannot be edited or deleted (DB trigger). - Never delete posted entries. Use
reverseEntry()(storno) to cancel. - Every entry must balance.
sum(debits) === sum(credits), both> 0. - Voucher numbers are sequential. Assigned via DB RPC. Never set manually.
- Period lock enforcement. DB trigger blocks writes to closed/locked periods.
- 7-year document retention. DB triggers prevent deletion of documents linked to posted entries.
- Storno, never edit. Use
correctEntry()fromlib/core/bookkeeping/storno-service.ts. - Use
Math.round(x * 100) / 100for monetary calculations. NevertoFixed(). - Always use engine functions. Never insert directly into journal tables.
- Account numbers are strings.
'1930', never1930.
Extension System
Extensions are opt-in plugins controlled by extensions.config.json. Core builds and runs with zero extensions.
How It Works
- Each extension has a
manifest.jsoninextensions/general/<name>/. extensions.config.jsonlists enabled extension IDs.npm run setup:extensionsgenerates files inlib/extensions/_generated/(static imports, workspace map, definitions).predev/prebuildhooks 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 |
invoice-inbox |
import | ANTHROPIC_API_KEY |
calendar |
operations | — |
enable-banking |
import | Enable Banking keys |
email |
operations | RESEND_API_KEY, RESEND_FROM_EMAIL |
Creating Extensions
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
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 mockscreateMockRequest(),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()andeventBus.clear()inbeforeEach - API route tests: mock
@/lib/initand 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 thetax_codestable. - 023 (
document_version_chain) — Planned but never deployed. Document versioning columns/functions do not exist in production.
Migration Rules
- Always enable RLS and create
SELECT/INSERT/UPDATEpolicies usingauth.uid() = user_id - Always add
updated_attrigger usingupdate_updated_at_column() - UUID primary keys:
DEFAULT uuid_generate_v4() - User ownership:
user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL - Never modify existing migrations — create new ones
- Never modify enforcement triggers (migration 017) — legally required
- 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 entriesenforce_journal_entry_line_immutability— Blocks line modifications on committed entriesenforce_period_lock— Blocks writes to closed/locked fiscal periodsblock_document_deletion— Prevents deletion of documents linked to committed entriesenforce_retention_journal_entries— 7-year retention enforcementset_committed_at— Auto-sets timestamp on draft-to-posted transitioncalculate_retention_expiry— Auto-setsretention_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) — Addsuntaxed_reservestochart_of_accounts.account_typeCHECK constraint for BAS 21xx accounts (obeskattade reserver). - Migration 051 (
set_search_path_on_functions) — Pinssearch_path = publicon 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
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
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: \gnubok\NUL