Fix onCountChange callback causing infinite fetch loop by using a ref instead of including it in useCallback deps. Also update CLAUDE.md with Zod validation docs, env vars, extension design doc reference, and API route patterns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
30 KiB
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 (tsconfig.json).
Language: All code, comments, and commit messages must be in English.
Commands
npm run dev # Start development server
npm run build # Production build
npm run lint # ESLint
npm test # Run all Vitest tests
npx vitest run <dir> # Run tests in a specific directory
Architecture
app/
(auth)/ Login, auth callback
(onboarding)/ 6-step setup wizard
(dashboard)/ Authenticated routes (invoices, customers, transactions,
bookkeeping, reports, suppliers, supplier-invoices,
receipts, settings, calendar, help, import,
extensions, e/[sector]/[slug])
(public)/ Public invoice action links (no auth)
api/ API routes organized by domain
components/
ui/ shadcn/ui primitives (button, card, dialog, table, etc.)
bookkeeping/ Chart of accounts manager, account combobox, add/edit dialogs
calendar/ Calendar views, deadline cards, payment summary, todo widgets
chat/ ChatWidget, ChatPanel, ChatInput, ChatMessage
customers/ CustomerForm
dashboard/ DashboardContent, DashboardNav, FSkattWarningCard
extensions/ Extension marketplace UI (ExtensionCard, SectorCard,
ExtensionToggleButton, workspace components,
per-sector subdirectories)
import/ Bank file import workflow components
invoices/ InvoiceReviewContent
onboarding/ NewUserChecklist, setup step components
reports/ Report views (including BankReconciliationView)
settings/ CalendarFeedSettings
suppliers/ Supplier-related components
transactions/ Transaction list, categorization, booking components
extensions/ Sector-based extension hierarchy
general/ General-purpose extensions (all businesses)
ai-categorization/ AI-powered transaction categorization
ai-chat/ Claude-based chat assistant (LangChain RAG)
enable-banking/ PSD2 bank integration (opt-in, commented out in loader)
example-logger/ Minimal reference extension (not loaded)
invoice-inbox/ Supplier invoice intake via email/upload with AI extraction
push-notifications/ Web push notification system
receipt-ocr/ Receipt image OCR processing (includes components/pages)
restaurant/ Restaurant & cafe sector
food-cost/ Food cost percentage calculator
earnings-per-liter/ Revenue per liter of alcohol
pos-import/ POS Z-report import
tip-tracking/ Tip tracking per shift/employee
construction/ Construction & trades sector
rot-calculator/ ROT tax deduction calculator
project-cost/ Project cost tracking
hotel/ Hotel & lodging sector
revpar/ Revenue Per Available Room
occupancy/ Occupancy rate tracking
tech/ IT & consulting sector
billable-hours/ Billable hours & utilization rate
project-billing/ Project billing analysis
ecommerce/ E-commerce sector
shopify-import/ Shopify order import
multichannel-revenue/ Multi-channel revenue analysis
ne-bilaga/ NE tax form attachment generation (top-level)
sru-export/ SRU file export (top-level)
lib/
api/ Zod validation schemas and utilities for API routes
schemas.ts Zod schemas for all API request bodies and query params
validate.ts validateBody() and validateQuery() helpers
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 (supports 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 codes, merchant patterns)
vat-entries.ts VAT line generation
bas-reference.ts BAS account catalog (~180 accounts with metadata, SRU codes)
account-descriptions.ts Human-readable account name lookup
validate-period-duration.ts Fiscal period duration validation (BFL 3 kap.)
handlers/ Booking handler functions (supplier-invoice-handler.ts)
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
calendar/ Calendar utilities, ICS feed generation
currency/ Riksbanken exchange rates
deadlines/ Tax deadline tracking, status engine
email/ Email service (Resend), invoice/reminder templates
events/ Event bus (bus.ts, types.ts)
extensions/ Extension system
loader.ts FIRST_PARTY_EXTENSIONS array, static imports
registry.ts Runtime extension registry
types.ts Extension, Sector, ExtensionDefinition, toggle types
sectors.ts Sector & extension metadata registry (pure data)
hooks.ts React hooks for extension state
context-factory.ts Extension context builder
toggle-check.ts Extension enable/disable logic
validation.ts Extension data validation
workspace-registry.tsx Extension workspace component registry
icon-resolver.tsx Dynamic icon lookup for extensions
use-account-totals.ts Hook for account balance queries
use-extension-data.ts Hook for extension-specific data
import/ SIE parser, SIE import orchestrator, bank file parser
bank-file/ Bank file parser with format modules
formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank
invoices/ VAT rules, invoice matching, PDF template, reminder processor
reconciliation/ Bank reconciliation engine (4-pass matching algorithm)
reports/ Financial reports (trial-balance, income-statement,
balance-sheet, vat-declaration, sie-export,
supplier-ledger, supplier-reconciliation,
general-ledger, journal-register,
ar-ledger, ar-reconciliation, monthly-breakdown)
supabase/ Client setup (client.ts = browser, server.ts = server/admin,
fetch-all.ts = pagination helper, middleware.ts)
tax/ Tax calculations, deadlines, Swedish holidays, expense warnings
transactions/ Transaction processing, category suggestions
init.ts Extension loader (idempotent, called by API routes)
utils.ts Shared utility functions
types/index.ts Canonical type definitions (single source of truth)
types/chat.ts Chat-specific type definitions
tests/helpers.ts Mock factories and fixture builders
supabase/migrations/ SQL migration files
scripts/ Utility scripts (clear-user-data.sql, copy-extensions.mjs,
move-extensions.js, setup-phase8.js)
dev_docs/ Project documentation (BAS account guides, gap analysis,
Enable Banking docs, Bokio reference screenshots)
extensions.md Extension system design document (architecture, data patterns,
sector model, workspace pattern, migration plan)
Key Relationships
- All journal entry creation routes through
lib/bookkeeping/engine.ts. The entry generators (invoice-entries.ts,transaction-entries.ts,supplier-invoice-entries.ts) callcreateJournalEntry()from the engine. - API routes that emit events must call
ensureInitialized()(fromlib/init.ts) at module level to load extensions. - Event bus (
lib/events/bus.ts) is a module-level singleton. Core services emit, extensions subscribe. - Supabase clients: browser (
lib/supabase/client.ts), server with user cookies (createClient()fromlib/supabase/server.ts), and service role (createServiceClient()). - Extension sector system: Extensions are organized by business sector (
lib/extensions/sectors.ts). Users can browse/toggle extensions via the marketplace UI (app/(dashboard)/extensions/). Sector-specific extension workspaces are rendered atapp/(dashboard)/e/[sector]/[slug]/.
Core Bookkeeping Engine
The bookkeeping engine (lib/bookkeeping/engine.ts) is the most critical system. All accounting flows route through it.
Journal Entry Lifecycle
createDraftEntry(userId, input)— Creates entry withstatus: 'draft',voucher_number: 0. Validates balance. Emitsjournal_entry.drafted.commitEntry(userId, entryId)— Assigns voucher number via DB RPC (next_voucher_number, concurrent-safe). Setsstatus: 'posted'. DB trigger setscommitted_at. Emitsjournal_entry.committed.createJournalEntry(userId, input)— Convenience: 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. Original marked'reversed'.
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 (per-rate lines) |
createInvoiceCashEntry() |
invoice-entries.ts |
Cash method: revenue + VAT at payment (per-rate) |
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
| Account | Description |
|---|---|
1510 |
Accounts receivable |
1930 |
Business bank account |
2013 |
Private withdrawals (enskild firma) |
2440 |
Accounts payable |
2611 / 2621 / 2631 |
Output VAT 25% / 12% / 6% |
2614 |
Output VAT reverse charge |
2641 |
Input VAT (deductible) |
2645 |
Calculated input VAT (EU reverse charge) |
2893 |
Loan from shareholders (aktiebolag) |
3001 / 3002 / 3003 |
Revenue by VAT rate (25% / 12% / 6%) |
3305 / 3308 |
Export / EU service revenue |
3960 / 7960 |
Exchange rate gains / losses |
VAT Treatments
standard_25, reduced_12, reduced_6, reverse_charge, export, exempt
Per-Line VAT
Invoice items support individual vat_rate values, enabling mixed-rate invoices. The helper generatePerRateLines() in invoice-entries.ts groups items by VAT rate and creates separate revenue + VAT account lines per rate group. Available rates depend on customer type — use getAvailableVatRates(customerType, vatNumberValidated) from lib/invoices/vat-rules.ts.
Bank Reconciliation
The reconciliation engine (lib/reconciliation/bank-reconciliation.ts) matches bank transactions to journal entry lines on account 1930 using a 4-pass algorithm:
| Pass | Method | Confidence | Match Criteria |
|---|---|---|---|
| 1 | auto_exact |
0.95 | Exact amount + exact date |
| 2 | auto_reference |
0.90 | Exact amount + reference/description match |
| 3 | auto_date_range |
0.85 | Exact amount + date within ±3 days |
| 4 | auto_fuzzy |
0.75 | Fuzzy amount (±0.01) + exact date |
Manual linking (manual method) is also supported. Only SEK transactions are reconciled. Greedy assignment prevents double-matching.
Accounting Guard Rails
These rules exist for legal compliance and are enforced by database triggers. Never violate them.
- Committed entries are immutable. Once
status: 'posted', an entry cannot be edited or deleted. Enforced by DB triggerenforce_journal_entry_immutability. - Never delete posted entries. Use
reverseEntry()(storno) to cancel. The reversal creates a new entry with swapped debit/credit and bidirectional linking. - Every entry must balance.
sum(debits) === sum(credits), both must be> 0. Checked byvalidateBalance()before insert. - Voucher numbers are sequential. Assigned via DB RPC
next_voucher_number(concurrent-safe). Never set manually. - Period lock enforcement. DB trigger
enforce_period_lockblocks writes to closed/locked fiscal periods. Check period status before creating entries. - 7-year document retention. DB triggers prevent deletion of documents linked to posted entries.
retention_expires_atis auto-calculated asperiod_end + 7 years. - Storno, never edit. To correct an error: reverse the wrong entry, then create a new correct one. Use
correctEntry()fromlib/core/bookkeeping/storno-service.tsfor atomic correction. - Use
Math.round(x * 100) / 100for all monetary calculations. Never usetoFixed()(returns strings, has rounding edge cases). - Always use engine functions. Never insert directly into
journal_entriesorjournal_entry_linestables. Always go throughlib/bookkeeping/engine.ts. - Account numbers are strings. BAS account numbers like
'1930'are always strings, never numbers.
Extension Development
Extensions are first-party plugins organized by business sector in the /extensions/ directory, loaded statically at startup.
Sector System
Extensions are grouped into sectors defined in lib/extensions/sectors.ts. Each sector targets a specific industry (restaurant, construction, hotel, tech, ecommerce) or serves all businesses (general). The sector registry provides metadata used by the extension marketplace UI.
Key types (from lib/extensions/types.ts):
SectorSlug—'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce'ExtensionDefinition— Marketplace metadata (slug, name, sector, category, icon, dataPattern, description)ExtensionCategory—'import' | 'operations' | 'reports' | 'accounting'ExtensionDataPattern—'core' | 'manual' | 'both'(how extension accesses data)ExtensionToggle— Per-user enable/disable state for extensions
Creating a New Extension
- Create
extensions/<sector>/<name>/index.ts - Export an object implementing the
Extensioninterface fromlib/extensions/types.ts - Add a static import to the
FIRST_PARTY_EXTENSIONSarray inlib/extensions/loader.ts - Add metadata to the appropriate sector in
lib/extensions/sectors.ts - Extensions cannot use dynamic imports (Next.js bundling constraint)
Currently Loaded Extensions (FIRST_PARTY_EXTENSIONS)
receiptOcrExtension @/extensions/general/receipt-ocr
aiCategorizationExtension @/extensions/general/ai-categorization
pushNotificationsExtension @/extensions/general/push-notifications
sruExportExtension @/extensions/sru-export
neBilagaExtension @/extensions/ne-bilaga
aiChatExtension @/extensions/general/ai-chat
invoiceInboxExtension @/extensions/general/invoice-inbox
# enableBankingExtension @/extensions/general/enable-banking (commented out, opt-in)
Extension Interface
interface Extension {
id: string // Unique identifier (e.g. 'receipt-ocr')
name: string // Display name
version: string // Semver
// Surfaces (all optional)
routes?: RouteDefinition[]
apiRoutes?: ApiRouteDefinition[]
sidebarItems?: SidebarItem[]
eventHandlers?: ExtensionEventHandler[]
mappingRuleTypes?: MappingRuleTypeDefinition[]
reportTypes?: ReportDefinition[]
settingsPanel?: SettingsPanelDefinition
taxCodes?: TaxCodeDefinition[]
dimensionTypes?: DimensionDefinition[]
// Lifecycle hooks
onInstall?(ctx: ExtensionContext): Promise<void>
onUninstall?(ctx: ExtensionContext): Promise<void>
}
Minimal Example
See extensions/general/example-logger/index.ts:
import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
export const myExtension: Extension = {
id: 'my-extension',
name: 'My Extension',
version: '0.1.0',
eventHandlers: [
{
eventType: 'journal_entry.committed',
handler: async (payload: EventPayload<'journal_entry.committed'>) => {
// React to committed journal entries
},
},
],
}
Available Event Types
All defined in lib/events/types.ts:
| Event | Payload |
|---|---|
journal_entry.drafted |
{ entry, userId } |
journal_entry.committed |
{ entry, userId } |
journal_entry.corrected |
{ original, storno, corrected, userId } |
document.uploaded |
{ document, userId } |
invoice.created |
{ invoice, userId } |
invoice.sent |
{ invoice, userId } |
invoice.paid |
{ invoice, transaction, kursdifferens?, userId } |
invoice.overdue |
{ invoice, days, userId } |
credit_note.created |
{ creditNote, userId } |
transaction.synced |
{ transactions[], userId } |
transaction.categorized |
{ transaction, account, taxCode, userId } |
transaction.reconciled |
{ transaction, journalEntryId, method, userId } |
bank.statement_received |
{ statement, userId } |
bank.payment_notification |
{ notification, userId } |
period.locked |
{ period, userId } |
period.year_closed |
{ period, userId } |
customer.created |
{ customer, userId } |
customer.pseudonymized |
{ customerId, userId } |
receipt.extracted |
{ receipt, documentId, confidence, userId } |
receipt.matched |
{ receipt, transaction, confidence, autoMatched, userId } |
receipt.confirmed |
{ receipt, businessTotal, privateTotal, userId } |
supplier_invoice.received |
{ inboxItem, userId } |
supplier_invoice.extracted |
{ inboxItem, confidence, userId } |
supplier_invoice.confirmed |
{ inboxItem, supplierInvoice, userId } |
audit.security_event |
{ event, userId } |
Event Bus Behavior
- Handlers run concurrently via
Promise.allSettled— a failing handler never crashes the emitter - Module-level singleton, persists across requests in the same process
- One-way: core services emit, extensions subscribe
- Call
eventBus.clear()in tests to reset state
Testing Guidelines
Scope
Test business logic in lib/ and API routes in app/api/. No component tests, no E2E tests.
Framework
Vitest 4 with globals: true and environment: 'node'. Config in vitest.config.ts.
Test Location
Colocated __tests__/ directories alongside source files in lib/.
Test Helpers (tests/helpers.ts)
Supabase mock:
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: makeTransaction(), error: null })
// supabase.from('x').select('*').eq('id', '1') resolves to the mocked result
Fixture factories (all accept Partial<T> overrides):
makeReceipt()— Receipt with default merchant, amounts, datesmakeTransaction()— Transaction with default category, amountmakeFiscalPeriod()— FiscalPeriod with default date rangemakeJournalEntry()— Posted JournalEntry with voucher numbermakeJournalEntryLine()— Line with account number, zero amountsmakeDocumentAttachment()— Document with hash, storage pathmakeTaxCode()— TaxCode with default output VAT 25%makeInvoice()— Invoice with default customer, amounts, datesmakeCustomer()— Customer with default name, addressmakeSupplier()— Supplier with default detailsmakeSupplierInvoice()— Supplier invoice with default amountsmakeCompanySettings()— Company settings with defaultsmakeInvoiceInboxItem()— Invoice inbox item for supplier invoice intakemakeExtensionToggle()— Extension toggle state
Patterns
- Always mock
@/lib/supabase/serverto avoid real DB calls - Use
vi.clearAllMocks()andeventBus.clear()inbeforeEach - Test balance validation edge cases (floating point precision, zero amounts)
- Test error paths (missing fiscal period, unbalanced entries)
- Verify events are emitted correctly
API Route Tests
- Colocated
__tests__/directories alongside route files (e.g.,app/api/invoices/__tests__/route.test.ts) - Mock
@/lib/supabase/server,@/lib/init, and lib functions — do NOT re-test lib business logic - Use
createMockRequest(),parseJsonResponse(),createMockRouteParams()fromtests/helpers.ts - Use
createQueuedMockSupabase()for routes with multiple sequential Supabase calls - Test: auth (401), validation (400), not found (404), errors (500), happy path, non-blocking journal entry failures
Reference Tests
lib/api/__tests__/schemas.test.ts— Zod schema validationlib/api/__tests__/validate.test.ts— Body/query validation helperslib/bookkeeping/__tests__/engine.test.ts— Balance validationlib/bookkeeping/__tests__/invoice-entries.test.ts— Per-line VAT, mixed-rate invoices, credit noteslib/core/bookkeeping/__tests__/storno-service.test.ts— Complex mock queueslib/core/documents/__tests__/document-service.test.ts— Storage mockinglib/events/__tests__/bus.test.ts— Event bus behaviorlib/extensions/__tests__/registry.test.ts— Extension registrationlib/import/__tests__/sie-parser.test.ts— SIE file parsinglib/import/bank-file/__tests__/parser.test.ts— Bank file format parsinglib/reconciliation/__tests__/bank-reconciliation.test.ts— Reconciliation matching algorithmlib/reports/__tests__/vat-declaration.test.ts— VAT declaration reportlib/tax/__tests__/deadline-config.test.ts— Tax deadline configurationlib/transactions/__tests__/ingest.test.ts— Transaction ingestion and dedup
Database & Migrations
Location
supabase/migrations/ — currently 39 files numbered 20240101000001 through 20240101000038 (note: two files share number 000033).
Naming Convention
YYYYMMDD00NNNN_descriptive_name.sql — next migration: 20240101000039_*.sql
Migration Rules
- Always enable RLS on new tables:
ALTER TABLE public.tablename ENABLE ROW LEVEL SECURITY; - Always create RLS policies using
auth.uid() = user_id:CREATE POLICY "tablename_select" ON public.tablename FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "tablename_insert" ON public.tablename FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "tablename_update" ON public.tablename FOR UPDATE USING (auth.uid() = user_id); - Always add
updated_attrigger:CREATE TRIGGER tablename_updated_at BEFORE UPDATE ON public.tablename FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); - UUID primary keys:
id UUID PRIMARY KEY DEFAULT uuid_generate_v4() - User ownership:
user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL - Never modify existing migrations — create new ones instead
- Never modify enforcement triggers (migration
20240101000017) — these are legally required - Never hardcode IDs in data migrations — use subqueries or variables
- Apply via Supabase MCP tool:
mcp__plugin_supabase_supabase__apply_migration - Test on a branch database before applying to production (use
create_branchMCP tool)
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 030 (
bank_reconciliation) — Addsreconciliation_methodcolumn totransactions(CHECK constraint for method types), indexes for unmatched transaction lookup, and RPCget_unlinked_1930_lines()for finding unreconciled GL lines. - Migration 031 (
invoice_document_type) — Addsdocument_typecolumn toinvoices(CHECK: invoice/proforma/delivery_note, default 'invoice') andconverted_from_idFK for tracking proforma-to-invoice conversions. - Migration 032 (
add_accounting_method) — Addsaccounting_methodcolumn tocompany_settings(CHECK: accrual/cash, default 'accrual') to support kontantmetoden vs faktureringsmetoden. - Migration 033 (
ai_chat_schema) — AI chat conversation and message storage. - Migration 033 (
invoice_inbox) — Invoice inbox table for supplier invoice intake. (Note: shares number with ai_chat_schema.) - Migration 034 (
fix_extension_data_trigger) — Fixes extension data trigger. - Migration 035 (
fix_push_notifications) — Push notifications schema fix. - Migration 036 (
fix_enable_banking) — Enable Banking schema fix. - Migration 037 (
extension_toggles) — Extension toggle table for per-user enable/disable. - Migration 038 (
fix_match_documents_search_path) — Fixes search path for document matching function.
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
Standard pattern for new API routes:
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateInvoiceSchema } 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 })
}
// Validate request body with Zod schema
const result = await validateBody(request, CreateInvoiceSchema)
if (!result.success) return result.response
const { data } = result
// Business logic using validated `data`...
// Always filter by user_id (defense in depth alongside RLS)
// Wrap journal entry creation in try/catch (non-blocking side effect)
// Emit events after successful operations
return NextResponse.json({ data: result })
}
Key conventions:
- Call
ensureInitialized()at module level in any route that emits events - Validate all input with
validateBody()/validateQuery()fromlib/api/validate.tsusing schemas fromlib/api/schemas.ts - Dynamic route params use
{ params }: { params: Promise<{ id: string }> }(Next.js 16) - Response shapes:
{ data }for success,{ error }for failures - Journal entry creation is non-blocking: catch errors and continue
Skills Usage
- Always use the
/frontend-designskill when creating new UI pages or significant components. This ensures consistent use of shadcn/ui, Tailwind CSS 4, and the existing component library. - Use the
langchainskill when building or modifying AI features (ai-chat, ai-categorization extensions). - Use
vercel:deployfor deployment tasks.
Git Conventions
Use conventional commits:
feat: add supplier invoice payment tracking
fix: correct VAT calculation for reduced 12% rate
refactor: extract VAT line generation into vat-entries.ts
test: add balance validation edge cases
docs: update CLAUDE.md with extension guide
- Atomic commits — one logical change per commit
- Branch from
mainfor new work
Deployment
Hosted on Vercel with cron jobs defined in vercel.json:
| Cron Job | Schedule |
|---|---|
/api/extensions/enable-banking/sync/cron |
Daily 05:00 |
/api/deadlines/status/cron |
Daily 06:00 |
/api/invoices/reminders/cron |
Daily 08:00 |
/api/extensions/push-notifications/cron |
Daily 09:00 |
/api/tax-deadlines/cron |
Yearly, January 2nd at 00:00 |
/api/documents/verify/cron |
Weekly, Sunday 03:00 |
Required Environment Variables
NEXT_PUBLIC_SUPABASE_URL # Supabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY # Supabase anonymous key
SUPABASE_SERVICE_ROLE_KEY # Supabase service role key
RESEND_API_KEY # Resend email service API key
RESEND_FROM_EMAIL # Sender email for transactional mail
RESEND_WEBHOOK_SECRET # Webhook auth for Resend
ENABLE_BANKING_APP_ID # Enable Banking app ID
ENABLE_BANKING_PRIVATE_KEY # Enable Banking private key (base64-encoded)
ENABLE_BANKING_SANDBOX # Enable Banking sandbox mode flag
ANTHROPIC_API_KEY # Claude API key (ai-chat)
OPENAI_API_KEY # OpenAI API key (embeddings)
NEXT_PUBLIC_APP_URL # App base URL
CRON_SECRET # Auth secret for Vercel cron jobs
NEXT_PUBLIC_VAPID_PUBLIC_KEY # Web push public key
VAPID_PRIVATE_KEY # Web push private key
VAPID_SUBJECT # VAPID subject (mailto: URI) for web push
Other
We should never create a nul file