feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup

- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-04 14:19:56 +01:00
co-authored by Claude Opus 4.6
parent a9bf24ce0b
commit 66a4027f1e
104 changed files with 4964 additions and 5501 deletions
-51
View File
@@ -1,51 +0,0 @@
ERP-Base: Gap-analys mot svensk bokföringsmarknad
1. SAKNAS HELT — Kritiska luckor
1.1 Leverantörsreskontra
Alla etablerade system har fullständig leverantörsreskontra: registrering av inkommande fakturor, förfallodatum, betalningsstatus, automatisk bokföring vid betalning. Ditt system saknar tabeller och flöden för leverantörsfakturor. Detta är ett absolut krav för att kunna kallas bokföringssystem.
Behövs: suppliers-tabell, supplier_invoices-tabell, flöde för registrering/betalning/bokföring, leverantörsreskontra-rapport, stöd för både kontant- och fakturametoden.
1.2 Kundreskontra (formellt)
Du har invoices och customers, men det saknas en explicit kundreskontra-vy som visar utestående fordringar, förfallna fakturor, och avstämning mot konto 1510. Alla konkurrenter har detta som standardfunktion.
1.3 Lönehantering
salary_payments finns men alla konkurrenter (Fortnox, Bokio, Visma) erbjuder komplett lönehantering: lönespecifikationer, arbetsgivaravgifter, skattetabeller (FOS-förfrågan mot Skatteverket), AGI-rapportering, semesterhantering. Detta är en separat modul som de flesta SME-kunder förväntar sig.
Behövs: Skattetabellhantering, lönespec-generering (PDF), arbetsgivaravgiftsberäkning, AGI-rapportering, semesterskuld, förmånsberäkning (bil, etc).
1.4 Årsredovisning (K2/K3)
Aktiebolag måste lämna årsredovisning till Bolagsverket. Fortnox och Björn Lundén genererar detta. Din plattform har årsbokslut men saknar årsredovisningsgenerering med förvaltningsberättelse, noter, och formell K2/K3-struktur.
Behövs: Generering av förvaltningsberättelse, resultaträkning (K2-format), balansräkning (K2-format), noter, digital inlämning till Bolagsverket (XBRL).
1.5 Kontantmetod-stöd
Många enskilda firmor bokför med kontantmetoden (bokslutsmetoden). Ditt system verkar byggt kring faktureringsmetoden. Båda måste stödjas, med automatisk övergång till fakturametod vid bokslut för kontantmetoden.
1.6 Anläggningsregister
Inventarier, maskiner, fastigheter — med avskrivningsplaner (linjär/degressiv), restvärden, och automatisk avskrivningsbokföring. Saknas helt. Krävs för AB med tillgångar.
1.7 Offert/Order-flöde
Fortnox och Visma har offert → order → faktura-kedja. Inte nödvändigt för MVP men förväntat i ett komplett system.
2. FINNS MEN OTILLRÄCKLIGT — Behöver utökas
2.1 Bokföringsmallar / Konteringshjälp
Bokio's stora USP är smart konteringshjälp: användaren väljer "IT-tjänst 25% moms" och systemet konterar automatiskt. Du har AI-kategorisering, men saknar troligen ett bibliotek av färdiga bokföringsmallar för vanliga affärshändelser som en nybörjare kan välja mellan.
Behövs: 50-100 vanliga transaktionsmallar (kontorsmateriell, IT-tjänst, bensin, representation, etc) med korrekt moms och kontering.
2.2 Bankavstämning
Du har PSD2-transaktionssynk, men behöver explicit bankavstämning: matcha banktransaktioner mot bokförda poster, markera avstämda, visa differenser. Alla konkurrenter har detta.
2.3 Momsdeklaration
Du nämner "10 rutor" men verifierar att den genererar korrekt SKV 4820-underlag? Behöver också stödja: EU-handel (omvänd skattskyldighet), import/export-moms, olika momssatser (25/12/6/0%), tröskelbelopp (120 000 SEK från 2025).
2.4 SIE-export
Du har SIE4-export. Verifiera att SIE-import också fungerar korrekt (ingående balanser, verifikationer, kontoplan) — detta är kritiskt för att kunder ska kunna byta till ditt system från Fortnox/Bokio.
2.5 Rapporter
Du har saldobalans, resultat, balans, moms. Saknar troligen:
Huvudbok (alla transaktioner per konto)
Grundbok (verifikationslista i datumordning)
Kundreskontra-rapport
Leverantörsreskontra-rapport
Periodrapporter (jämförelse mellan perioder)
Kassaflödesanalys
3. HYGIEN-FUNKTIONER — Förväntas av alla
3.1 Autentisering
BankID-inloggning förväntas av svenska användare. Inte nödvändigt dag 1, men e-post + lösenord + 2FA via TOTP är minimum.
3.2 Mobilapp / Responsivt
Alla konkurrenter har mobilapp eller fullt responsivt gränssnitt. Kvittofotografering från mobil är en hygienfaktor.
3.3 Periodlåsning
Bokföringslagen kräver att bokföring är "varaktig" — du behöver kunna låsa perioder så att poster inte kan ändras i efterhand utan att det syns. Du har WORM-arkiv, verifiera att periodlåsning är implementerad.
3.4 Fleranvändarstöd
Roller: ägare, redovisningskonsult (extern), anställd. Behörigheter per modul. Alla konkurrenter har detta. Redovisningskonsult-access är affärskritiskt — byråer är den viktigaste distributionskanalen.
3.5 Verifikationskedja
Varje verifikation behöver: löpnummer utan luckor, datum, belopp, motkonto, beskrivning, bifogat underlag. Du har detta delvis via WORM + voucher numbering, men verifiera fullständigt BFL-compliance.
-232
View File
@@ -1,232 +0,0 @@
Architecture Cleanup & SupabaseClient Injection
Context
External feedback identified the codebase as overengineered in some areas (unused sector extensions, dead event types) and
underengineered in one critical area (no abstraction boundary between core lib/ and the Supabase platform). The goal is to
slim down dead weight and refactor lib/ functions to accept SupabaseClient as a parameter instead of self-instantiating,
matching a pattern already used by bank-reconciliation.ts, ingest.ts, and other files.
Two PRs:
- PR1: Cleanup (delete sector/export extensions, prune 6 dead events)
- PR2: SupabaseClient injection refactor (reports first, then engine + core services)
---
PR1: Cleanup
1a. Delete sector & export extension directories
Delete these 6 directories entirely:
extensions/restaurant/
extensions/construction/
extensions/hotel/
extensions/tech/
extensions/ecommerce/
extensions/export/
Delete their workspace components:
components/extensions/restaurant/
components/extensions/construction/
components/extensions/hotel/
components/extensions/tech/
components/extensions/ecommerce/
components/extensions/export/
1b. Update sector registry
lib/extensions/types.ts:13 — reduce SectorSlug union:
// Before
export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce' | 'export'
// After
export type SectorSlug = 'general'
lib/extensions/sectors.ts — remove 6 sector shells from SECTOR_SHELLS array (lines 21-57), keeping only the general entry.
1c. Update sectors test
lib/extensions/__tests__/sectors.test.ts — the test uses buildDefinitionsFromManifests() which walks extensions/ at runtime,
so counts auto-adjust. But hardcoded assertions need updating:
- Line 48: expect(SECTORS.length).toBe(7) → .toBe(1)
- Line 51: expect(getAllExtensions().length).toBe(25) → update to match remaining general extensions count (count manifests in
extensions/general/)
- Lines 63-66: "at least one extension per sector" — still valid for 1 sector
- Lines 69-73: Change getSector('restaurant') test to getSector('general')
- Lines 81-87: Change getExtensionDefinition('restaurant', 'food-cost') to a general extension
- Lines 94-96: Change getExtensionsBySector('restaurant') to getExtensionsBySector('general')
1d. Remove 6 dead event types
lib/events/types.ts — remove these 6 union members from CoreEvent:
- invoice.paid (line 32)
- invoice.overdue (line 33)
- bank.statement_received (line 39)
- bank.payment_notification (line 40)
- customer.pseudonymized (line 46)
- audit.security_event (line 72)
Remove from the import on line 1-16:
- CAMT053Statement
- CAMT054Notification
- AuditSecurityEvent
types/index.ts — delete the 3 placeholder interfaces (lines 1669-1685):
- CAMT053Statement
- CAMT054Notification
- AuditSecurityEvent
1e. Verification
npm run build # Confirm no broken imports
npm test # All tests pass
npm run setup:extensions # Codegen still works (manifests removed)
---
PR2: SupabaseClient Injection Refactor
Phase 1: Report generators (10 files)
These files self-instantiate createClient(). Refactor each to accept supabase: SupabaseClient as first parameter.
┌────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────┐
│ File │ Functions to change │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/trial-balance.ts │ generateTrialBalance(supabase, userId, periodId), │
│ │ generateTrialBalanceManual(supabase, userId, periodId) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/vat-declaration.ts │ calculateVatDeclaration(supabase, userId, ...) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/sie-export.ts │ generateSIEExport(supabase, userId, options) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/general-ledger.ts │ generateGeneralLedger(supabase, userId, periodId, ...) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/journal-register.ts │ generateJournalRegister(supabase, userId, periodId) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/monthly-breakdown.ts │ generateMonthlyBreakdown(supabase, userId, periodId) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/supplier-ledger.ts │ generateSupplierLedger(supabase, userId, asOfDate) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/supplier-reconciliation.ts │ generateReconciliation(supabase, userId, periodId) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/ar-ledger.ts │ generateARLedger(supabase, userId, asOfDate) │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ lib/reports/ar-reconciliation.ts │ generateARReconciliation(supabase, userId, periodId) │
└────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────┘
Not changed (no direct createClient call):
- income-statement.ts — delegates to generateTrialBalance(), which gets the client. Pass supabase through:
generateIncomeStatement(supabase, userId, periodId).
- balance-sheet.ts — same pattern, delegates to generateTrialBalance().
Sub-reports (also need injection):
- lib/reports/ne-bilaga/ne-engine.ts — generateNEDeclaration(supabase, userId, periodId)
- lib/reports/sru-export/sru-engine.ts — aggregateBalancesBySRU(supabase, userId, periodId), getSRUCoverage(supabase, userId)
Mechanical change per file:
1. Remove import { createClient } from '@/lib/supabase/server'
2. Add import type { SupabaseClient } from '@supabase/supabase-js'
3. Add supabase: SupabaseClient as first parameter
4. Delete the const supabase = await createClient() line
Update callers — each report API route already creates a client for auth. Pass it through:
// Before (app/api/reports/trial-balance/route.ts)
const result = await generateTrialBalance(user.id, periodId)
// After
const result = await generateTrialBalance(supabase, user.id, periodId)
12 API routes to update:
- app/api/reports/trial-balance/route.ts
- app/api/reports/income-statement/route.ts
- app/api/reports/balance-sheet/route.ts
- app/api/reports/vat-declaration/route.ts
- app/api/reports/sie-export/route.ts
- app/api/reports/general-ledger/route.ts
- app/api/reports/journal-register/route.ts
- app/api/reports/monthly-breakdown/route.ts
- app/api/reports/supplier-ledger/route.ts
- app/api/reports/ar-ledger/route.ts
- app/api/reports/ne-bilaga/route.ts
- app/api/reports/sru-export/route.ts (+ coverage/route.ts)
Phase 2: Core services (7 files)
Same mechanical pattern. Each function gets supabase: SupabaseClient as first parameter.
┌──────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┐
│ File │ Functions │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/core/bookkeeping/period-service.ts │ lockPeriod, closePeriod, createNextPeriod, getPeriodStatus │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/core/bookkeeping/storno-service.ts │ correctEntry │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ │ validateYearEndReadiness, previewYearEndClosing, generateOpeningBalances │
│ lib/core/bookkeeping/year-end-service.ts │ (note: executeYearEndClosing calls others that self-instantiate, so it also │
│ │ needs the param and must pass it through) │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ │ uploadDocument, createNewVersion, linkToJournalEntry, verifyIntegrity (keep │
│ lib/core/documents/document-service.ts │ ensureDocumentsBucket using createServiceClient — it needs service role for │
│ │ bucket ops) │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/core/audit/audit-service.ts │ getAuditLog, getEntityHistory, getCorrectionChain │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/core/tax/tax-code-service.ts │ getTaxCodes, getTaxCodeByCode, calculateMomsFromTaxCodes, seedTaxCodes │
├──────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ lib/invoices/invoice-matching.ts │ findMatchingInvoices │
└──────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┘
Phase 3: Bookkeeping engine + mapping (3 files)
┌───────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────┐
│ File │ Functions │
├───────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ │ getNextVoucherNumber, findFiscalPeriod, createDraftEntry, commitEntry, │
│ lib/bookkeeping/engine.ts │ createJournalEntry, reverseEntry (validateBalance stays pure, resolveAccountIds │
│ │ already takes client) │
├───────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ lib/bookkeeping/mapping-engine.ts │ evaluateMappingRules, saveUserMappingRule │
├───────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ lib/import/sie-import.ts │ checkDuplicateImport, importVouchers, saveMappings, loadMappings, executeSIEImport │
└───────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────┘
Engine.ts cascade: Since createJournalEntry calls createDraftEntry and commitEntry, and commitEntry calls
getNextVoucherNumber, the client flows through all internal calls. This eliminates the current pattern where each sub-function
creates its own independent client.
Special cases
- lib/bookkeeping/handlers/supplier-invoice-handler.ts — event handler, invoked by event bus. The handler creates its own
client because it has no caller to receive one from. Leave as-is — this is the edge of the system where the event bus must
bootstrap a client.
- lib/extensions/toggle-check.ts — uses createServiceClient() (service role, no cookies). Leave as-is — extension toggle
checks bypass RLS intentionally.
- lib/core/documents/document-service.ts ensureDocumentsBucket — uses createServiceClient(). Leave as-is for this one private
function.
Phase 2/3 caller updates
The API routes calling these functions need the same one-line change as the report routes: pass the existing supabase variable
as the first argument.
Verification
npm run build # No broken imports
npm test # All tests pass — mock pattern changes from module mock to direct client mock
npx vitest run lib/reports # Report tests specifically
npx vitest run lib/bookkeeping # Engine tests specifically
Tests currently mock @/lib/supabase/server. After the refactor, tests can pass a mock client directly via createMockSupabase()
from tests/helpers.ts — which is already the pattern used by the tests. The vi.mock('@/lib/supabase/server') calls in test
files can be removed for functions that now accept the client as a parameter.
---
What we're NOT changing
- engine.ts single write path
- DB enforcement triggers
- Extension toggle system
- Codegen from extensions.config.json
- Event bus design (lib/events/bus.ts)
- Report system structure
- Reconciliation 4-pass algorithm
- AI extension architecture
-472
View File
@@ -1,472 +0,0 @@
Here is Claude's plan:
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Plan A: Extension Opt-In Foundation (~3 days)
Context
erp-base is being open-sourced. The core (Swedish bookkeeping) should work standalone with only Supabase. Extensions (AI,
PSD2, push, email, sector tools) ship in the same repo as an "extensions library" that developers explicitly opt into via a
JSON config file. Everything is open-source — monetization happens on the hosted platform.
This plan establishes the architectural boundary. After this plan:
- Core builds and runs with an empty config (zero extensions)
- Extensions are enabled by adding their ID to extensions.config.json
- CI prevents anyone from accidentally importing extension code in core
- Every extension has a manifest describing its dependencies
Plan B (separate, future) handles the full cleanup: email extraction, API route migration, template-embeddings relocation,
SRU/NE-bilaga merge, cross-extension cleanup, and documentation.
Current Coupling (What We're Fixing)
Only 3 files in lib/ directly import from extensions/:
┌───────────────────────────────────────┬───────────────────────────────────────────────────────────┐
│ File │ Coupling │
├───────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ lib/extensions/loader.ts │ 12 hardcoded static imports from @/extensions/ │
├───────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ lib/extensions/workspace-registry.tsx │ 24 hardcoded next/dynamic() imports │
├───────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ lib/extensions/sectors.ts │ Hardcoded extension metadata (data only, no code imports) │
└───────────────────────────────────────┴───────────────────────────────────────────────────────────┘
Everything else is already clean — event bus, registry, context factory, types, core API routes, core components.
Implementation
Step 1: Create the config file and JSON schema
New file: extensions.config.json
{
"$schema": "./extensions.schema.json",
"extensions": []
}
New file: extensions.schema.json
JSON Schema listing all valid extension IDs with descriptions, giving IDE autocompletion. Generated from manifest files (or
hand-maintained initially).
Step 2: Add manifest.json to every extension
Each extension directory gets a manifest describing its metadata, imports, and requirements.
Format:
{
"id": "receipt-ocr",
"sector": "general",
"exportName": "receiptOcrExtension",
"workspace": "@/components/extensions/general/ReceiptOcrWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk"],
"definition": {
"name": "Receipt OCR",
"category": "import",
"icon": "Camera",
"dataPattern": "both",
"description": "Scan and process receipts with AI",
"longDescription": "..."
}
}
Extensions to manifest (24 total):
┌────────────────────────┬────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│ Extension │ Path │ Required Env Vars │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ receipt-ocr │ extensions/general/receipt-ocr/ │ ANTHROPIC_API_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ ai-categorization │ extensions/general/ai-categorization/ │ ANTHROPIC_API_KEY, OPENAI_API_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ ai-chat │ extensions/general/ai-chat/ │ ANTHROPIC_API_KEY, OPENAI_API_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ invoice-inbox │ extensions/general/invoice-inbox/ │ ANTHROPIC_API_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ enable-banking │ extensions/general/enable-banking/ │ ENABLE_BANKING_APP_ID, ENABLE_BANKING_PRIVATE_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ push-notifications │ extensions/general/push-notifications/ │ VAPID_PRIVATE_KEY, NEXT_PUBLIC_VAPID_PUBLIC_KEY │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ calendar │ extensions/general/calendar/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ eu-sales-list │ extensions/export/eu-sales-list/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ vat-monitor │ extensions/export/vat-monitor/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ intrastat │ extensions/export/intrastat/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ currency-receivables │ extensions/export/currency-receivables/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ food-cost │ extensions/restaurant/food-cost/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ earnings-per-liter │ extensions/restaurant/earnings-per-liter/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ pos-import │ extensions/restaurant/pos-import/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ tip-tracking │ extensions/restaurant/tip-tracking/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ rot-calculator │ extensions/construction/rot-calculator/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ project-cost │ extensions/construction/project-cost/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ revpar │ extensions/hotel/revpar/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ occupancy │ extensions/hotel/occupancy/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ billable-hours │ extensions/tech/billable-hours/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ project-billing │ extensions/tech/project-billing/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ shopify-import │ extensions/ecommerce/shopify-import/ │ (none) │
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ multichannel-revenue │ extensions/ecommerce/multichannel-revenue/ │ (none) │
└────────────────────────┴────────────────────────────────────────────┴───────────────────────────────────────────────────┘
Step 3: Build the generator script
New file: scripts/generate-extension-registry.ts
The generator:
1. Reads extensions.config.json to get enabled extension IDs
2. For each ID, finds and reads extensions/**/manifest.json matching that ID
3. Generates 3 files under lib/extensions/_generated/:
lib/extensions/_generated/extension-list.ts — When config has ["receipt-ocr", "ai-categorization"]:
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import type { Extension } from '../types'
import { receiptOcrExtension } from '@/extensions/general/receipt-ocr'
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
]
lib/extensions/_generated/workspace-map.tsx — Dynamic import map:
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import dynamic from 'next/dynamic'
import type { ComponentType } from 'react'
import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
'general/receipt-ocr': dynamic(() => import('@/components/extensions/general/ReceiptOcrWorkspace')),
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
}
lib/extensions/_generated/sector-definitions.ts — Extension metadata:
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import type { ExtensionDefinition } from '../types'
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
general: [
{ slug: 'receipt-ocr', name: 'Receipt OCR', ... },
{ slug: 'ai-categorization', name: 'AI Categorization', ... },
],
}
When config is empty ("extensions": []):
export const FIRST_PARTY_EXTENSIONS: Extension[] = []
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {}
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {}
Generator features:
- npm run setup:extensions — Generate + validate env vars (warn if missing)
- npm run setup:extensions -- --list — Print all available extensions with descriptions
- Outputs: "Enabled: receipt-ocr, ai-categorization. Warning: OPENAI_API_KEY not set (required by ai-categorization)"
Step 4: Modify loader, workspace-registry, and sectors to use generated files
lib/extensions/loader.ts — Replace hardcoded imports:
import { extensionRegistry } from './registry'
import { FIRST_PARTY_EXTENSIONS } from './_generated/extension-list'
let loaded = false
export function loadExtensions(): void {
if (loaded) return
loaded = true
for (const extension of FIRST_PARTY_EXTENSIONS) {
extensionRegistry.register(extension)
}
}
lib/extensions/workspace-registry.tsx — Replace hardcoded map:
import type { ComponentType } from 'react'
import { WORKSPACES } from './_generated/workspace-map'
export interface WorkspaceComponentProps {
userId: string
}
export function getWorkspaceComponent(
sector: string,
slug: string
): ComponentType<WorkspaceComponentProps> | null {
return WORKSPACES[`${sector}/${slug}`] ?? null
}
lib/extensions/sectors.ts — Replace hardcoded extension definitions:
The sector shells (general, restaurant, construction, etc.) stay hardcoded since they are structural. The extension
definitions per sector come from the generated file. Merge them at runtime.
Step 5: Commit empty defaults for generated files
These are committed so core compiles out of the box without running the generator:
- lib/extensions/_generated/extension-list.ts → Empty FIRST_PARTY_EXTENSIONS
- lib/extensions/_generated/workspace-map.tsx → Empty WORKSPACES
- lib/extensions/_generated/sector-definitions.ts → Empty EXTENSION_DEFINITIONS
Add to .gitignore a comment explaining these files are auto-generated but the defaults are committed.
Step 6: npm scripts
Add to package.json:
{
"setup:extensions": "tsx scripts/generate-extension-registry.ts",
"prebuild": "npm run setup:extensions",
"predev": "npm run setup:extensions"
}
This ensures the generated files are always up-to-date before build/dev.
Step 7: CI regression guard
New file: .github/workflows/core-build.yml
name: Core Build (no extensions)
on: [pull_request]
jobs:
core-only:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- name: Reset extensions config
run: echo '{"extensions":[]}' > extensions.config.json
- run: npm run setup:extensions
- run: npm run build
- run: npm test
- name: Check no core imports from extensions
run: |
VIOLATIONS=$(grep -r "from '@/extensions/" lib/ app/api/ components/ --include="*.ts" --include="*.tsx" \
| grep -v "app/api/extensions/" \
| grep -v "components/extensions/" \
| grep -v "lib/extensions/_generated/" \
| grep -v "lib/extensions/loader.ts" || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: Core code imports from @/extensions/:"
echo "$VIOLATIONS"
exit 1
fi
Note: After this plan, lib/extensions/loader.ts will no longer import from @/extensions/ (it imports from _generated/), so the
exclusion for loader.ts is just a safety measure during transition.
Verification Criteria
1. Empty config builds: echo '{"extensions":[]}' > extensions.config.json && npm run setup:extensions && npm run build →
succeeds
2. Single extension works: Add "calendar" to config → npm run setup:extensions && npm run build → calendar extension available
at /e/general/calendar
3. Full config works: Add all 12 currently-loaded extension IDs → npm run setup:extensions && npm run build → identical to
current behavior
4. CI catches violations: If someone adds import { x } from '@/extensions/foo' in lib/utils.ts, the CI job fails
5. All tests pass: npm test with both empty and full config
6. Generator warns about missing env vars: Enable ai-categorization without OPENAI_API_KEY → warning printed, build still
succeeds
Critical Files
┌─────────────────────────────────────────────────┬────────────────────────────────────────────────────────┐
│ File │ Action │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ extensions.config.json │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ extensions.schema.json │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ scripts/generate-extension-registry.ts │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/_generated/extension-list.ts │ Create (empty default) │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/_generated/workspace-map.tsx │ Create (empty default) │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/_generated/sector-definitions.ts │ Create (empty default) │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ .github/workflows/core-build.yml │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/loader.ts │ Modify: import from generated file │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/workspace-registry.tsx │ Modify: import from generated file │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ lib/extensions/sectors.ts │ Modify: import definitions from generated file │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ extensions/*/manifest.json (24 files) │ Create │
├─────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ package.json │ Modify: add setup:extensions, prebuild, predev scripts │
└─────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘
---
Plan B: Full Decoupling (Reference — Execute Later)
This plan is for after Plan A is complete. Context preserved here so nothing is lost.
Prerequisites
Plan A complete: config system works, manifests exist, CI guard in place.
Phase 3: Create the email extension (~1.5 days)
Extract email from core into an extension. Core only needs Supabase.
1. Create extensions/general/email/:
- index.ts — Extension definition, subscribes to invoice.created, invoice.overdue
- lib/email-service.ts — Resend integration (moved from lib/email/)
- lib/templates/ — Invoice, reminder, notification templates
- manifest.json — requires RESEND_API_KEY, RESEND_FROM_EMAIL
2. Create NoopEmailAdapter in core (lib/email/service.ts):
- Core defines EmailService interface + no-op default
- Email extension registers real implementation via services pattern on the registry
- Invoice flows check if email service is available; if not, skip sending (no crash)
3. Event-driven: Core emits invoice.created, invoice.overdue. Email extension subscribes, sends emails. If not loaded, events
fire but nothing sends.
4. Move cron: /api/invoices/reminders/cron becomes a thin proxy or moves into email extension's apiRoutes.
Current email files to move:
- lib/email/ → Review what's here, extract Resend-specific code into extension
- Invoice template generation stays in core (PDF generation), email delivery moves to extension
Phase 4: Move extension API routes into extensions (~5-7 days)
Move handler logic from app/api/extensions/<name>/ route files into each extension's apiRoutes array. The catch-all at
app/api/extensions/ext/[...path]/route.ts dispatches.
Frontend URL change: /api/extensions/<name>/<action> → /api/extensions/ext/<name>/<action>
Routes to convert (move handler into extension apiRoutes):
- ai-categorization/suggestions/, ai-categorization/settings/
- ai-chat/, ai-chat/stream/, ai-chat/sessions/
- invoice-inbox/inbox/, invoice-inbox/inbox/[id]/*, invoice-inbox/settings/
- receipt-ocr/upload/, receipt-ocr/[id]/*, receipt-ocr/settings/, receipt-ocr/queue/
- push-notifications/subscribe/, push-notifications/settings/
- All export/* routes
Thin proxy routes (external callbacks / cron — keep but make extension-agnostic):
- invoice-inbox/webhook/ — Resend webhook: delegates to extensionRegistry.get('invoice-inbox')?.apiRoutes
- enable-banking/callback/ — PSD2 OAuth: delegates to registry
- enable-banking/sync/cron/ — Vercel cron: delegates to registry
- push-notifications/cron/ — Vercel cron: delegates to registry
Keep as-is (core framework):
- toggles/, [sector]/[slug]/data/, [sector]/[slug]/settings/, ext/[...path]/
Delete all other dedicated routes after moving logic.
Phase 5: Move template-embeddings.ts out of core (~1 day)
lib/bookkeeping/template-embeddings.ts imports @langchain/openai.
1. Move to extensions/general/ai-categorization/lib/template-embeddings.ts
2. Add services field to Extension interface (lib/extensions/types.ts):
services?: Record<string, (...args: unknown[]) => Promise<unknown>>
3. ai-categorization registers: services: { findSimilarTemplates: ... }
4. app/api/transactions/suggest-categories/route.ts uses registry:
const aiExt = extensionRegistry.get('ai-categorization')
const templateSuggestions = aiExt?.services?.findSimilarTemplates
? await aiExt.services.findSimilarTemplates(transaction, entityType)
: []
// Rule-based suggestions from mapping-engine.ts always available
Phase 6: Merge SRU export and NE-bilaga into core (~1 day)
Tax compliance features, no external deps, always available:
- Move extensions/sru-export/ → lib/reports/sru-export/
- Move extensions/ne-bilaga/ → lib/reports/ne-bilaga/
- Move workspace components → components/reports/
- Move API routes → app/api/reports/sru-export/, app/api/reports/ne-bilaga/
- Remove from extension system (no manifest, not in loader)
Phase 7: Cross-extension dependency cleanup (~0.5 days)
invoice-inbox imports processReceiptFromDocument from receipt-ocr.
Use services pattern:
- receipt-ocr registers: services: { processReceiptFromDocument }
- invoice-inbox calls: extensionRegistry.get('receipt-ocr')?.services?.processReceiptFromDocument(...)
- Gracefully skips if receipt-ocr not enabled
Phase 8: Documentation (~1 day)
1. README.md: Self-hosting guide — core setup (just Supabase), extension opt-in
2. EXTENSIONS.md: Extension interface, events, context API, how to build extensions
3. scripts/create-extension.ts: Scaffolds new extension (manifest, index.ts, types, workspace)
Plan B Effort Summary
┌──────────────────────────────────┬─────────────┐
│ Phase │ Effort │
├──────────────────────────────────┼─────────────┤
│ Phase 3: Email extension │ 1.5 days │
├──────────────────────────────────┼─────────────┤
│ Phase 4: API route migration │ 5-7 days │
├──────────────────────────────────┼─────────────┤
│ Phase 5: Template-embeddings │ 1 day │
├──────────────────────────────────┼─────────────┤
│ Phase 6: SRU/NE-bilaga merge │ 1 day │
├──────────────────────────────────┼─────────────┤
│ Phase 7: Cross-extension cleanup │ 0.5 days │
├──────────────────────────────────┼─────────────┤
│ Phase 8: Documentation │ 1 day │
├──────────────────────────────────┼─────────────┤
│ Total │ ~10-12 days │
└──────────────────────────────────┴─────────────┘
Environment Variable Reference
Core (required):
- NEXT_PUBLIC_SUPABASE_URL — Supabase project URL
- NEXT_PUBLIC_SUPABASE_ANON_KEY — Supabase anonymous key
- SUPABASE_SERVICE_ROLE_KEY — Supabase service role key
- NEXT_PUBLIC_APP_URL — App base URL
- CRON_SECRET — Auth for core cron jobs (deadlines, tax deadlines, document verification)
Extension env vars:
┌────────────────────────┬───────────────────────────────────────────────────┬────────────────────────┐
│ Extension │ Required │ Optional │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ email │ RESEND_API_KEY, RESEND_FROM_EMAIL │ RESEND_WEBHOOK_SECRET │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ receipt-ocr │ ANTHROPIC_API_KEY │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ ai-categorization │ ANTHROPIC_API_KEY, OPENAI_API_KEY │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ ai-chat │ ANTHROPIC_API_KEY, OPENAI_API_KEY │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ invoice-inbox │ ANTHROPIC_API_KEY │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ enable-banking │ ENABLE_BANKING_APP_ID, ENABLE_BANKING_PRIVATE_KEY │ ENABLE_BANKING_SANDBOX │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ push-notifications │ VAPID_PRIVATE_KEY, NEXT_PUBLIC_VAPID_PUBLIC_KEY │ VAPID_SUBJECT │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ calendar │ (none) │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ All export extensions │ (none) │ │
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
│ All sector extensions │ (none) │ │
└────────────────────────┴───────────────────────────────────────────────────┴────────────────────────┘
Key Architectural Decisions
1. Config is JSON — No TypeScript in config. Generator reads it without a compiler. CI validates trivially.
2. Core never imports from @/extensions/ — Enforced by CI. The only bridge is the generated _generated/ files.
3. Extensions communicate via events and services — Event bus for async reactions, services record for synchronous
capabilities.
4. Extension tables stay in shared DB — Empty when extension isn't enabled. RLS prevents access issues. No migration splitting
needed.
5. CRON_SECRET is core — 3 core cron jobs need it (deadlines, tax deadlines, document verification).
6. Email is an extension — Core works without email. Invoices can be created/downloaded but not sent.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB