* feat: add language preference for customers to support invoice localization - Introduced language support for invoices, allowing customers to choose between Swedish and English. - Updated invoice PDF generation to reflect the selected language for titles, labels, and messages. - Enhanced email templates to generate content in the customer's preferred language. - Added migration to include a language column in the customers table with a default value of Swedish. - Updated tests to verify correct language usage in invoice emails and PDFs. * fix: debounce API requests in InvoicePreviewCard and update F-skatt terminology in email templates
35 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. 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.
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
Key Architectural Relationships
- Multi-tenant model:
companiesowns all business data.company_memberslinks users to companies (owner/admin/member/viewer).teamsgroup companies. Context resolved viagnubok-company-idcookie inlib/supabase/middleware.ts. - All journal entry creation routes through
lib/bookkeeping/engine.ts. Lifecycle:createDraftEntry()→commitEntry()(atomic voucher viacommit_journal_entryRPC).createJournalEntry()does both. Reversal:reverseEntry(). Correction:correctEntry()inlib/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 usingPromise.allSettled. 36 event types inlib/events/types.ts. Persisted toevent_logtable (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. Enabled:enable-banking,email,arcim-migration,tic,mcp-server,cloud-backup. - Core reports (
lib/reports/): balance sheet, income statement, trial balance, general ledger, AR/supplier ledger + reconciliation, VAT declaration, journal register, monthly breakdown, continuity check, opening balances, KPI, NE-bilaga, INK2, SIE export, full archive, salary journal, vacation liability, avgifter basis. - Types: Shared types in
types/index.ts(~2,570 lines). Import viaimport type { T } from '@/types'. Event types inlib/events/types.ts. Extension types inlib/extensions/types.ts. - Error messages:
lib/errors/get-error-message.tsmaps to Swedish (Zod → Postgres → HTTP → fallback).
Multi-Tenant Architecture
- companies: Business unit. All business data has a
company_idcolumn. - company_members: Roles
owner/admin/member/viewer, sourcedirect|team. - teams: Consultant grouping. Team members auto-sync to company_members via DB triggers.
- user_preferences: Stores
active_company_id.
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 enforcedNEXT_PUBLIC_REQUIRE_MFA=true→ middleware redirects to/mfa/enrollor/mfa/verifyuntil 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 (bookkeeping/bas-data/): Full BAS 2026 chart by class (1–8) + SRU mapping.
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). Use getAvailableVatRates(customerType, vatNumberValidated) from lib/invoices/vat-rules.ts. VIES validation via lib/vat/vies-client.ts.
VAT Declaration Rutor (SKV 4700)
VatDeclarationRutor type maps to momsdeklaration:
- Ruta 05: Domestic taxable sales (3001+3002+3003)
- Ruta 06/07: Unused, always 0
- Ruta 10/11/12: Output VAT 25%/12%/6% (2611/2621/2631)
- Ruta 39/40: EU services / Export (3308/3305)
- Ruta 48: Input VAT (2641/2645)
- Ruta 49: Moms att betala/återfå = (10+11+12+30+31+32+60+61+62) − 48
Core Services (lib/core/)
bookkeeping/period-service.ts— Fiscal period lifecycle management (open, close, lock)bookkeeping/year-end-service.ts— Year-end closing proceduresbookkeeping/storno-service.ts— Reversal/correction entry generationtax/tax-code-service.ts— Tax code definitions and ratesaudit/audit-service.ts— Audit trail and compliance loggingdocuments/document-service.ts— Document attachment lifecycle (WORM storage with version chains)
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 atomically via
commit_journal_entryDB RPC. Never set manually. - Voucher gap documentation. BFNAR 2013:2 requires documented explanations for gaps (
voucher_gap_explanationstable,detect_voucher_gapsRPC). - Period lock enforcement. DB trigger blocks writes to closed/locked periods. Company-wide lock date enforced via
enforce_company_lock_date()trigger. - 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 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.
Available (12): Enabled — enable-banking (PSD2), email (Resend), arcim-migration, tic (org lookup), mcp-server, cloud-backup (Google Drive). Disabled — inbox-smart-match, invoice-inbox, push-notifications, calendar, skatteverket, example-logger.
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.
API routes: app/api/extensions/ext/[...path]/route.ts catch-all → /api/extensions/ext/{extensionId}/{routePath}. Path params as _paramName query.
Service patterns: Interface registration (email — registerEmailService()/getEmailService()) or services record (extension exposes via services property).
Creating: npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "...".
MCP Server & API Keys
gnubok exposes its bookkeeping engine as an MCP server for Claude Desktop/Code.
MCP extension (extensions/general/mcp-server/): 35 tools covering transactions, categorization, customers/suppliers, invoices, accounts, fiscal periods, reports (trial balance, GL, BS, IS, AR/supplier ledger, VAT, KPI), reconciliation, salary runs, AGI, document upload. JSON-RPC 2.0. Endpoint: /api/extensions/ext/mcp-server/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).
OAuth 2.1 for Claude connectors: .well-known/oauth-protected-resource + .well-known/oauth-authorization-server discovery; /api/mcp-oauth/authorize, /token (PKCE), /register. Stateless AES-256-GCM auth codes (lib/auth/oauth-codes.ts). Single-use via oauth_used_codes. Allowlist: claude.ai/api/*, claude.com/api/*, localhost.
npm package (packages/gnubok-mcp): Stdio-to-HTTP bridge; users run npx gnubok-mcp with API key.
Tool authoring conventions (enforced by tests):
- Every
inputSchemamust declareadditionalProperties: falseat the top level. Guarded byextensions/general/mcp-server/__tests__/strict-schemas.test.ts. - Tool descriptions must be ≤ 280 chars (guarded by
output-schema.test.ts). NoArgs:/Returns:/Examples:blocks — those belong in JSON Schema, not description prose. Use agent-native hints like "Use to…" / "Call X first" instead. - Completion-signal pattern: write tools that stage operations return
STAGED_OPERATION_SCHEMA(server.ts:495) —{ staged, risk_level, actor, message, preview, period_status?, next? }. Thestaged: trueboolean is the explicit completion signal; agents must not infer completion from prose. Do NOT introduce a parallel{ success, shouldContinue, output }envelope. - Tools that touch a fiscal-period-bound date (categorize, mark paid, create voucher, correct/reverse entry, approve supplier invoice) pass
dateForPeriodChecktostagePendingOperationso the response includesperiod_status: { period_id, status: open|locked|closed, lock_date }. Widgets and agents use this to disable writes without round-trips.
API Route Pattern
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 company_id (defense in depth alongside RLS)
return NextResponse.json({ data: result })
}
- Dynamic route params:
{ params }: { params: Promise<{ id: string }> }(Next.js 16) - Response shapes:
{ data }for success,{ error }for failures - Zod schemas in
lib/api/schemas.ts— 30+ schemas with shared primitives (uuid, isoDate, accountNumber, nonNegativeAmount)
Key lib/ Directories
bookkeeping/— Engine, entry generators, mapping, templates, BAS datacore/— Period, year-end, storno, tax codes, audit, documentsevents/— Bus singleton, 36 event types, event log handlerauth/— API keys, require-auth/write, MFA, OAuth codes, invite tokens, cron, BankIDsupabase/— Clients, middleware,fetchAllRowspaginationapi/— Zod validation (validateBody/validateQuery), schemasreports/— 20 report generatorsinvoices/— Matching, payment log, reminders, VAT rules, PDFtransactions/—ingest.ts, AI suggestionsimport/— SIE, bank file, opening balance, account mapperdocuments/— Matchers (single + batch)extensions/— Registry, loader, context factoryemail/— Service interface, Resend, templatescompany/— Context resolution, CRUD, fiscal period computationproviders/— Fortnox, Bokio, Briox, BL, Visma (OAuth, retry, consent)salary/— Payroll engine, tax tables, AGI, KU, payslips, löneväxling, personnummerprocessing-history/,reconciliation/,tax/,vat/(VIES, MOMS box),deadlines/,currency/(Riksbanken),skatteverket/,bankgiro/(Luhn),calendar/(ICS)errors/— Swedish error mapping (Zod → Postgres → HTTP → fallback)rate-limits/— Postgres-backedcheckInboxUploadRateLimitviacheck_and_increment_inbox_quotaRPC; fails openhooks/,logger.ts,support.ts,utils.ts(cn(),formatCurrency(),formatDate(),formatOrgNumber())
App Routes
Pages: /login, /register, /reset-password, /mfa/{enroll,verify}, /onboarding, /companies/new, /invite/[token], / (dashboard), /transactions, /invoices[/new|/[id]|/[id]/credit], /supplier-invoices[/new|/[id]], /customers[/[id]], /suppliers[/[id]], /expenses[/new|/[id]], /receipts[/scan], /bookkeeping[/[id]|/year-end], /salary[/employees|/runs], /reports, /import, /kpi, /deadlines, /pending, /help, /extensions[/[sector]/[ext]], /e/[sector]/[slug] (workspace), /settings/*, /dpa, /privacy, /invoice-action/[token], /sandbox.
API endpoints:
/api/bookkeeping/*— accounts, fiscal periods, journal entries (CRUD/reverse/correct), mapping rules, voucher gaps/api/invoices/*,/api/supplier-invoices/*— CRUD + state transitions/api/transactions/*— categorize, describe, book, match-{invoice,supplier-invoice}, batch, AI suggestions/api/customers/*,/api/suppliers/*— CRUD/api/documents/*— CRUD, versions, link, match-sweep, verify cron/api/reports/*— 19 endpoints (GL, TB, BS, IS, AR/supplier ledger, VAT, SIE, INK2, NE-bilaga, KPI, audit, continuity, monthly, full-archive, salary, vacation, avgifter)/api/salary/*— employees, payroll-config, tax-tables, KU, runs/api/import/*— bank-file, SIE (parse/execute/mappings)/api/reconciliation/bank/*,/api/settings/*,/api/company/*,/api/team/*/api/deadlines/*,/api/tax-deadlines/*— CRUD + crons/api/pending-operations/*,/api/events/*,/api/audit-trail/*/api/calendar/feed/[token],/api/mcp-oauth/*,/api/support/contact,/api/account/delete/api/log,/api/health,/api/vat/validate,/api/currency/rate,/api/sandbox/*/api/extensions/ext/[...path]— dynamic extension routes
Testing
Framework: Vitest 4, node env, tests in __tests__/. Scope: lib/ and app/api/. No component/E2E tests.
Helpers (tests/helpers.ts): createMockSupabase(), createQueuedMockSupabase(), createMockRequest(), parseJsonResponse(), createMockRouteParams(), plus fixture factories (makeTransaction, makeJournalEntry, makeInvoice, makeCustomer, makeSupplier, makeSupplierInvoice, makeFiscalPeriod, makeReceipt, makeDocumentAttachment, makeCompany, makeCompanySettings, makeTaxCode, makeSIEVoucher, makeBankConnection, etc.).
Patterns: Always mock @/lib/supabase/server. vi.clearAllMocks() + eventBus.clear() in beforeEach. Test auth (401), validation (400), 404, 500, happy path.
pg-real: Parallel Vitest project for triggers/RPCs/RLS using real Postgres (CI: supabase/postgres:15, migrations replayed). Local: npm run test:pg. File convention *.pg.test.ts. Helpers: tests/pg/setup.ts (getPool(), withUserContext()), tests/pg/fixtures.ts (seedCompany(), insertDraftJournalEntry(), etc.). Required: any PR touching a trigger/RPC/RLS/DEFERRABLE must include or extend a *.pg.test.ts.
Database & Migrations
Location: supabase/migrations/ — 118 files. Early migrations use sequential numbering (20240101000001–20240101000038), later ones use real timestamps.
Key Tables (~60)
- Multi-tenant:
companies,company_members,company_invitations,teams,team_members,team_invitations,user_preferences,profiles - Bookkeeping:
chart_of_accounts,fiscal_periods,journal_entries,journal_entry_lines,account_balances,voucher_sequences,voucher_gap_explanations - Invoicing:
customers,invoices,invoice_items,invoice_payments,invoice_inbox_items - Suppliers:
suppliers,supplier_invoices,supplier_invoice_items - Banking:
bank_connections,transactions,bank_file_imports,payment_match_log - Documents:
document_attachments(WORM),receipts,receipt_line_items - Settings:
company_settings,mapping_rules,categorization_templates,booking_template_library,extension_data - Dimensions:
cost_centers,projects - Tax/Deadlines:
tax_rates,tax_table_rates,deadlines,calendar_feeds,skatteverket_tokens - API/Auth:
api_keys,oauth_used_codes,bankid_identities - Audit/Ops:
audit_log(immutable),event_log(30d TTL),pending_operations,processing_history,ai_usage_tracking,automation_webhooks - Inbox:
invoice_inbox_items,company_inboxes,email_connections - Salary:
employees,salary_runs,salary_run_employees,salary_line_items,salary_payroll_config,agi_declarations - Providers:
provider_consents,provider_consent_tokens,provider_otc - Other:
sandbox_users
Key RPC Functions
create_company_with_owner()— Atomic company + owner creationcommit_journal_entry()— Atomic draft→posted with voucher numbernext_voucher_number()— Concurrent-safe voucher generationdetect_voucher_gaps()— BFNAR 2013:2 gap detectiongenerate_invoice_number(),get_next_arrival_number(),generate_delivery_note_number()— Sequence generatorsseed_chart_of_accounts()— BAS chart seeding per entity typevalidate_and_increment_api_key()— Atomic rate limitinguser_company_ids()— RLS helper returning user's company IDsget_unlinked_1930_lines()— Bank reconciliation helpercleanup_sandbox_user(),cleanup_expired_sandbox_users()— Sandbox lifecycle
Key Triggers
check_journal_entry_balance()— Debit must equal creditenforce_journal_entry_immutability()— Posted entries cannot be modifiedenforce_period_lock()— No entries in closed/locked periodsenforce_company_lock_date()— Company-wide bookkeeping lock dateblock_document_deletion()— WORM complianceenforce_retention_journal_entries()— 7-year retentionaudit_log_immutable()— Audit log cannot be modifiedwrite_audit_log()— Auto-audit on DML operationssync_team_member_to_companies()— Auto-sync team→company membership
Migration Rules
- Enable RLS + policies using
user_company_ids()for company-scoped data - Add
updated_attrigger viaupdate_updated_at_column() - UUID PKs:
DEFAULT uuid_generate_v4() - Company ownership:
company_id UUID REFERENCES companies NOT NULL+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
apply_migration - Always end with
NOTIFY pgrst, 'reload schema'when altering table structure
Skills, Git & CI
Skills: Always use /frontend-design for new UI. Use vercel:deploy for deployment. Use /supabase-migration for new migrations. Use /erp-api-route for new API routes. Use /create-extension for new extensions. Use the Swedish domain skills (swedish-sie-import-export, swedish-accounting-compliance, swedish-vat, swedish-invoice-compliance, swedish-payroll, swedish-year-end-closing, swedish-financial-reporting, swedish-sru-filing, swedish-asset-accounting, swedish-project-accounting, swedish-tax-planning) for accounting domain questions.
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 on main.
Docker (.github/workflows/docker-publish.yml): Pushes to GHCR (erp-mafia/erp-base) on main push. 4-stage Dockerfile (base → deps → builder → runner) with Node 22 Alpine. Runtime env placeholder replacement via docker-entrypoint.sh. Docker Compose with app + supercronic cron service.
Deployment
Vercel (Hosted)
Cron jobs in vercel.json: deadline status (6:00), invoice reminders (8:00), tax deadlines (yearly Jan 2), enable-banking sync (5:00), document verify (3:00), sandbox cleanup (4:00), event log cleanup (2:00, 30-day TTL), cloud-backup auto-sync (hourly).
Docker (Self-Hosted)
Dockerfile: 4-stage Node 22 Alpine build with standalone outputdocker-compose.yml: App service + supercronic cron schedulerdocker-entrypoint.sh: Validates required env vars, replaces build-time placeholders in.next/static/JS- Extension presets:
docker/extensions.self-hosted.json,docker/extensions.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 extension is 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
Other
Never create a NUL/nul file: \gnubok\NUL
i18n
The app is Swedish-first and bilingual (Swedish + English) for UI chrome. Locale is per-user on user_preferences.locale ('sv' | 'en', default 'sv'), resolved server-side via next-intl. The user picker lives at /settings/account.
Pattern for new UI:
// Server component
import { getTranslations } from 'next-intl/server'
const t = await getTranslations('namespace')
// Client component
'use client'
import { useTranslations } from 'next-intl'
const t = useTranslations('namespace')
// In JSX
<button>{t('save')}</button>
Add new strings to both messages/sv.json and messages/en.json under the matching namespace (common, nav, auth, settings, empty, etc.). Never ship an English key without a Swedish counterpart — Swedish is the default and the fallback.
Locale-aware formatters:
formatCurrency(amount)— stays SEK with sv-SE conventions in BOTH locales (Swedish accounting standard, not a UI string).formatDate(date)— ISOyyyy-MM-dd, locale-independent.formatDateLong(date, locale)— accepts locale. In client components useuseFormat()(lib/hooks/use-format.ts) which pulls the active locale.
Error messages: getErrorMessage(err, { locale, context }) from lib/errors/get-error-message.ts is bilingual on the primary maps (Postgres codes, HTTP statuses, context fallbacks, generic fallback). The structured error envelope ({ error: { code, message, message_en } }) already carries both; the function picks the right one from locale. Pass useLocale() / getLocale() as the locale arg.
Stays Swedish — do NOT translate:
| Surface | Reason |
|---|---|
Invoice PDFs (lib/invoices/pdf-template.tsx) |
Customer-facing — driven by customer.language (sv default, en opt-in). The template's chrome translates; statutory chapter refs (ML 17 kap 24§, ML 3 kap.) stay intact in both locales. |
Customer email templates (lib/email/invoice-templates.ts, reminder-templates.ts) |
Same — customer.language drives the output. reminder-templates.ts is still Swedish-only; mirror the PDF/invoice-templates approach if you add English here. |
Year-end wizard (app/(dashboard)/bookkeeping/year-end/page.tsx) |
Statutory bokslut terminology; English would be misleading |
Journal entry editor (app/(dashboard)/bookkeeping/[id]/page.tsx) |
Deeply regulatory (verifikat, voucher numbers, BAS) |
INK2 / NE-bilaga / SRU (lib/reports/ink2/**, lib/reports/ne-bilaga/**, lib/reports/sru-*) |
Skatteverket forms — field codes and labels are statutory |
SIE export (lib/reports/sie-export.ts) |
SIE format is Swedish-only by spec (#KONTO, #VER, etc.) |
BAS chart names (lib/bookkeeping/bas-data/**) |
Standardized Swedish account names per BAS 2026 |
VAT declaration ruta labels (lib/reports/vat-declaration*.ts) |
Momsdeklaration field labels are Skatteverket form labels |
Salary AGI / KU (lib/salary/agi*, lib/salary/ku*) |
Skatteverket-bound forms |
| Bookkeeping engine domain errors ("Verifikationen balanserar inte", "Bokföringen är låst") | Regulatory concepts; English equivalents would be ambiguous |
Anything in the table above stays Swedish in BOTH locales. If you find yourself reaching for t() inside one of these files, stop and reconsider.
Design Context
Users
Swedish sole traders (enskild firma) and small business owners (aktiebolag) who need to manage their own bookkeeping. They are not accountants — they are professionals (consultants, freelancers, shop owners) who want to stay compliant without hiring one. They use gnubok in short, focused sessions: sending an invoice, categorizing bank transactions, filing a VAT declaration. Speed and clarity matter — every second spent in the app is a second away from their real work.
Brand & Aesthetic
Editorial monochrome. Paper-white surfaces, hairline borders, serif headlines. The interface should feel like a well-made instrument — considered, quiet, confident. Anti-references: enterprise software (SAP/Oracle density), neon SaaS coldness.
- Palette: Achromatic foundation. Pure white background, warm beige (
40 11% 89%) for chips / active sidebar / hover / secondary buttons. Achromatic primary (no cool tint). Semantic colors (--successsage,--warningochre,--destructiveterracotta) exist but are data-only — they appear in charts and financial numbers (positive/negative deltas), never as chrome backgrounds. In chrome, only--destructivesurvives. - Typography: Hedvig Letters Serif for display headings, Geist (sans) for body, forms, and tables. Hedvig is single-weight (400) — do not apply
font-mediumto display text; its natural high-contrast strokes carry the weight. Tabular numbers everywhere financial data appears. - Surfaces: Cards sit flat on the page — no shadow, full-opacity hairline border (
border-border),rounded-lg(8px). Card background matches page background; the border carries hierarchy. Dark mode drops the warm tint from secondary for a pure-gray mood shift; light mode keeps the beige. - Spacing: Generous whitespace. Dense data (tables, ledgers) uses tighter spacing but never feels cramped.
- Motion: Functional, not decorative. No press-scale, no hover-lift, no spring overshoot. Hover state is a flat background shift (
bg-secondary/60).transition-colors duration-150is the default. Stagger animations on list entry are fine. Respectprefers-reduced-motion(already wired). - Icons: Lucide — 15px in navigation, slightly larger in empty states.
Design Principles
- Clarity over cleverness — Swedish labels, obvious hierarchy.
- Earned minimalism — remove what doesn't serve the task, keep compliance context.
- Numbers are first-class — tabular-nums, alignment, positive/negative clarity.
- Trust through consistency.
- Speed is a feature — optimize for the 90-second session.
Accessibility
WCAG AA (4.5:1 text, 3:1 UI). Keyboard-navigable + visible focus rings. Respect prefers-reduced-motion. Color never sole state indicator. Touch targets ≥40px (44px for mobile-critical). Icon-only buttons need aria-label.
Design System Tokens
These conventions are locked. Don't reinvent them in new code; deviating from them on existing pages is a regression.
Spacing scale. Only use Tailwind values 1, 2, 3, 4, 6, 8, 10, 12. Forbidden: 2.5, 5, hardcoded pixels in page logic.
| Token | Tailwind | Use for |
|---|---|---|
| 4 | 1 |
icon padding |
| 8 | 2 |
tight inline gaps |
| 12 | 3 |
dense list rows, badge gaps |
| 16 | 4 |
default form / control / grid gap |
| 24 | 6 |
card padding default (p-6) |
| 32 | 8 |
between page sections (space-y-8 on page root) |
| 40 | 10 |
hero spacing |
| 48 | 12 |
top of page after header |
Compact metric cards (e.g. dashboard tiles, salary KPI row) use p-4. Detail cards use p-6. Never mix p-5.
Layout.
- Sidebar width:
md:w-64(256px). Main content offset:md:pl-64. - Main container:
max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10(viacomponents/dashboard/MainContainer.tsx). - Page root:
<div className="space-y-8">.
Primitives — always use these, don't hand-roll.
| Need | Component | Notes |
|---|---|---|
| Page title + action | components/ui/page-header.tsx PageHeader |
Use this, not bespoke <h1> + <p> blocks. Drop the description prop when it just paraphrases the title. |
| Data table | components/ui/table.tsx Table / TableHeader / TableHead / TableRow / TableCell |
Header style is baked in: text-[11px] font-medium uppercase tracking-wider text-muted-foreground. Wrap in <CardContent className="p-0"> when the table is a card's primary content. Add tabular-nums to numeric cells. |
| Status indicator | components/ui/badge.tsx <Badge variant> |
Variants: default / secondary / success / warning / destructive / outline. Never use raw Tailwind colors (bg-blue-100, bg-emerald-500/10, etc.) for status. Map status → variant via a small Record per feature. |
| No-data state | components/ui/empty-state.tsx EmptyState |
Don't hand-roll <div className="flex flex-col items-center py-12">…</div>. Preset variants exist (EmptyInvoices, EmptyCustomers, EmptyTransactions, etc.). |
| Loading placeholder | components/ui/skeleton.tsx <Skeleton> |
Don't hand-roll bg-muted rounded animate-pulse divs. |
| Inline help / formulas | components/ui/info-tooltip.tsx InfoTooltip |
Hover-revealed; don't use always-visible info buttons. |
| Fiscal year picker | components/common/FiscalYearSelector.tsx |
Don't use raw <select> for fiscal periods. |
Tabular display rules.
- All financial values get
tabular-nums. - Dates in tables:
tabular-numsfor fixed width. - Right-align numeric columns (
text-right). - For group bands inside tables (Resultatrapport-style):
<tr className="bg-muted/30"><td colSpan={n} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">{label}</td></tr>.
Date formatting. Two helpers in lib/utils.ts:
formatDate(x)→2026-05-11(ISOyyyy-MM-dd). Use for accounting data — transaction dates, invoice dates, payment dates, voucher dates. Aligns in tables, matches SIE/BFL convention.formatDateLong(x)→11 maj 2026(Swedish long form). Use for metadata — when something was created, linked, verified, expires. Settings panels and audit displays.
Never render raw {x.invoice_date} directly — always route through formatDate() for code consistency.
Currency. formatCurrency(n, currency?) from lib/utils.ts. Default SEK.
Typography.
- Page title: use
PageHeader(rendersfont-display text-3xl md:text-4xl tracking-tight). Do not hand-roll an<h1>. - Card title:
<CardTitle className="text-base">for sections, default for primary cards. The primitive already dropsfont-medium— do not add it back. - Section divider header inside a page:
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">. - Headline number:
font-display text-xl tabular-nums. Nofont-medium— Hedvig's natural weight carries the gravitas. - Display font (
font-display, Hedvig Letters Serif) reserved for h1/h2/h3 and primary financial numbers. If a specificfont-displaynumeral reads weak inside a compact metric card, override that call site withfont-sans tabular-nums(Geist) — better legibility on small numerals.
Forbidden / dead patterns.
- Page descriptions that paraphrase the page title (e.g.
<PageHeader title="Fakturor" description="Hantera dina fakturor">) → drop the description. - Two different status indicators on the same element (e.g. colored card border and Badge for status) → pick one (prefer Badge).
- Mobile-specific
<select>duplicating desktop tabs in code — use a single Tabs primitive or a single groupedSelect. - Hand-rolled icon buttons smaller than
h-10 w-10. Use shadcnButton size="icon". - Color-coded status using full-rainbow Tailwind palette (
bg-amber-100,bg-emerald-500/10, etc.). Use Badge variants tied to the brand palette. shadow-sm/shadow-md/shadow-lgon cards, buttons, or list items. The aesthetic is flat-with-hairlines — surfaces useborder-border, not elevation. Shadows survive only on dialogs/popovers/dropdowns (anything that overlays the page).active:scale-[...]on buttons. Buttons do not bounce.bg-gradient-to-*on page or card backgrounds. Flat surfaces only.font-mediumon display elements (font-display, h1/h2/h3, CardTitle, PageHeader title). Hedvig is single-weight by design.rounded-xl(12px) on cards. Cards arerounded-lg(8px).rounded-xlsurvives only on prominent hero-style surfaces if absolutely needed.- Opacity-suffixed border classes (
border-border/30,border-border/60) on cards and primary surfaces. Use full-opacityborder-border— the new border token is calibrated for that.