Files
accounted/dev_docs/02-ARCHITECTURE.md
T
2026-02-13 14:00:46 +01:00

11 KiB

Technical Architecture Document

Stack Overview

Frontend:     Next.js (App Router) + React + TypeScript
Styling:      Tailwind CSS + shadcn/ui
Backend:      Next.js API Routes + Supabase Edge Functions
Database:     Supabase (PostgreSQL)
Auth:         Supabase Auth (magic link + Google OAuth)
Storage:      Supabase Storage (invoice PDFs)
Banking:      Enable Banking (PSD2 AISP)
PDF Gen:      @react-pdf/renderer or Puppeteer
Hosting:      Vercel
Currency:     Riksbanken API for exchange rates

Project Structure

/
├── app/
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   └── callback/page.tsx
│   ├── (onboarding)/
│   │   └── onboarding/
│   │       ├── page.tsx             # Wizard container
│   │       ├── step-1/page.tsx      # Entity type
│   │       ├── step-2/page.tsx      # Company details
│   │       ├── step-3/page.tsx      # Tax registration
│   │       ├── step-4/page.tsx      # Preliminary tax
│   │       ├── step-5/page.tsx      # Bank details for invoices
│   │       └── step-6/page.tsx      # Connect bank (Enable Banking)
│   ├── (dashboard)/
│   │   ├── layout.tsx
│   │   ├── page.tsx                 # Main dashboard
│   │   ├── transactions/
│   │   │   └── page.tsx             # Swipe categorization
│   │   ├── invoices/
│   │   │   ├── page.tsx             # Invoice list
│   │   │   ├── new/page.tsx         # Create invoice
│   │   │   ├── [id]/page.tsx        # Invoice detail
│   │   │   └── [id]/credit/page.tsx # Create credit note
│   │   ├── customers/
│   │   │   └── page.tsx             # Customer management
│   │   └── settings/
│   │       └── page.tsx             # Company profile
│   └── api/
│       ├── banking/
│       │   ├── connect/route.ts     # Initiate Enable Banking connection
│       │   ├── callback/route.ts    # PSD2 callback
│       │   └── sync/route.ts        # Fetch transactions
│       ├── invoices/
│       │   ├── route.ts             # CRUD
│       │   ├── [id]/pdf/route.ts    # Generate PDF
│       │   └── [id]/credit/route.ts # Create credit note
│       ├── currency/
│       │   └── rates/route.ts       # Riksbanken exchange rates
│       └── webhooks/
│           └── banking/route.ts     # Transaction webhooks
├── components/
│   ├── ui/                          # shadcn components
│   ├── onboarding/
│   │   ├── WizardProgress.tsx
│   │   ├── EntityTypeSelector.tsx
│   │   └── BankConnectButton.tsx
│   ├── dashboard/
│   │   ├── BalanceCard.tsx
│   │   ├── TaxBreakdown.tsx
│   │   ├── FskattWarning.tsx
│   │   └── RevenueChart.tsx
│   ├── transactions/
│   │   ├── SwipeCard.tsx
│   │   └── TransactionList.tsx
│   └── invoices/
│       ├── InvoiceForm.tsx
│       ├── InvoicePreview.tsx
│       ├── CurrencySelector.tsx
│       └── CustomerSelect.tsx
├── lib/
│   ├── supabase/
│   │   ├── client.ts                # Browser client
│   │   ├── server.ts                # Server client
│   │   └── admin.ts                 # Service role client
│   ├── banking/
│   │   └── enable-banking.ts        # Enable Banking API wrapper
│   ├── currency/
│   │   └── riksbanken.ts            # Exchange rate fetching
│   ├── tax/
│   │   ├── calculator.ts            # Swedish tax calculations
│   │   ├── expense-warnings.ts      # Non-deductible expense alerts
│   │   ├── schablonavdrag.ts        # Standard deductions
│   │   └── fskatt-warning.ts        # Preliminary tax comparison
│   ├── invoice/
│   │   ├── vat-rules.ts             # VAT logic
│   │   └── pdf-generator.ts
│   └── utils/
│       └── currency.ts
├── types/
│   └── index.ts                     # TypeScript interfaces
└── supabase/
    ├── migrations/                  # Database migrations
    └── seed.sql                     # Test data

Authentication Flow

  1. User lands on /login
  2. Enters email → Supabase sends magic link
  3. User clicks link → redirected to /callback
  4. Callback exchanges code for session
  5. Onboarding check: Query company_settings for user
    • If no settings exist → redirect to /onboarding
    • If settings exist but onboarding_complete = false → redirect to /onboarding
    • If settings complete → redirect to / (dashboard)

Alternative: Google OAuth for faster onboarding (still requires completing wizard).

Onboarding Flow

1. User completes auth
2. Middleware checks company_settings.onboarding_complete
3. If incomplete → force redirect to /onboarding
4. Wizard steps (cannot skip):
   a. Entity type selection
   b. Company details (name, org.nr, address)
   c. Tax registration (F-skatt, VAT, momsperiod)
   d. Preliminary tax amount (optional but encouraged)
   e. Bank details for invoices
   f. Connect bank via Enable Banking (required)
5. On completion → set onboarding_complete = true
6. Redirect to dashboard

Middleware Logic:

// middleware.ts - simplified
const publicRoutes = ['/login', '/auth/callback']
const onboardingRoutes = ['/onboarding']

if (!user) {
  // Not logged in → login page
  return redirect('/login')
}

const settings = await getCompanySettings(user.id)

if (!settings?.onboarding_complete) {
  // Onboarding incomplete → force wizard
  if (!onboardingRoutes.some(r => path.startsWith(r))) {
    return redirect('/onboarding')
  }
}

// Onboarding complete → allow access

Bank Connection Flow

PSD2 Provider: Enable Banking

Using Enable Banking as the AISP provider.

Why Enable Banking:

  • Good coverage of Swedish banks (Nordea, SEB, Handelsbanken, Swedbank, etc.)
  • Reasonable pricing for startups
  • TSP model: they hold the license, you process data
  • REST API with good documentation

Consent Lifecycle:

  • PSD2 RTS allows 90-180 day consent periods (bank dependent)
  • System must track consent expiry per bank connection
  • Proactive notification: "Din bankkoppling till Nordea löper ut om 7 dagar"

Multiple Accounts:

  • Users can connect multiple bank accounts (business + savings)
  • Each connection tracked separately with own consent expiry
  • Dashboard aggregates across all connected accounts
1. User clicks "Koppla bank"
2. Frontend calls POST /api/banking/connect
3. Backend creates Enable Banking session, returns redirect URL
4. User selects bank, authenticates with BankID (SCA requirement)
5. Enable Banking redirects to /api/banking/callback
6. Backend stores connection metadata (consent expiry, account info)
7. Initial transaction fetch (90 days history typical)
8. User redirected to /transactions
9. Webhook receives new transactions in real-time
10. At consent expiry - 7 days: prompt re-consent flow

Non-Custodial Design (Critical)

The app must NEVER hold user funds. This avoids:

  • Lagen om redovisningsmedel (1944:181)
  • Klientmedelskonto requirements
  • Full betalningsinstitut licensing

For future "tax savings" feature:

  • Use PISP to instruct user's bank to transfer to user's OWN savings account
  • App orchestrates, never touches money
  • User maintains full control and ownership

Currency Conversion

Riksbanken Integration

For multi-currency invoicing, convert foreign amounts to SEK for bokföring.

// lib/currency/riksbanken.ts

interface ExchangeRate {
  currency: string
  rate: number
  date: string
}

const RIKSBANKEN_API = 'https://api.riksbank.se/swea/v1/CrossRates'

export async function getExchangeRate(
  currency: 'EUR' | 'USD' | 'GBP',
  date: string // YYYY-MM-DD
): Promise<number> {
  // Riksbanken publishes daily rates
  // Use booking date for invoice conversion
  const response = await fetch(
    `${RIKSBANKEN_API}/${currency}/SEK/${date}`
  )
  const data = await response.json()
  return data.value
}

// Invoice creation: store both original and SEK amounts
interface InvoiceAmounts {
  originalCurrency: string
  originalTotal: number
  exchangeRate: number
  exchangeRateDate: string
  sekTotal: number  // This is what goes in bokföring
}

Bokföringslagen requirement: Foreign currency transactions must be converted to SEK. The exchange rate on the invoice date (fakturadatum) is typically used.

Tax Calculation Logic

Swedish sole proprietor (enskild näringsidkare) tax components:

interface TaxBreakdown {
  grossIncome: number;          // Total invoiced + received
  deductibleExpenses: number;   // Categorized business expenses
  netIncome: number;            // grossIncome - deductibleExpenses
  
  // Egenavgifter (self-employment contributions) ~28.97%
  egenavgifter: number;
  
  // Preliminary income tax (kommunalskatt ~32% avg)
  incomeTax: number;
  
  // VAT collected (if registered)
  vatCollected: number;
  vatDeductible: number;
  vatPayable: number;
  
  // Total locked
  totalLocked: number;
  
  // Available to spend
  disponibelt: number;
}

Important: These are estimates. Display clear disclaimer.

VAT Rules Engine

type VatScenario = 
  | 'swedish_customer'           // 25% VAT
  | 'eu_business_valid_vat'      // 0% + reverse charge text
  | 'eu_consumer'                // 25% VAT (or destination country rate for digital services)
  | 'non_eu'                     // 0% VAT, export

interface VatDecision {
  rate: number;
  reverseCharge: boolean;
  invoiceText: string | null;    // Legal text to include
}

Validation: EU VAT numbers validated via VIES API.

Security Considerations

  • Row Level Security (RLS) on all tables
  • User can only access own data
  • Banking tokens encrypted at rest (Supabase Vault or env vars)
  • Invoice PDFs in private bucket, signed URLs for access
  • No PII in logs
  • Rate limiting on API routes

Environment Variables

# Supabase
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=

# Banking (Tink example)
TINK_CLIENT_ID=
TINK_CLIENT_SECRET=
TINK_REDIRECT_URI=

# App
NEXT_PUBLIC_APP_URL=https://app.yourdomain.se

# PDF
PDF_STORAGE_BUCKET=invoices

Deployment

Vercel configuration:

// vercel.json
{
  "crons": [
    {
      "path": "/api/banking/sync",
      "schedule": "0 6 * * *"
    }
  ]
}

Database migrations run via Supabase CLI or dashboard.