Backend

Backend — Target Architecture

Bilko API — Express Backend

BookStack — Provjeri PRVO

Prije traženja bilo čega — provjeri BookStack (http://localhost:6875). Centralna baza znanja za tools, skills, hooks, agents, rules, projekte, klijente, dokumentaciju. Ako odgovor postoji tamo — NE TRAŽI dalje.

Status: NOT BUILT YET

This directory is EMPTY. The CLAUDE.md describes the target architecture. When building, follow docs/backend/API-REFERENCE.md as the implementation contract.

Target Tech Stack

Route Structure

All routes under /api/v1/{resource}:

Middleware Stack (Order Matters)

  1. helmet — Security headers
  2. cors — CORS with whitelist
  3. express.json() — Body parser
  4. rate-limit — 100 req/15min per IP
  5. auth-guard — JWT validation (protected routes)
  6. zod-validation — Request validation
  7. route-handler — Business logic
  8. error-handler — Centralized error responses

Error Response Format

{
  "error": "Error message",
  "code": "ERROR_CODE",
  "details": {} // optional
}

HTTP Status Codes:

Database Access

Authentication

Validation Rules

All requests validated with Zod schemas:

Double-Entry Rules (CRITICAL)

Every financial transaction MUST:

  1. Have both debit and credit accounts
  2. Equal amounts (debit = credit)
  3. Reference the source (invoice ID, expense ID)
  4. Lock exchange rate at transaction date
  5. Create audit log entry (LoggedAction)

Development Rules

  1. NEVER hold money — This is an accounting tool, not a payment processor
  2. Immutable transactions — Once locked, NEVER modify
  3. Audit everything — All mutations logged to LoggedAction
  4. Multi-currency always — Even single-currency orgs need exchange rate support
  5. Test with real accounting scenarios — Invoice → payment → reconciliation

API Reference

Full endpoint documentation in docs/backend/API-REFERENCE.md (to be created). This file will be the contract for implementation.

Database — Schema & Models

Bilko Database — Prisma + PostgreSQL

BookStack — Provjeri PRVO

Prije traženja bilo čega — provjeri BookStack (http://localhost:6875). Centralna baza znanja za tools, skills, hooks, agents, rules, projekte, klijente, dokumentaciju. Ako odgovor postoji tamo — NE TRAŽI dalje.

Schema Location

prisma/schema.prisma — 15 models, fully defined

Database Models (15)

Core:

Chart of Accounts:

Contacts:

Invoicing:

Expenses:

Transactions:

Banking:

Multi-Currency:

Audit:

Key Design Decisions

1. NUMERIC(19,4) for Money

NEVER use float or JavaScript number for currency.

2. Double-Entry Bookkeeping

Every financial event creates a Transaction with:

3. Multi-Currency with Rate Locking

4. Immutable Audit Trail

LoggedAction table:

5. Transaction Locking

6. Organization-Scoped Multi-Tenancy

7. UUID Primary Keys

Migration Rules

  1. Never edit existing migrations — Always create new ones
  2. Test migrations on copy — Never run on production first
  3. Backward compatible — Additive changes only
  4. Data migrations separate — Use Prisma seed or custom scripts
  5. Rollback plan — Document how to undo breaking changes

Naming Conventions

Indexes

Defined for:

Enums

Development Commands

# Generate Prisma Client
npx prisma generate

# Create migration
npx prisma migrate dev --name migration_name

# Apply migrations (production)
npx prisma migrate deploy

# Reset database (dev only)
npx prisma migrate reset

# Open Prisma Studio
npx prisma studio

Critical Rules

  1. NUMERIC for money — NEVER float
  2. Double-entry enforced — Every transaction has debit + credit
  3. Exchange rates locked — At transaction date, NEVER recalculate
  4. Audit is append-only — NEVER delete LoggedAction records
  5. UUID everywhere — NEVER expose auto-increment IDs

API Reference

Bilko API Reference

Status: SPECIFICATION (backend not implemented) Base URL: http://localhost:4000/api/v1 (development) Production URL: https://api.bilko.io/api/v1 Last updated: 2026-02-20


Purpose

This document is the implementation contract for Bilko's backend. All ~35 endpoints are specified with:

CRITICAL: Backend is NOT BUILT. This is the spec that apps/api/ MUST implement.


Table of Contents

  1. Authentication (5 endpoints)
  2. Organization (2 endpoints)
  3. Users (4 endpoints)
  4. Contacts (5 endpoints)
  5. Invoices (8 endpoints)
  6. Expenses (6 endpoints)
  7. Bank Accounts (4 endpoints)
  8. Reports (7 endpoints)
  9. Chart of Accounts (3 endpoints)
  10. Transactions (2 endpoints)
  11. Settings (2 endpoints)
  12. Currencies (2 endpoints)

Total: 50 endpoints


API Architecture Overview

graph LR
    subgraph CLIENT [Client]
        FE[Next.js Frontend\nbilko.io:3000]
    end

    subgraph API [Express API — api.bilko.io:4000]
        AUTH_R[/auth/*\nPublic]
        ORG_R[/organization\nAll roles]
        USR_R[/users/*\nowner, admin]
        CON_R[/contacts/*\nAll roles]
        INV_R[/invoices/*\nAll roles]
        EXP_R[/expenses/*\nAll roles]
        BANK_R[/bank-accounts/*\nAll roles]
        RPT_R[/reports/*\nAll roles]
        ACC_R[/accounts/*\nAll roles]
        TXN_R[/transactions/*\nAll roles]
        SET_R[/settings/*\nowner, admin]
        CUR_R[/currencies\nAll roles]
    end

    FE -->|Bearer token\nin Authorization header| API
    FE -->|refreshToken\nhttpOnly cookie| AUTH_R

    style AUTH_R fill:#e2e8f0,color:#000
    style FE fill:#00E5A0,color:#000

Global Response Patterns

Pagination

All list endpoints support pagination:

interface PaginatedResponse<T> {
  data: T[]
  meta: {
    total: number        // Total records
    page: number         // Current page (1-indexed)
    perPage: number      // Records per page
    totalPages: number   // Total pages
  }
}

Query parameters:

Error Responses

interface ApiError {
  error: string                         // Human-readable error message
  code: string                          // Machine-readable error code
  details?: Record<string, string[]>    // Field-level validation errors
}

HTTP Status Codes:


1. Authentication

POST /api/v1/auth/register

Create new organization and owner user.

Auth: None Rate limit: 5 req/min

Request:

interface RegisterRequest {
  // Organization
  organizationName: string
  country: 'RS' | 'BA' | 'HR'        // Serbia, BiH, Croatia
  baseCurrency: 'EUR' | 'RSD' | 'BAM' | 'HRK'
  language: 'sr' | 'bs' | 'hr'
  registrationNumber?: string         // Company tax ID
  vatNumber?: string

  // User
  email: string                       // Must be unique
  password: string                    // Min 8 chars, 1 upper, 1 lower, 1 number
  fullName: string
}

Response (201):

interface RegisterResponse {
  user: {
    id: string
    email: string
    fullName: string
    role: 'owner'
  }
  organization: {
    id: string
    name: string
    country: string
    baseCurrency: string
  }
  tokens: {
    accessToken: string      // JWT, expires in 15 min
    refreshToken: string     // Expires in 7 days
  }
}

Errors:


POST /api/v1/auth/login

Authenticate with email + password.

Auth: None Rate limit: 5 req/min

Request:

interface LoginRequest {
  email: string
  password: string
  rememberMe?: boolean     // If true, refreshToken expires in 30 days
}

Response (200):

interface LoginResponse {
  user: {
    id: string
    email: string
    fullName: string
    role: 'owner' | 'admin' | 'accountant' | 'viewer'
    organizationId: string
    organizationName: string
  }
  tokens: {
    accessToken: string      // JWT, expires in 15 min
    refreshToken: string     // httpOnly cookie
  }
}

Errors:


POST /api/v1/auth/refresh

Get new access token using refresh token.

Auth: Refresh token (httpOnly cookie) Rate limit: 100 req/min

Request: None (uses cookie)

Response (200):

interface RefreshResponse {
  accessToken: string
}

Errors:


POST /api/v1/auth/logout

Invalidate refresh token.

Auth: Bearer token Rate limit: 100 req/min

Request: None

Response (204): No content


GET /api/v1/auth/me

Get current user info.

Auth: Bearer token Rate limit: 100 req/min

Response (200):

interface CurrentUser {
  id: string
  email: string
  fullName: string
  role: 'owner' | 'admin' | 'accountant' | 'viewer'
  twoFactorEnabled: boolean
  lastLoginAt: string | null
  organization: {
    id: string
    name: string
    country: string
    baseCurrency: string
    language: string
  }
}

2. Organization

GET /api/v1/organization

Get organization details.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200):

interface Organization {
  id: string
  name: string
  registrationNumber: string | null
  vatNumber: string | null
  baseCurrency: string
  country: string
  language: string
  fiscalYearStart: string    // ISO date, e.g., "2026-01-01"
  createdAt: string
  updatedAt: string
}

PUT /api/v1/organization

Update organization details.

Auth: Bearer token Roles: owner, admin Rate limit: 10 req/min

Request:

interface UpdateOrganizationRequest {
  name?: string
  registrationNumber?: string
  vatNumber?: string
  baseCurrency?: 'EUR' | 'RSD' | 'BAM' | 'HRK'
  language?: 'sr' | 'bs' | 'hr'
  fiscalYearStart?: string    // ISO date
}

Response (200): Organization object (same as GET)

Errors:


3. Users

GET /api/v1/users

List all users in organization.

Auth: Bearer token Roles: owner, admin Rate limit: 100 req/min

Query:

Response (200):

interface UserListResponse {
  data: Array<{
    id: string
    email: string
    fullName: string
    role: 'owner' | 'admin' | 'accountant' | 'viewer'
    twoFactorEnabled: boolean
    lastLoginAt: string | null
    createdAt: string
  }>
}

POST /api/v1/users/invite

Invite new user to organization.

Auth: Bearer token Roles: owner, admin Rate limit: 10 req/min

Request:

interface InviteUserRequest {
  email: string
  fullName: string
  role: 'admin' | 'accountant' | 'viewer'    // Cannot create 'owner'
}

Response (201):

interface InviteUserResponse {
  user: {
    id: string
    email: string
    fullName: string
    role: string
  }
  inviteLink: string    // One-time setup link, expires in 7 days
}

Errors:


PUT /api/v1/users/:id/role

Change user role.

Auth: Bearer token Roles: owner Rate limit: 10 req/min

Request:

interface ChangeRoleRequest {
  role: 'admin' | 'accountant' | 'viewer'
}

Response (200): User object

Errors:


DELETE /api/v1/users/:id

Remove user from organization.

Auth: Bearer token Roles: owner Rate limit: 10 req/min

Response (204): No content

Errors:


4. Contacts

GET /api/v1/contacts

List contacts (customers/vendors).

Auth: Bearer token Roles: All Rate limit: 100 req/min

Query:

Response (200):

type ContactListResponse = PaginatedResponse<Contact>

interface Contact {
  id: string
  type: 'customer' | 'vendor' | 'both'
  name: string
  email: string | null
  phone: string | null
  registrationNumber: string | null
  vatNumber: string | null
  addressLine1: string | null
  addressLine2: string | null
  city: string | null
  postalCode: string | null
  country: string | null
  currencyCode: string
  paymentTerms: number           // Days
  isActive: boolean
  createdAt: string
  updatedAt: string
}

POST /api/v1/contacts

Create new contact.

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 50 req/min

Request:

interface CreateContactRequest {
  type: 'customer' | 'vendor' | 'both'
  name: string
  email?: string
  phone?: string
  registrationNumber?: string
  vatNumber?: string
  addressLine1?: string
  addressLine2?: string
  city?: string
  postalCode?: string
  country?: string               // ISO 3166-1 alpha-2 (e.g., 'RS')
  currencyCode?: string          // ISO 4217 (default: org baseCurrency)
  paymentTerms?: number          // Default: 30 days
  notes?: string
}

Response (201): Contact object

Errors:


GET /api/v1/contacts/:id

Get contact details.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200): Contact object + notes field


PUT /api/v1/contacts/:id

Update contact.

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 50 req/min

Request: Same as CreateContactRequest (all fields optional)

Response (200): Contact object


DELETE /api/v1/contacts/:id

Soft-delete contact (sets isActive = false).

Auth: Bearer token Roles: owner, admin Rate limit: 10 req/min

Response (204): No content

Errors:


5. Invoices

GET /api/v1/invoices

List invoices.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Query:

Response (200):

type InvoiceListResponse = PaginatedResponse<InvoiceSummary>

interface InvoiceSummary {
  id: string
  invoiceNumber: string
  customerId: string
  customerName: string
  invoiceDate: string
  dueDate: string
  currencyCode: string
  totalAmount: string         // Decimal as string, e.g., "125000.0000"
  status: 'draft' | 'sent' | 'viewed' | 'paid' | 'overdue' | 'cancelled'
  createdAt: string
}

Invoice Creation — Full Sequence

sequenceDiagram
    participant FE as Frontend
    participant MW as Middleware Stack\n(auth, roleGuard, validate)
    participant H as Invoice Handler
    participant DB as PostgreSQL\n(Prisma)
    participant EX as Exchange Rate\nService

    FE->>MW: POST /api/v1/invoices\nAuthorization: Bearer {accessToken}
    MW->>MW: authGuard: verify JWT\nAttach req.user {id, role, orgId}
    MW->>MW: roleGuard: check owner/admin/accountant
    MW->>MW: validate(createInvoiceSchema)\ncustomerId UUID, dates, items[]

    MW->>H: Validated request
    H->>DB: Find Contact by customerId\nwhere orgId matches
    DB-->>H: Contact { email, currencyCode }

    H->>EX: getExchangeRate(invoiceCurrency, orgBaseCurrency, invoiceDate)
    EX-->>H: rate (locked at invoiceDate — NEVER changes)

    H->>H: Calculate:\nlineTotal = qty × unitPrice\ntaxAmount = SUM(lineTotal × taxRate/100)\ntotalAmount = subtotal + taxAmount - discount\nbaseAmount = totalAmount × exchangeRate

    H->>DB: BEGIN TRANSACTION\nGenerate invoiceNumber INV-YYYY-NNN\nINSERT Invoice { status: draft }\nINSERT InvoiceItems[]

    DB-->>H: Invoice created
    H->>DB: INSERT LoggedAction\n{ action: INSERT, tableName: Invoice }
    DB-->>H: Logged

    H->>FE: 201 Created\n{ id, invoiceNumber, status: draft, items, totals }

POST /api/v1/invoices

Create invoice.

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 50 req/min

Request:

interface CreateInvoiceRequest {
  customerId: string
  invoiceDate: string           // ISO date
  dueDate: string               // ISO date
  currencyCode?: string         // Default: customer's currency
  items: Array<{
    description: string
    quantity: number            // Decimal as number
    unitPrice: number           // Decimal as number
    taxRate: number             // Percentage, e.g., 20 for 20%
    accountId?: string          // Revenue account
  }>
  notes?: string
  terms?: string
}

Response (201):

interface Invoice {
  id: string
  invoiceNumber: string         // Auto-generated
  customerId: string
  customerName: string
  invoiceDate: string
  dueDate: string
  currencyCode: string
  exchangeRate: string          // Decimal as string
  subtotal: string
  taxAmount: string
  discountAmount: string
  totalAmount: string
  baseAmount: string            // Converted to org baseCurrency
  status: 'draft'
  items: Array<{
    id: string
    lineNumber: number
    description: string
    quantity: string
    unitPrice: string
    taxRate: string
    lineTotal: string
    accountId: string | null
  }>
  notes: string | null
  terms: string | null
  pdfUrl: string | null
  createdBy: string
  createdAt: string
  updatedAt: string
}

Errors:


GET /api/v1/invoices/:id

Get invoice details.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200): Invoice object (same as POST response)


PUT /api/v1/invoices/:id

Update invoice (draft only).

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 50 req/min

Request: Same as CreateInvoiceRequest

Response (200): Invoice object

Errors:


Invoice Status Transition — Send Flow

sequenceDiagram
    participant FE as Frontend
    participant API as Bilko API
    participant PDF as Puppeteer\nPDF Service
    participant R2 as Cloudflare R2
    participant SG as SendGrid
    participant DB as PostgreSQL

    FE->>API: PATCH /invoices/:id/status\n{ action: "send" }
    API->>DB: Fetch Invoice with items, customer, org
    DB-->>API: Invoice (must be status=draft)
    API->>PDF: generateInvoicePDF(invoice data)
    PDF-->>API: PDF Buffer

    API->>R2: PUT invoices/{orgId}/INV-2026-001.pdf
    R2-->>API: pdfUrl stored

    API->>DB: BEGIN TRANSACTION
    API->>DB: INSERT Transaction {\n  DR: Accounts Receivable (1200)\n  CR: Revenue (4000)\n  amount: invoice.totalAmount\n  referenceType: 'invoice'\n}
    API->>DB: UPDATE Invoice SET\n  status='sent', sentAt=now()\n  pdfUrl=url

    API->>SG: sendEmail({\n  to: customer.email,\n  subject: "Invoice INV-2026-001 from Org",\n  html: template,\n  attachment: pdf\n})
    SG-->>API: { messageId }

    DB-->>API: COMMIT

    API->>FE: 200 { status: sent, sentAt, pdfUrl }

    Note over API: If SendGrid fails:\nKeep invoice as draft\nAdd note: "Email delivery failed"\nAlert admin via Slack

PATCH /api/v1/invoices/:id/status

Change invoice status.

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 50 req/min

Request:

interface ChangeInvoiceStatusRequest {
  action: 'send' | 'mark-paid' | 'cancel'
  paidAt?: string               // Required if action = 'mark-paid'
}

Response (200): Invoice object

Business logic:

Errors:


GET /api/v1/invoices/:id/pdf

Get invoice PDF.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200):

Errors:


POST /api/v1/invoices/:id/send

Send invoice email to customer.

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 10 req/min

Request:

interface SendInvoiceRequest {
  to?: string                   // Override customer email
  cc?: string[]
  subject?: string              // Override default subject
  message?: string              // Custom message
}

Response (200):

interface SendInvoiceResponse {
  sentAt: string
  sentTo: string
  emailId: string               // SendGrid message ID
}

Errors:


6. Expenses

GET /api/v1/expenses

List expenses.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Query:

Response (200):

type ExpenseListResponse = PaginatedResponse<ExpenseSummary>

interface ExpenseSummary {
  id: string
  expenseNumber: string
  vendorId: string | null
  vendorName: string | null
  expenseDate: string
  category: string
  amount: string
  currencyCode: string
  status: 'pending' | 'approved' | 'paid' | 'rejected'
  receiptUrl: string | null
  createdAt: string
}

POST /api/v1/expenses

Create expense.

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 50 req/min

Request:

interface CreateExpenseRequest {
  vendorId?: string
  expenseDate: string
  category: string              // Free text or predefined categories
  amount: number
  currencyCode?: string         // Default: org baseCurrency
  taxAmount?: number
  paymentMethod?: string        // 'cash', 'card', 'bank_transfer', etc.
  accountId?: string            // Expense account
  description?: string
  receiptFile?: File            // Multipart form upload (max 10MB)
}

Response (201):

interface Expense {
  id: string
  expenseNumber: string         // Auto-generated
  vendorId: string | null
  vendorName: string | null
  expenseDate: string
  category: string
  currencyCode: string
  exchangeRate: string
  amount: string
  baseAmount: string
  taxAmount: string
  paymentMethod: string | null
  accountId: string | null
  description: string | null
  receiptUrl: string | null     // Cloudflare R2 URL
  status: 'pending'
  createdBy: string
  createdAt: string
  updatedAt: string
}

Errors:


GET /api/v1/expenses/:id

Get expense details.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200): Expense object


PUT /api/v1/expenses/:id

Update expense (pending only).

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 50 req/min

Request: Same as CreateExpenseRequest

Response (200): Expense object

Errors:


Expense Approval — Full Sequence

sequenceDiagram
    participant FE as Frontend\n(admin/owner)
    participant MW as Middleware Stack
    participant H as Expense Handler
    participant DB as PostgreSQL

    FE->>MW: PATCH /api/v1/expenses/:id/approve\nAuthorization: Bearer {accessToken}
    MW->>MW: authGuard: verify JWT
    MW->>MW: roleGuard: owner or admin ONLY\n(accountant CANNOT approve)

    MW->>H: Request passes
    H->>DB: Find Expense by id\nwhere organizationId = req.orgId
    DB-->>H: Expense { status: pending, amount, accountId }

    H->>H: Validate: status must be 'pending'\nIf not → 400 Bad Request

    H->>DB: BEGIN TRANSACTION

    H->>DB: Find ExpenseAccount\n(expense.accountId or default 5xxx)
    H->>DB: Find AccountsPayable account\n(2110 or configured account)

    H->>DB: INSERT Transaction {\n  debitAccountId: expenseAccountId,\n  creditAccountId: accountsPayableId,\n  amount: expense.amount,\n  referenceType: 'expense',\n  referenceId: expense.id\n}

    H->>DB: UPDATE Expense SET\n  status='approved',\n  approvedBy=req.user.id,\n  approvedAt=now()

    H->>DB: INSERT LoggedAction

    DB-->>H: COMMIT

    H->>FE: 200 OK\n{ id, status: approved, approvedBy, approvedAt }

PATCH /api/v1/expenses/:id/approve

Approve expense.

Auth: Bearer token Roles: owner, admin Rate limit: 50 req/min

Response (200): Expense object (status = approved)

Business logic:

Errors:


DELETE /api/v1/expenses/:id

Delete expense (pending only).

Auth: Bearer token Roles: owner, admin Rate limit: 10 req/min

Response (204): No content

Errors:


7. Bank Accounts

GET /api/v1/bank-accounts

List bank accounts.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200):

interface BankAccountListResponse {
  data: Array<{
    id: string
    accountId: string           // GL account ID
    accountCode: string         // GL account code
    bankName: string
    accountNumber: string | null
    iban: string | null
    currencyCode: string
    currentBalance: string
    isActive: boolean
    createdAt: string
    updatedAt: string
  }>
}

POST /api/v1/bank-accounts

Create bank account.

Auth: Bearer token Roles: owner, admin Rate limit: 10 req/min

Request:

interface CreateBankAccountRequest {
  accountId: string             // Must be Asset account
  bankName: string
  accountNumber?: string
  iban?: string
  currencyCode: string
  currentBalance?: number       // Default: 0
}

Response (201): BankAccount object

Errors:


GET /api/v1/bank-accounts/:id/transactions

Get bank transactions.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Query:

Response (200):

type BankTransactionListResponse = PaginatedResponse<BankTransaction>

interface BankTransaction {
  id: string
  transactionDate: string
  amount: string                // Positive = credit, negative = debit
  description: string | null
  reference: string | null
  reconciled: boolean
  matchedTransactionId: string | null
  createdAt: string
}

POST /api/v1/bank-accounts/:id/import

Import bank statement (CSV).

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 10 req/min

Request:

CSV format:

Date,Description,Amount,Reference
2026-02-19,"Payment from customer",3500.00,INV-2026-002
2026-02-18,"AWS Invoice",-850.00,

Response (200):

interface ImportStatementResponse {
  imported: number
  duplicates: number
  errors: Array<{
    line: number
    error: string
  }>
}

Errors:


Bank Reconciliation — Full Sequence

sequenceDiagram
    participant FE as Frontend
    participant API as Bilko API
    participant DB as PostgreSQL

    Note over FE,DB: Step 1 — Import Bank Statement
    FE->>API: POST /bank-accounts/:id/import\n[multipart: CSV file, max 5MB]
    API->>API: Parse CSV\nDate, Description, Amount, Reference
    API->>DB: INSERT BankTransaction[] records\n{ bankAccountId, transactionDate, amount, reference }
    DB-->>API: Imported count
    API->>FE: 200 { imported: 45, duplicates: 2, errors: [] }

    Note over FE,DB: Step 2 — Auto-Match Suggestions
    FE->>API: GET /bank-accounts/:id/transactions?reconciled=false
    API->>DB: Fetch unreconciled BankTransactions
    DB-->>API: BankTransaction[]
    API->>DB: Fetch unreconciled GL Transactions\nfor same date range
    DB-->>API: Transaction[]
    API->>API: calculateMatchScore() for each pair\nAmount match +50\nDate match +30/+20/+10\nReference match +20
    API->>FE: 200 { bankTransactions, suggestions[{ bankTxId, glTxId, score }] }

    Note over FE,DB: Step 3 — Confirm Reconciliation
    FE->>API: POST /bank-accounts/:id/reconcile\n{ bankTransactionId, transactionId }
    API->>DB: Find both records, verify same org
    API->>DB: UPDATE BankTransaction SET\n  reconciled=true\n  matchedTransactionId=glTxId
    API->>DB: UPDATE Transaction SET\n  reconciled=true\n  reconciledAt=now()
    DB-->>API: Both updated
    API->>FE: 200 { bankTransaction, transaction, confidence: 95 }

POST /api/v1/bank-accounts/:id/reconcile

Reconcile bank transactions with GL transactions.

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 10 req/min

Request:

interface ReconcileRequest {
  bankTransactionId: string
  transactionId: string         // GL transaction ID
}

Response (200):

interface ReconcileResponse {
  bankTransaction: BankTransaction
  transaction: Transaction
  confidence: number            // 0-100 match score
}

Errors:


8. Reports

GET /api/v1/reports/dashboard

Get dashboard metrics.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200):

interface DashboardMetrics {
  cashBalance: string           // Total across all bank accounts (in baseCurrency)
  revenueMTD: string            // Month-to-date revenue
  unpaidInvoices: string        // Total unpaid invoices
  expensesMTD: string           // Month-to-date expenses
  profitMTD: string             // revenueMTD - expensesMTD
  cashFlowChange: number        // Percentage change from last month

  // Chart data
  monthlyPL: Array<{
    month: string
    revenue: string
    expenses: string
    profit: string
  }>

  receivablesAging: {
    current: string             // 0-30 days
    days30: string              // 31-60 days
    days60: string              // 61-90 days
    days90plus: string          // 90+ days
  }

  expensesByCategory: Array<{
    category: string
    amount: string
    currencyCode: string
  }>
}

GET /api/v1/reports/profit-loss

Profit & Loss statement.

Auth: Bearer token Roles: All Rate limit: 50 req/min

Query:

Response (200):

interface ProfitLossReport {
  period: {
    from: string
    to: string
  }
  baseCurrency: string

  revenue: {
    total: string
    accounts: Array<{
      accountCode: string
      accountName: string
      amount: string
    }>
  }

  expenses: {
    total: string
    accounts: Array<{
      accountCode: string
      accountName: string
      amount: string
    }>
  }

  netProfit: string             // revenue.total - expenses.total
}

GET /api/v1/reports/balance-sheet

Balance Sheet.

Auth: Bearer token Roles: All Rate limit: 50 req/min

Query:

Response (200):

interface BalanceSheetReport {
  asOfDate: string
  baseCurrency: string

  assets: {
    total: string
    current: {
      total: string
      accounts: Array<AccountBalance>
    }
    fixed: {
      total: string
      accounts: Array<AccountBalance>
    }
  }

  liabilities: {
    total: string
    current: {
      total: string
      accounts: Array<AccountBalance>
    }
    longTerm: {
      total: string
      accounts: Array<AccountBalance>
    }
  }

  equity: {
    total: string
    accounts: Array<AccountBalance>
  }
}

interface AccountBalance {
  accountCode: string
  accountName: string
  balance: string
}

GET /api/v1/reports/cash-flow

Cash Flow statement.

Auth: Bearer token Roles: All Rate limit: 50 req/min

Query:

Response (200):

interface CashFlowReport {
  period: {
    from: string
    to: string
  }
  baseCurrency: string

  operating: {
    total: string
    items: Array<{
      description: string
      amount: string
    }>
  }

  investing: {
    total: string
    items: Array<{
      description: string
      amount: string
    }>
  }

  financing: {
    total: string
    items: Array<{
      description: string
      amount: string
    }>
  }

  netCashFlow: string
  openingBalance: string
  closingBalance: string
}

GET /api/v1/reports/vat

VAT/PDV report.

Auth: Bearer token Roles: All Rate limit: 50 req/min

Query:

Response (200):

interface VATReport {
  period: {
    from: string
    to: string
  }
  country: string               // Organization country

  outputVAT: {
    total: string
    invoices: Array<{
      invoiceNumber: string
      customerName: string
      invoiceDate: string
      baseAmount: string
      vatAmount: string
      vatRate: string
    }>
  }

  inputVAT: {
    total: string
    expenses: Array<{
      expenseNumber: string
      vendorName: string
      expenseDate: string
      baseAmount: string
      vatAmount: string
      vatRate: string
    }>
  }

  netVAT: string                // outputVAT.total - inputVAT.total

  reconciliationStatus: {
    allInvoicesPaid: boolean
    allExpensesApproved: boolean
    unmatchedTransactions: number
  }
}

GET /api/v1/reports/trial-balance

Trial Balance.

Auth: Bearer token Roles: All Rate limit: 50 req/min

Query:

Response (200):

interface TrialBalanceReport {
  asOfDate: string
  baseCurrency: string

  accounts: Array<{
    accountCode: string
    accountName: string
    accountType: string
    debitTotal: string
    creditTotal: string
    balance: string
  }>

  totals: {
    debit: string
    credit: string
  }

  balanced: boolean             // totals.debit === totals.credit
}

9. Chart of Accounts

GET /api/v1/accounts

List chart of accounts.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Query:

Response (200):

interface AccountListResponse {
  data: Array<{
    id: string
    code: string                // e.g., "1000", "4000"
    name: string                // e.g., "Bank Account EUR", "Revenue"
    accountTypeId: number
    accountTypeName: string     // Asset, Liability, Equity, Revenue, Expense
    normalBalance: 'debit' | 'credit'
    currencyCode: string
    parentAccountId: string | null
    parentAccountCode: string | null
    isActive: boolean
    currentBalance: string      // Calculated from transactions
    createdAt: string
    updatedAt: string
  }>
}

POST /api/v1/accounts

Create account.

Auth: Bearer token Roles: owner, admin Rate limit: 10 req/min

Request:

interface CreateAccountRequest {
  code: string                  // Must be unique within organization
  name: string
  accountTypeId: number         // 1-5 (Asset, Liability, Equity, Revenue, Expense)
  currencyCode?: string         // Default: org baseCurrency
  parentAccountId?: string      // For sub-accounts
}

Response (201): Account object

Errors:


PUT /api/v1/accounts/:id

Update account.

Auth: Bearer token Roles: owner, admin Rate limit: 10 req/min

Request:

interface UpdateAccountRequest {
  name?: string
  isActive?: boolean
}

Response (200): Account object

Errors:


10. Transactions

GET /api/v1/transactions

List general ledger transactions.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Query:

Response (200):

type TransactionListResponse = PaginatedResponse<Transaction>

interface Transaction {
  id: string
  transactionDate: string
  description: string
  debitAccountId: string
  debitAccountCode: string
  debitAccountName: string
  creditAccountId: string
  creditAccountCode: string
  creditAccountName: string
  amount: string
  currencyCode: string
  exchangeRate: string
  baseAmount: string
  referenceType: string | null
  referenceId: string | null
  locked: boolean
  reconciled: boolean
  createdBy: string
  createdAt: string
}

POST /api/v1/transactions

Create manual journal entry.

Auth: Bearer token Roles: owner, admin, accountant Rate limit: 20 req/min

Request:

interface CreateTransactionRequest {
  transactionDate: string
  description: string
  debitAccountId: string
  creditAccountId: string
  amount: number
  currencyCode?: string         // Default: org baseCurrency
  notes?: string
}

Response (201): Transaction object

Errors:


11. Settings

GET /api/v1/settings/tax-rates

Get tax rate configuration.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200):

interface TaxRatesResponse {
  country: string
  defaultVATRate: number        // e.g., 20 for Serbia, 17 for BiH
  rates: Array<{
    name: string                // "Standard", "Reduced", "Zero"
    rate: number
    description: string
  }>
}

PUT /api/v1/settings/tax-rates

Update tax rate configuration.

Auth: Bearer token Roles: owner, admin Rate limit: 10 req/min

Request:

interface UpdateTaxRatesRequest {
  defaultVATRate: number
  rates: Array<{
    name: string
    rate: number
    description: string
  }>
}

Response (200): TaxRatesResponse


12. Currencies

GET /api/v1/currencies

List supported currencies.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Response (200):

interface CurrencyListResponse {
  data: Array<{
    code: string                // ISO 4217
    name: string
    symbol: string | null
    decimalPlaces: number
    isActive: boolean
  }>
}

GET /api/v1/exchange-rates

Get exchange rates.

Auth: Bearer token Roles: All Rate limit: 100 req/min

Query:

Response (200):

interface ExchangeRateResponse {
  baseCurrency: string
  targetCurrency: string
  rate: string                  // Decimal as string
  effectiveDate: string
  source: string                // "ECB", "fixer.io", "manual"
  lastUpdated: string
}

Errors:


Endpoint Summary Map

graph TD
    subgraph AUTH [Authentication — No auth required]
        A1[POST /auth/register]
        A2[POST /auth/login]
        A3[POST /auth/refresh]
        A4[POST /auth/logout]
        A5[GET /auth/me]
    end

    subgraph ORG [Organization]
        O1[GET /organization]
        O2[PUT /organization]
    end

    subgraph USR [Users]
        U1[GET /users]
        U2[POST /users/invite]
        U3[PUT /users/:id/role]
        U4[DELETE /users/:id]
    end

    subgraph CON [Contacts]
        C1[GET /contacts]
        C2[POST /contacts]
        C3[GET /contacts/:id]
        C4[PUT /contacts/:id]
        C5[DELETE /contacts/:id]
    end

    subgraph INV [Invoices]
        I1[GET /invoices]
        I2[POST /invoices]
        I3[GET /invoices/:id]
        I4[PUT /invoices/:id]
        I5[PATCH /invoices/:id/status]
        I6[GET /invoices/:id/pdf]
        I7[POST /invoices/:id/send]
    end

    subgraph EXP [Expenses]
        E1[GET /expenses]
        E2[POST /expenses]
        E3[GET /expenses/:id]
        E4[PUT /expenses/:id]
        E5[PATCH /expenses/:id/approve]
        E6[DELETE /expenses/:id]
    end

    subgraph BANK [Bank Accounts]
        B1[GET /bank-accounts]
        B2[POST /bank-accounts]
        B3[GET /bank-accounts/:id/transactions]
        B4[POST /bank-accounts/:id/import]
        B5[POST /bank-accounts/:id/reconcile]
    end

    subgraph RPT [Reports]
        R1[GET /reports/dashboard]
        R2[GET /reports/profit-loss]
        R3[GET /reports/balance-sheet]
        R4[GET /reports/cash-flow]
        R5[GET /reports/vat]
        R6[GET /reports/trial-balance]
    end

    subgraph MISC [Other]
        M1[GET /accounts]
        M2[POST /accounts]
        M3[PUT /accounts/:id]
        M4[GET /transactions]
        M5[POST /transactions]
        M6[GET /settings/tax-rates]
        M7[PUT /settings/tax-rates]
        M8[GET /currencies]
        M9[GET /exchange-rates]
    end

Implementation Notes

Request Validation

All requests validated with Zod schemas. Invalid requests return 422 with field-level errors.

Database Transactions

All write operations wrapped in database transactions. Rollback on error.

Audit Logging

All INSERT/UPDATE/DELETE captured in LoggedAction table via Prisma middleware.

Rate Limiting

File Uploads

CORS

Error Logging


Example Requests

Create Invoice

curl -X POST http://localhost:4000/api/v1/invoices \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "550e8400-e29b-41d4-a716-446655440000",
    "invoiceDate": "2026-02-20",
    "dueDate": "2026-03-20",
    "items": [
      {
        "description": "Web Development",
        "quantity": 40,
        "unitPrice": 100,
        "taxRate": 20
      }
    ]
  }'

Get Dashboard Metrics

curl http://localhost:4000/api/v1/reports/dashboard \
  -H "Authorization: Bearer $TOKEN"

End of API Reference

Database Schema

Bilko Database Schema

Status: IMPLEMENTED (Prisma schema exists) Location: /Users/makinja/ALAI/products/Bilko/packages/database/prisma/schema.prisma Database: PostgreSQL 14+ ORM: Prisma 5.x Last updated: 2026-02-20


Purpose

This document describes the complete database schema for Bilko. The schema is IMPLEMENTED in Prisma and ready for migration. This doc explains the relationships, constraints, and design decisions.


Entity Relationship Overview

Organization (1) ──┬── (N) User
                   ├── (N) Account
                   ├── (N) Contact
                   ├── (N) Invoice
                   ├── (N) Expense
                   ├── (N) Transaction
                   └── (N) BankAccount

Contact (1) ────┬── (N) Invoice
                └── (N) Expense

Invoice (1) ──── (N) InvoiceItem

Account (1) ───┬── (N) InvoiceItem
               ├── (N) Expense
               ├── (N) BankAccount
               ├── (N) Transaction (debit)
               ├── (N) Transaction (credit)
               └── (N) Account (parent-child hierarchy)

BankAccount (1) ── (N) BankTransaction

Currency (1) ───┬── (N) ExchangeRate (base)
                └── (N) ExchangeRate (target)

User (1) ───┬── (N) Invoice (creator)
            ├── (N) Expense (creator)
            ├── (N) Expense (approver)
            ├── (N) Transaction (creator)
            └── (N) LoggedAction

Full ER Diagram

erDiagram
    Organization {
        UUID id PK
        VARCHAR name
        VARCHAR registrationNumber
        VARCHAR vatNumber
        CHAR baseCurrency
        CHAR country
        CHAR language
        DATE fiscalYearStart
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    User {
        UUID id PK
        UUID organizationId FK
        VARCHAR email
        VARCHAR passwordHash
        VARCHAR fullName
        ENUM role
        BOOLEAN twoFactorEnabled
        VARCHAR twoFactorSecret
        TIMESTAMP lastLoginAt
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    AccountType {
        INT id PK
        VARCHAR name
        ENUM normalBalance
        TIMESTAMP createdAt
    }

    Account {
        UUID id PK
        UUID organizationId FK
        VARCHAR code
        VARCHAR name
        INT accountTypeId FK
        CHAR currencyCode
        UUID parentAccountId FK
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Contact {
        UUID id PK
        UUID organizationId FK
        ENUM type
        VARCHAR name
        VARCHAR email
        VARCHAR phone
        VARCHAR vatNumber
        VARCHAR addressLine1
        VARCHAR city
        CHAR country
        CHAR currencyCode
        INT paymentTerms
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Invoice {
        UUID id PK
        UUID organizationId FK
        UUID customerId FK
        VARCHAR invoiceNumber
        DATE invoiceDate
        DATE dueDate
        CHAR currencyCode
        DECIMAL exchangeRate
        DECIMAL subtotal
        DECIMAL taxAmount
        DECIMAL discountAmount
        DECIMAL totalAmount
        DECIMAL baseAmount
        ENUM status
        TIMESTAMP sentAt
        TIMESTAMP paidAt
        VARCHAR pdfUrl
        UUID createdBy FK
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    InvoiceItem {
        UUID id PK
        UUID invoiceId FK
        INT lineNumber
        VARCHAR description
        DECIMAL quantity
        DECIMAL unitPrice
        DECIMAL taxRate
        DECIMAL lineTotal
        UUID accountId FK
        TIMESTAMP createdAt
    }

    Expense {
        UUID id PK
        UUID organizationId FK
        UUID vendorId FK
        VARCHAR expenseNumber
        DATE expenseDate
        CHAR currencyCode
        DECIMAL exchangeRate
        DECIMAL amount
        DECIMAL baseAmount
        DECIMAL taxAmount
        VARCHAR category
        VARCHAR paymentMethod
        UUID accountId FK
        ENUM status
        UUID approvedBy FK
        TIMESTAMP approvedAt
        UUID createdBy FK
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    Transaction {
        UUID id PK
        UUID organizationId FK
        DATE transactionDate
        VARCHAR description
        UUID debitAccountId FK
        UUID creditAccountId FK
        DECIMAL amount
        CHAR currencyCode
        DECIMAL exchangeRate
        DECIMAL baseAmount
        VARCHAR referenceType
        UUID referenceId
        BOOLEAN locked
        BOOLEAN reconciled
        UUID createdBy FK
        TIMESTAMP createdAt
    }

    BankAccount {
        UUID id PK
        UUID organizationId FK
        UUID accountId FK
        VARCHAR bankName
        VARCHAR accountNumber
        VARCHAR iban
        CHAR currencyCode
        DECIMAL currentBalance
        BOOLEAN isActive
        TIMESTAMP createdAt
        TIMESTAMP updatedAt
    }

    BankTransaction {
        UUID id PK
        UUID bankAccountId FK
        DATE transactionDate
        DECIMAL amount
        VARCHAR description
        VARCHAR reference
        BOOLEAN reconciled
        UUID matchedTransactionId
        TIMESTAMP createdAt
    }

    Currency {
        CHAR code PK
        VARCHAR name
        VARCHAR symbol
        SMALLINT decimalPlaces
        BOOLEAN isActive
        TIMESTAMP createdAt
    }

    ExchangeRate {
        UUID id PK
        CHAR baseCurrency FK
        CHAR targetCurrency FK
        DECIMAL rate
        DATE effectiveDate
        VARCHAR source
        TIMESTAMP lastUpdated
    }

    LoggedAction {
        BIGINT eventId PK
        TEXT schemaName
        TEXT tableName
        UUID userId FK
        TIMESTAMP actionTimestamp
        ENUM action
        JSONB rowData
        JSONB changedFields
        INET clientIp
    }

    SchemaVersion {
        VARCHAR version PK
        TIMESTAMP appliedAt
        TEXT description
    }

    Organization ||--o{ User : "has"
    Organization ||--o{ Account : "owns"
    Organization ||--o{ Contact : "manages"
    Organization ||--o{ Invoice : "issues"
    Organization ||--o{ Expense : "tracks"
    Organization ||--o{ Transaction : "records"
    Organization ||--o{ BankAccount : "holds"

    User ||--o{ Invoice : "createdBy"
    User ||--o{ Expense : "createdBy"
    User ||--o{ Expense : "approvedBy"
    User ||--o{ Transaction : "createdBy"
    User ||--o{ LoggedAction : "performed"

    AccountType ||--o{ Account : "categorizes"
    Account ||--o{ Account : "parentOf"
    Account ||--o{ InvoiceItem : "revenueAccount"
    Account ||--o{ Expense : "expenseAccount"
    Account ||--o| BankAccount : "glAccount"
    Account ||--o{ Transaction : "debitAccount"
    Account ||--o{ Transaction : "creditAccount"

    Contact ||--o{ Invoice : "customer"
    Contact ||--o{ Expense : "vendor"

    Invoice ||--o{ InvoiceItem : "contains"

    BankAccount ||--o{ BankTransaction : "has"

    Currency ||--o{ ExchangeRate : "base"
    Currency ||--o{ ExchangeRate : "target"

Multi-Tenant Scoping

graph LR
    ORG[Organization\nMulti-tenant Root]

    ORG --> U[Users\nowner/admin/accountant/viewer]
    ORG --> COA[Chart of Accounts\nHierarchical GL]
    ORG --> C[Contacts\nCustomers & Vendors]
    ORG --> INV[Invoices\nOutgoing]
    ORG --> EXP[Expenses\nIncoming]
    ORG --> TXN[Transactions\nDouble-Entry Ledger]
    ORG --> BANK[BankAccounts\nReconciliation]

    INV --> ITEM[InvoiceItems\nLine Items]
    BANK --> BTXN[BankTransactions\nStatement Import]
    TXN --> LOG[LoggedAction\nAudit Trail]

    style ORG fill:#00E5A0,color:#000
    style TXN fill:#ffd700,color:#000

Core Tables

1. Organization

Purpose: Multi-tenant root. Every business is one organization.

Column Type Constraints Description
id UUID PK, default uuid_generate_v4() Primary key
name VARCHAR(255) NOT NULL Business name
registrationNumber VARCHAR(50) NULL Company tax ID
vatNumber VARCHAR(50) NULL VAT registration number
baseCurrency CHAR(3) NOT NULL, default 'EUR' ISO 4217 currency code
country CHAR(2) NOT NULL ISO 3166-1 alpha-2 country code
language CHAR(2) NOT NULL, default 'sr' ISO 639-1 language code
fiscalYearStart DATE NOT NULL, default '2026-01-01' Fiscal year start date
createdAt TIMESTAMP NOT NULL, default now() Record creation timestamp
updatedAt TIMESTAMP NOT NULL, default now() Last update timestamp

Indexes:

Business rules:


2. User

Purpose: Users within an organization. Role-based access control.

Column Type Constraints Description
id UUID PK Primary key
organizationId UUID FK → Organization, NOT NULL, CASCADE Organization membership
email VARCHAR(255) UNIQUE, NOT NULL Login email
passwordHash VARCHAR(255) NOT NULL bcrypt hash (12 rounds)
fullName VARCHAR(255) NOT NULL Display name
role ENUM NOT NULL owner, admin, accountant, viewer
twoFactorEnabled BOOLEAN NOT NULL, default false 2FA status
twoFactorSecret VARCHAR(255) NULL TOTP secret
lastLoginAt TIMESTAMP NULL Last login timestamp
createdAt TIMESTAMP NOT NULL Account creation
updatedAt TIMESTAMP NOT NULL Last update

Indexes:

Enums:

enum UserRole {
  owner       // Full access, can delete org
  admin       // Can manage users and settings
  accountant  // Can create invoices/expenses
  viewer      // Read-only access
}

Business rules:


Chart of Accounts

Account Hierarchy & Normal Balances

graph TD
    COA[Chart of Accounts]

    COA --> A[1xxx Assets\nnormalBalance: debit]
    COA --> L[2xxx Liabilities\nnormalBalance: credit]
    COA --> E[3xxx Equity\nnormalBalance: credit]
    COA --> R[4xxx Revenue\nnormalBalance: credit]
    COA --> EX[5xxx Expenses\nnormalBalance: debit]

    A --> CA[1100 Current Assets]
    A --> FA[1500 Fixed Assets]
    CA --> Cash[1110 Cash]
    CA --> Bank[1120 Bank Accounts]
    CA --> AR[1200 Accounts Receivable]
    Bank --> B1[1121 Intesa RSD]
    Bank --> B2[1122 Raiffeisen EUR]

    L --> CL[2100 Current Liabilities]
    L --> LL[2500 Long-term]
    CL --> AP[2110 Accounts Payable]
    CL --> VAT[2120 VAT Payable]

    E --> SC[3100 Share Capital]
    E --> RE[3900 Retained Earnings]

    R --> SR[4100 Service Revenue]
    R --> PR[4200 Product Sales]

    EX --> OE[5100 Operating Expenses]
    OE --> SAL[5110 Salaries]
    OE --> RENT[5120 Rent]

    style A fill:#4ade80,color:#000
    style L fill:#f87171,color:#000
    style E fill:#60a5fa,color:#000
    style R fill:#a78bfa,color:#000
    style EX fill:#fb923c,color:#000

3. AccountType

Purpose: Defines account categories for double-entry bookkeeping.

Column Type Constraints Description
id INT PK, AUTOINCREMENT Primary key
name VARCHAR(50) UNIQUE, NOT NULL Asset, Liability, Equity, Revenue, Expense
normalBalance ENUM NOT NULL debit or credit
createdAt TIMESTAMP NOT NULL Record creation

Enums:

enum NormalBalance {
  debit   // Asset, Expense accounts increase with debits
  credit  // Liability, Equity, Revenue accounts increase with credits
}

Seed data:

1 | Asset      | debit
2 | Liability  | credit
3 | Equity     | credit
4 | Revenue    | credit
5 | Expense    | debit

4. Account

Purpose: Chart of Accounts. Hierarchical GL accounts.

Column Type Constraints Description
id UUID PK Primary key
organizationId UUID FK → Organization, NOT NULL Organization scope
code VARCHAR(10) NOT NULL Account code (e.g., "1000", "4000")
name VARCHAR(255) NOT NULL Account name (e.g., "Cash", "Revenue")
accountTypeId INT FK → AccountType, NOT NULL Account category
currencyCode CHAR(3) NOT NULL, default 'EUR' Account currency
parentAccountId UUID FK → Account, NULL Parent account (for sub-accounts)
isActive BOOLEAN NOT NULL, default true Active status
createdAt TIMESTAMP NOT NULL Record creation
updatedAt TIMESTAMP NOT NULL Last update

Indexes:

Business rules:


Contacts (Customers & Vendors)

5. Contact

Purpose: Customers (invoice recipients) and vendors (expense payees).

Column Type Constraints Description
id UUID PK Primary key
organizationId UUID FK → Organization, NOT NULL Organization scope
type ENUM NOT NULL customer, vendor, both
name VARCHAR(255) NOT NULL Contact name
email VARCHAR(255) NULL Email address
phone VARCHAR(50) NULL Phone number
registrationNumber VARCHAR(50) NULL Company registration number
vatNumber VARCHAR(50) NULL VAT number
addressLine1 VARCHAR(255) NULL Street address
addressLine2 VARCHAR(255) NULL Apt/suite
city VARCHAR(100) NULL City
postalCode VARCHAR(20) NULL Postal/ZIP code
country CHAR(2) NULL ISO 3166-1 alpha-2
currencyCode CHAR(3) NOT NULL, default 'EUR' Preferred currency
paymentTerms INT NOT NULL, default 30 Payment terms in days
notes TEXT NULL Free-text notes
isActive BOOLEAN NOT NULL, default true Active status
createdAt TIMESTAMP NOT NULL Record creation
updatedAt TIMESTAMP NOT NULL Last update

Indexes:

Enums:

enum ContactType {
  customer  // Invoice recipient
  vendor    // Expense payee
  both      // Can be both customer and vendor
}

Business rules:


Invoicing

6. Invoice

Purpose: Sales invoices (outgoing). Revenue recognition.

Column Type Constraints Description
id UUID PK Primary key
organizationId UUID FK → Organization, NOT NULL Organization scope
customerId UUID FK → Contact, NOT NULL Invoice recipient
invoiceNumber VARCHAR(50) NOT NULL Auto-generated (e.g., INV-2026-001)
invoiceDate DATE NOT NULL Invoice issue date
dueDate DATE NOT NULL Payment due date
currencyCode CHAR(3) NOT NULL Invoice currency
exchangeRate DECIMAL(12,6) NOT NULL, default 1.0 Exchange rate at invoiceDate
subtotal DECIMAL(19,4) NOT NULL Sum of line totals (before tax)
taxAmount DECIMAL(19,4) NOT NULL, default 0 Total VAT/tax
discountAmount DECIMAL(19,4) NOT NULL, default 0 Total discount
totalAmount DECIMAL(19,4) NOT NULL subtotal + taxAmount - discountAmount
baseAmount DECIMAL(19,4) NOT NULL Converted to org baseCurrency
status ENUM NOT NULL, default 'draft' Invoice status
sentAt TIMESTAMP NULL When invoice was sent
viewedAt TIMESTAMP NULL When customer viewed (email tracking)
paidAt TIMESTAMP NULL When marked as paid
notes TEXT NULL Internal notes
terms TEXT NULL Payment terms text
pdfUrl VARCHAR(500) NULL Cloudflare R2 URL
createdBy UUID FK → User, NULL Creator user
createdAt TIMESTAMP NOT NULL Record creation
updatedAt TIMESTAMP NOT NULL Last update

Indexes:

Enums:

enum InvoiceStatus {
  draft      // Being edited
  sent       // Sent to customer
  viewed     // Customer viewed email
  paid       // Payment received
  overdue    // Past dueDate and unpaid
  cancelled  // Voided
}

Invoice Status Transitions:

stateDiagram-v2
    [*] --> draft : Created
    draft --> sent : Send email\n(creates GL Transaction)
    sent --> viewed : Tracking pixel loaded
    viewed --> paid : Mark as paid\n(creates GL Transaction)
    sent --> paid : Mark as paid\n(creates GL Transaction)
    draft --> cancelled : Cancel
    sent --> cancelled : Cancel\n(reverses Transaction)
    viewed --> cancelled : Cancel\n(reverses Transaction)
    paid --> [*]
    cancelled --> [*]

    note right of draft
        Can edit line items,\ndates, amounts
    end note
    note right of sent
        Locked — no edits\nPDF generated & stored in R2
    end note
    note right of overdue
        Automated check: dueDate < today\nAND status != paid
    end note

Business rules:


7. InvoiceItem

Purpose: Line items on invoices.

Column Type Constraints Description
id UUID PK Primary key
invoiceId UUID FK → Invoice, CASCADE, NOT NULL Parent invoice
lineNumber INT NOT NULL Line order (1, 2, 3...)
description VARCHAR(500) NOT NULL Item description
quantity DECIMAL(10,2) NOT NULL Quantity sold
unitPrice DECIMAL(19,4) NOT NULL Price per unit
taxRate DECIMAL(5,2) NOT NULL, default 0 VAT rate (20 = 20%)
lineTotal DECIMAL(19,4) NOT NULL quantity * unitPrice
accountId UUID FK → Account, NULL Revenue account
createdAt TIMESTAMP NOT NULL Record creation

Indexes:

Business rules:


Expenses

8. Expense

Purpose: Purchase tracking (incoming). Expense recognition.

Column Type Constraints Description
id UUID PK Primary key
organizationId UUID FK → Organization, NOT NULL Organization scope
vendorId UUID FK → Contact, NULL Vendor (optional)
expenseNumber VARCHAR(50) NOT NULL Auto-generated (e.g., EXP-2026-001)
expenseDate DATE NOT NULL Expense date
currencyCode CHAR(3) NOT NULL Expense currency
exchangeRate DECIMAL(12,6) NOT NULL, default 1.0 Exchange rate at expenseDate
amount DECIMAL(19,4) NOT NULL Total expense amount
baseAmount DECIMAL(19,4) NOT NULL Converted to org baseCurrency
taxAmount DECIMAL(19,4) NOT NULL, default 0 VAT amount
category VARCHAR(100) NOT NULL Expense category
paymentMethod VARCHAR(50) NULL cash, card, bank_transfer, etc.
accountId UUID FK → Account, NULL Expense account
description TEXT NULL Expense description
receiptUrl VARCHAR(500) NULL Cloudflare R2 URL
status ENUM NOT NULL, default 'pending' Approval status
approvedBy UUID FK → User, NULL Approver user
approvedAt TIMESTAMP NULL Approval timestamp
paidAt TIMESTAMP NULL Payment timestamp
createdBy UUID FK → User, NULL Creator user
createdAt TIMESTAMP NOT NULL Record creation
updatedAt TIMESTAMP NOT NULL Last update

Indexes:

Enums:

enum ExpenseStatus {
  pending   // Awaiting approval
  approved  // Approved, ready to pay
  paid      // Payment made
  rejected  // Approval denied
}

Business rules:


Transactions (Double-Entry Ledger)

9. Transaction

Purpose: General ledger transactions. Every financial event creates a transaction.

Column Type Constraints Description
id UUID PK Primary key
organizationId UUID FK → Organization, NOT NULL Organization scope
transactionDate DATE NOT NULL Transaction date
description VARCHAR(255) NOT NULL Transaction description
debitAccountId UUID FK → Account, NOT NULL Account to debit
creditAccountId UUID FK → Account, NOT NULL Account to credit
amount DECIMAL(19,4) NOT NULL Transaction amount
currencyCode CHAR(3) NOT NULL Transaction currency
exchangeRate DECIMAL(12,6) NOT NULL, default 1.0 Exchange rate at transactionDate
baseAmount DECIMAL(19,4) NOT NULL Converted to org baseCurrency
referenceType VARCHAR(50) NULL invoice, expense, payment, manual
referenceId UUID NULL Invoice/Expense ID
locked BOOLEAN NOT NULL, default false Immutable if true
lockedAt TIMESTAMP NULL When locked
reconciled BOOLEAN NOT NULL, default false Matched to bank transaction
reconciledAt TIMESTAMP NULL When reconciled
notes TEXT NULL Free-text notes
createdBy UUID FK → User, NULL Creator user
createdAt TIMESTAMP NOT NULL Record creation

Indexes:

Business rules:

Common transaction patterns:

  1. Invoice created (draft → sent):

    • Debit: Accounts Receivable (Asset)
    • Credit: Revenue (Revenue)
  2. Invoice paid:

    • Debit: Bank Account (Asset)
    • Credit: Accounts Receivable (Asset)
  3. Expense approved:

    • Debit: Expense Account (Expense)
    • Credit: Accounts Payable (Liability)
  4. Expense paid:

    • Debit: Accounts Payable (Liability)
    • Credit: Bank Account (Asset)

Banking & Reconciliation

10. BankAccount

Purpose: Bank account metadata.

Column Type Constraints Description
id UUID PK Primary key
organizationId UUID FK → Organization, NOT NULL Organization scope
accountId UUID FK → Account, NOT NULL GL account (must be Asset)
bankName VARCHAR(255) NOT NULL Bank name
accountNumber VARCHAR(50) NULL Account number
iban VARCHAR(50) NULL IBAN
currencyCode CHAR(3) NOT NULL, default 'EUR' Account currency
currentBalance DECIMAL(19,4) NOT NULL, default 0 Current balance
isActive BOOLEAN NOT NULL, default true Active status
createdAt TIMESTAMP NOT NULL Record creation
updatedAt TIMESTAMP NOT NULL Last update

Indexes:

Business rules:


11. BankTransaction

Purpose: Bank statement imports. For reconciliation.

Column Type Constraints Description
id UUID PK Primary key
bankAccountId UUID FK → BankAccount, CASCADE, NOT NULL Parent bank account
transactionDate DATE NOT NULL Transaction date
amount DECIMAL(19,4) NOT NULL Positive = credit, negative = debit
description VARCHAR(500) NULL Bank description
reference VARCHAR(255) NULL Reference number
reconciled BOOLEAN NOT NULL, default false Matched to GL transaction
matchedTransactionId UUID NULL GL transaction ID
createdAt TIMESTAMP NOT NULL Record creation

Indexes:

Business rules:


Multi-Currency

12. Currency

Purpose: Supported currencies.

Column Type Constraints Description
code CHAR(3) PK ISO 4217 currency code
name VARCHAR(100) NOT NULL Currency name
symbol VARCHAR(10) NULL Currency symbol
decimalPlaces SMALLINT NOT NULL, default 2 Decimal precision
isActive BOOLEAN NOT NULL, default true Active status
createdAt TIMESTAMP NOT NULL Record creation

Seed data:

EUR | Euro           | €    | 2
RSD | Serbian Dinar  | din. | 2
BAM | Bosnian Mark   | KM   | 2
HRK | Croatian Kuna  | kn   | 2
USD | US Dollar      | $    | 2

13. ExchangeRate

Purpose: Historical exchange rates.

Column Type Constraints Description
id UUID PK Primary key
baseCurrency CHAR(3) FK → Currency, NOT NULL From currency
targetCurrency CHAR(3) FK → Currency, NOT NULL To currency
rate DECIMAL(12,6) NOT NULL Exchange rate
effectiveDate DATE NOT NULL Rate effective date
source VARCHAR(50) NULL ECB, fixer.io, manual
lastUpdated TIMESTAMP NOT NULL Last update timestamp

Indexes:

Business rules:


Audit Trail

14. LoggedAction

Purpose: Immutable audit log. Captures all INSERT/UPDATE/DELETE.

Column Type Constraints Description
eventId BIGINT PK, AUTOINCREMENT Event ID
schemaName TEXT NOT NULL Database schema (default: public)
tableName TEXT NOT NULL Table name
userId UUID FK → User, NULL User who performed action
actionTimestamp TIMESTAMP NOT NULL, default now() When action occurred
action ENUM NOT NULL INSERT, UPDATE, DELETE
rowData JSONB NULL Full row data before change
changedFields JSONB NULL Changed fields (UPDATE only)
queryText TEXT NULL SQL query (if available)
clientIp INET NULL Client IP address
applicationName TEXT NOT NULL, default 'fiken-clone-api' Application identifier

Indexes:

Enums:

enum AuditAction {
  INSERT
  UPDATE
  DELETE
}

Business rules:


Schema Version

15. SchemaVersion

Purpose: Migration tracking.

Column Type Constraints Description
version VARCHAR(20) PK Version string (e.g., "1.0.0")
appliedAt TIMESTAMP NOT NULL, default now() Migration timestamp
description TEXT NULL Migration description

Business rules:


Data Types & Precision

NUMERIC(19,4) for ALL Money

CRITICAL: NEVER use float, double, or JavaScript number for currency.

Why:

Usage in code:

import { Decimal } from '@prisma/client/runtime'

const amount = new Decimal('125000.0000')
const taxRate = new Decimal('0.20')
const taxAmount = amount.times(taxRate)  // 25000.0000

Constraints Summary

Primary Keys

All tables use UUID primary keys (except AccountType uses INT auto-increment).

Foreign Keys

All foreign keys have onDelete: Cascade (deleting organization deletes all data).

Unique Constraints

Check Constraints

(Enforced in API layer, not database):


Indexes Strategy

Query Patterns Optimized

  1. List by organization + filter:

    • (organizationId, status, date) composite index on invoices
    • (organizationId, category, date) composite index on expenses
  2. Foreign key lookups:

    • All foreign keys have indexes
  3. Date range queries:

    • Dedicated indexes on transactionDate, invoiceDate, expenseDate, dueDate
  4. Reconciliation:

    • Index on (referenceType, referenceId) for transaction lookups

Migration Commands

# Generate Prisma Client
npx prisma generate

# Create migration
npx prisma migrate dev --name migration_name

# Apply migrations (production)
npx prisma migrate deploy

# Reset database (dev only)
npx prisma migrate reset

# Seed initial data
npx prisma db seed

Seed Data

AccountType

INSERT INTO account_types (id, name, normal_balance) VALUES
(1, 'Asset', 'debit'),
(2, 'Liability', 'credit'),
(3, 'Equity', 'credit'),
(4, 'Revenue', 'credit'),
(5, 'Expense', 'debit');

Currency

INSERT INTO currencies (code, name, symbol, decimal_places) VALUES
('EUR', 'Euro', '€', 2),
('RSD', 'Serbian Dinar', 'din.', 2),
('BAM', 'Bosnian Mark', 'KM', 2),
('HRK', 'Croatian Kuna', 'kn', 2),
('USD', 'US Dollar', '$', 2);

End of Database Schema

Authentication & Authorization

Bilko Authentication & Authorization

Status: SPECIFICATION (backend not implemented) Last updated: 2026-02-20


Purpose

This document specifies the authentication and authorization system for Bilko's backend. Covers JWT tokens, password hashing, 2FA, role-based access control (RBAC), and session management.


Authentication Flow

System Overview

graph LR
    CLIENT[Frontend\nbilko.io]

    subgraph AUTH [Auth Layer]
        LOGIN[POST /auth/login]
        REGISTER[POST /auth/register]
        REFRESH[POST /auth/refresh]
        LOGOUT[POST /auth/logout]
    end

    subgraph TOKENS [Token Storage]
        AT[Access Token\nBearer header\n15 min TTL]
        RT[Refresh Token\nhttpOnly Cookie\n7-30 days TTL]
        BL[Blacklist\nRevoked JTIs]
    end

    subgraph GUARDS [Middleware Guards]
        AG[authGuard\nVerify JWT]
        RG[roleGuard\nCheck role]
        RL[rateLimiter\n5 req/min auth]
    end

    CLIENT --> LOGIN
    CLIENT --> REGISTER
    CLIENT --> REFRESH
    CLIENT --> LOGOUT

    LOGIN --> AT
    LOGIN --> RT
    REGISTER --> AT
    REGISTER --> RT
    REFRESH --> AT
    LOGOUT --> BL

    AT --> AG
    AG --> RG
    RG --> HANDLER[Route Handler]

    style AT fill:#00E5A0,color:#000
    style RT fill:#ffd700,color:#000
    style BL fill:#f87171,color:#fff

1. Registration

Endpoint: POST /api/v1/auth/register

sequenceDiagram
    participant C as Client
    participant API as Express API
    participant DB as PostgreSQL
    participant JWT as JWT Service

    C->>API: POST /auth/register\n{email, password, orgName, country}
    API->>API: Validate Zod schema\nCheck password strength
    API->>DB: Check email uniqueness
    DB-->>API: Email available
    API->>API: bcrypt.hash(password, 12)
    API->>DB: BEGIN TRANSACTION\nCreate Organization\nCreate User (role=owner)\nCreate default Chart of Accounts
    DB-->>API: Organization + User created
    API->>JWT: Generate access token (15min)\nGenerate refresh token (7days)
    JWT-->>API: { accessToken, refreshToken }
    API->>C: 201 Created\n{ user, organization, tokens }\nSet-Cookie: refreshToken (httpOnly)

Steps:

  1. Validate request body (email uniqueness, password strength, country/currency codes)
  2. Hash password with bcrypt (12 rounds)
  3. Create database transaction:
    • Create Organization
    • Create User (role = 'owner')
    • Create default Chart of Accounts (seed accounts based on country)
  4. Generate JWT access token (15 min expiry)
  5. Generate refresh token (7 days expiry)
  6. Set refresh token in httpOnly cookie
  7. Return user + organization + tokens

Password Requirements:

Password Hashing:

import bcrypt from 'bcrypt'

const SALT_ROUNDS = 12

async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS)
}

async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash)
}

Errors:


2. Login

Endpoint: POST /api/v1/auth/login

sequenceDiagram
    participant C as Client
    participant RL as Rate Limiter\n5 req/min/IP
    participant API as Express API
    participant DB as PostgreSQL
    participant JWT as JWT Service

    C->>RL: POST /auth/login\n{email, password}
    RL-->>C: 429 Too Many Requests (if exceeded)
    RL->>API: Pass through
    API->>DB: Find user by email
    DB-->>API: User record
    API->>API: bcrypt.compare(password, hash)

    alt Invalid credentials
        API-->>C: 401 Unauthorized
    else 2FA required
        API-->>C: 403 { requiresTwoFactor: true }
        C->>API: POST /auth/verify-2fa { code }
        API->>API: speakeasy.totp.verify()
    end

    API->>DB: UPDATE users SET lastLoginAt = now()
    API->>JWT: Generate access token (15min)\nGenerate refresh token\n(7d or 30d if rememberMe)
    JWT-->>API: Tokens
    API->>C: 200 OK { user, tokens }\nSet-Cookie: refreshToken (httpOnly, Secure, SameSite=Strict)

Steps:

  1. Find user by email
  2. Verify password with bcrypt.compare()
  3. If 2FA enabled, send TOTP challenge (not covered in MVP)
  4. Update user.lastLoginAt
  5. Generate JWT access token (15 min expiry)
  6. Generate refresh token (7 days or 30 days if rememberMe = true)
  7. Set refresh token in httpOnly cookie
  8. Return user + tokens

Rate Limiting:

Errors:


3. Token Refresh

Endpoint: POST /api/v1/auth/refresh

sequenceDiagram
    participant C as Client
    participant API as Express API
    participant BL as Blacklist\n(Redis/PostgreSQL)
    participant JWT as JWT Service

    C->>API: POST /auth/refresh\n[Cookie: refreshToken]
    API->>API: Extract JWT from httpOnly cookie
    API->>JWT: Verify signature & expiry
    JWT-->>API: RefreshTokenPayload { sub, jti, exp }
    API->>BL: Check if jti is blacklisted
    BL-->>API: Not blacklisted
    API->>JWT: Generate new access token (15min)
    JWT-->>API: New accessToken
    API->>C: 200 OK { accessToken }

    note over C,API: Access token expires every 15min\nClient must silently refresh via cookie

Steps:

  1. Extract refresh token from httpOnly cookie
  2. Verify refresh token signature
  3. Check if token is blacklisted (revoked)
  4. Check expiry
  5. Generate new access token (15 min expiry)
  6. Return new access token

Refresh Token Storage:

Token Revocation:

Errors:


4. Logout

Endpoint: POST /api/v1/auth/logout

Steps:

  1. Extract refresh token from cookie
  2. Add token JTI to blacklist
  3. Clear httpOnly cookie
  4. Return 204 No Content

JWT Tokens

Access Token

Purpose: Short-lived token for API authentication.

Claims:

interface AccessTokenPayload {
  sub: string           // User ID (UUID)
  email: string         // User email
  role: UserRole        // owner, admin, accountant, viewer
  orgId: string         // Organization ID (UUID)
  iat: number           // Issued at (Unix timestamp)
  exp: number           // Expires at (Unix timestamp, iat + 15 min)
}

Expiry: 15 minutes

Header:

{
  "alg": "HS256",
  "typ": "JWT"
}

Usage:


Refresh Token

Purpose: Long-lived token for obtaining new access tokens.

Claims:

interface RefreshTokenPayload {
  sub: string           // User ID (UUID)
  jti: string           // JWT ID (for revocation)
  iat: number           // Issued at (Unix timestamp)
  exp: number           // Expires at (Unix timestamp, iat + 7 days or 30 days)
}

Expiry: 7 days (default) or 30 days (if rememberMe = true)

Storage:

Revocation:


JWT Secret Management

CRITICAL: JWT secret MUST be stored securely.

Environment Variables:

# .env
JWT_SECRET=<256-bit random string, minimum 32 chars>
JWT_REFRESH_SECRET=<different 256-bit random string>

Generation:

# Generate secure random secret
openssl rand -base64 32

Best Practices:


Two-Factor Authentication (2FA)

Status: OPTIONAL in MVP, implement in v2

sequenceDiagram
    participant U as User
    participant APP as Frontend
    participant API as Backend
    participant DB as PostgreSQL

    Note over U,DB: 2FA Setup Flow
    U->>APP: Enable 2FA in settings
    APP->>API: POST /settings/2fa/enable
    API->>API: Generate 32-char base32 TOTP secret
    API->>APP: { secret, qrCodeUrl }
    APP->>U: Display QR code
    U->>U: Scan with Google Authenticator / Authy
    U->>APP: Enter 6-digit code to verify
    APP->>API: POST /settings/2fa/verify { code }
    API->>API: speakeasy.totp.verify(secret, code)
    API->>DB: UPDATE users SET twoFactorEnabled=true\ntwoFactorSecret=encrypted
    API->>APP: 2FA activated

    Note over U,DB: Login with 2FA
    U->>APP: Enter email + password
    APP->>API: POST /auth/login
    API->>DB: Find user, verify password
    API->>APP: 403 { requiresTwoFactor: true }
    APP->>U: Prompt for 6-digit code
    U->>APP: Enter TOTP code
    APP->>API: POST /auth/verify-2fa { code }
    API->>API: Verify TOTP (30-second window ±1 step)
    API->>APP: 200 OK { user, tokens }

Flow:

  1. User enables 2FA in settings
  2. Generate TOTP secret (32-char base32 string)
  3. Display QR code (Google Authenticator, Authy compatible)
  4. User scans QR code
  5. User enters 6-digit code to verify
  6. Store twoFactorSecret (encrypted) in users table
  7. Set twoFactorEnabled = true

Login with 2FA:

  1. User enters email + password
  2. If twoFactorEnabled = true, return 403 with requiresTwoFactor: true
  3. Frontend prompts for 6-digit code
  4. User submits code via POST /api/v1/auth/verify-2fa
  5. Verify TOTP code (30-second window)
  6. If valid, issue tokens

TOTP Verification:

import speakeasy from 'speakeasy'

function verifyTOTP(secret: string, token: string): boolean {
  return speakeasy.totp.verify({
    secret,
    encoding: 'base32',
    token,
    window: 1  // Allow 1 time step before/after (30s window)
  })
}

Role-Based Access Control (RBAC)

RBAC Model

graph TD
    subgraph ROLES [User Roles — Hierarchy]
        OW[owner\nFull control]
        AD[admin\nManage users + all financials]
        AC[accountant\nCreate financials only]
        VW[viewer\nRead-only]
    end

    subgraph ACTIONS [Protected Actions]
        direction LR
        INV_C[Create Invoice]
        INV_S[Send Invoice]
        INV_P[Mark Invoice Paid]
        EXP_C[Create Expense]
        EXP_AP[Approve Expense]
        TXN[Create Transaction]
        USR_I[Invite User]
        USR_R[Change User Role]
        USR_D[Delete User]
        ORG_S[Org Settings]
        ORG_D[Delete Organization]
        RPT[View Reports]
    end

    OW --> INV_C & INV_S & INV_P
    OW --> EXP_C & EXP_AP
    OW --> TXN
    OW --> USR_I & USR_R & USR_D
    OW --> ORG_S & ORG_D
    OW --> RPT

    AD --> INV_C & INV_S & INV_P
    AD --> EXP_C & EXP_AP
    AD --> TXN
    AD --> USR_I
    AD --> ORG_S
    AD --> RPT

    AC --> INV_C & INV_S & INV_P
    AC --> EXP_C
    AC --> TXN
    AC --> RPT

    VW --> RPT

    style OW fill:#00E5A0,color:#000
    style AD fill:#60a5fa,color:#000
    style AC fill:#fbbf24,color:#000
    style VW fill:#94a3b8,color:#000

Roles

Role Permissions
owner Full access: manage users, delete organization, change settings, all financial operations
admin Manage users (except owner), change settings, all financial operations
accountant Create/edit invoices, expenses, transactions. View reports. Cannot manage users or settings.
viewer Read-only access to all financial data. Cannot create or edit.

Permission Matrix

Action owner admin accountant viewer
Create invoice
Edit invoice (draft)
Send invoice
Mark invoice paid
Create expense
Approve expense
Create manual transaction
View reports
Invite user
Change user role
Delete user
Update org settings
Delete organization

Middleware Implementation

Role Guard:

import { Request, Response, NextFunction } from 'express'

type UserRole = 'owner' | 'admin' | 'accountant' | 'viewer'

function roleGuard(allowedRoles: UserRole[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const user = req.user  // Attached by authGuard middleware

    if (!user) {
      return res.status(401).json({ error: 'Unauthorized', code: 'NO_AUTH' })
    }

    if (!allowedRoles.includes(user.role)) {
      return res.status(403).json({
        error: 'Forbidden',
        code: 'INSUFFICIENT_PERMISSIONS',
        details: { required: allowedRoles, current: user.role }
      })
    }

    next()
  }
}

// Usage in routes
app.post('/api/v1/invoices',
  authGuard,
  roleGuard(['owner', 'admin', 'accountant']),
  createInvoice
)

Session Management

Session Storage

Option 1: JWT-only (stateless, recommended for MVP):

Option 2: Redis sessions (for v2):


Session Invalidation

On password change:

  1. Hash new password
  2. Update users.passwordHash
  3. Delete all refresh tokens from blacklist older than 1 hour (force re-login)
  4. Return success

On account deletion:

  1. Soft-delete user (set isActive = false)
  2. Add all user's refresh tokens to blacklist
  3. Revoke access immediately

Security Best Practices

1. Password Storage

2. Token Security

3. Rate Limiting

4. HTTPS Only

5. CORS Configuration

const corsOptions = {
  origin: ['https://bilko.io', 'http://localhost:3000'],
  credentials: true,  // Allow cookies
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}

app.use(cors(corsOptions))

6. Input Validation


Example Implementation

Auth Middleware

import jwt from 'jsonwebtoken'
import { Request, Response, NextFunction } from 'express'

interface AuthRequest extends Request {
  user?: {
    id: string
    email: string
    role: UserRole
    organizationId: string
  }
}

async function authGuard(req: AuthRequest, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Unauthorized', code: 'NO_TOKEN' })
  }

  const token = authHeader.substring(7)  // Remove 'Bearer '

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as AccessTokenPayload

    // Attach user to request
    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
      organizationId: payload.orgId
    }

    next()
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' })
    }

    return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' })
  }
}

export { authGuard, roleGuard }

Environment Variables

# JWT
JWT_SECRET=<256-bit secret for access tokens>
JWT_REFRESH_SECRET=<256-bit secret for refresh tokens>
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d

# Rate Limiting
RATE_LIMIT_AUTH=5           # Max login attempts per minute
RATE_LIMIT_GENERAL=100      # Max requests per minute

# Session
SESSION_COOKIE_SECURE=true  # HTTPS only (production)
SESSION_COOKIE_SAMESITE=strict

End of Authentication Documentation

Business Logic

Bilko Business Logic

Status: SPECIFICATION (backend not implemented) Last updated: 2026-02-20


Purpose

This document defines the accounting domain rules that Bilko's backend MUST enforce. These are non-negotiable business requirements for financial accuracy and compliance.


Table of Contents

  1. Double-Entry Bookkeeping
  2. Invoice Workflow
  3. Expense Workflow
  4. VAT Calculation
  5. Multi-Currency
  6. Bank Reconciliation
  7. Chart of Accounts
  8. Fiscal Year
  9. Audit Trail

1. Double-Entry Bookkeeping

Core Principle

EVERY financial event creates a Transaction with exactly one debit and one credit.

The fundamental equation:

DEBITS = CREDITS

Double-Entry Flow

flowchart TD
    EVENT[Financial Event\ne.g. Invoice sent, Expense approved, Payment received]
    EVENT --> TXN[Create Transaction\ndebitAccountId + creditAccountId + amount]

    TXN --> CHK{Validate:\ndebit ≠ credit\namount > 0}
    CHK -->|FAIL| ERR[422 Validation Error]
    CHK -->|PASS| DEBIT[Debit Account\nIncrease if Asset/Expense\nDecrease if Liability/Equity/Revenue]
    DEBIT --> CREDIT[Credit Account\nIncrease if Liability/Equity/Revenue\nDecrease if Asset/Expense]
    CREDIT --> BAL{Trial Balance\nSum Debits = Sum Credits?}
    BAL -->|Balanced| LOCK[Lock Transaction\nappend to GL]
    BAL -->|Unbalanced| ALERT[System Alert\nCritical Error]

    style EVENT fill:#00E5A0,color:#000
    style ERR fill:#f87171,color:#fff
    style ALERT fill:#f87171,color:#fff
    style LOCK fill:#60a5fa,color:#000

Common Transaction Patterns

flowchart LR
    subgraph INV_SENT [Invoice Sent]
        IS_D[Debit: 1200 Accounts Receivable\nAsset ↑]
        IS_C[Credit: 4000 Revenue\nRevenue ↑]
        IS_D -. "amount" .- IS_C
    end

    subgraph INV_PAID [Invoice Paid]
        IP_D[Debit: 1000 Bank Account\nAsset ↑]
        IP_C[Credit: 1200 Accounts Receivable\nAsset ↓]
        IP_D -. "amount" .- IP_C
    end

    subgraph EXP_APR [Expense Approved]
        EA_D[Debit: 5100 Expense Account\nExpense ↑]
        EA_C[Credit: 2000 Accounts Payable\nLiability ↑]
        EA_D -. "amount" .- EA_C
    end

    subgraph EXP_PAID [Expense Paid]
        EP_D[Debit: 2000 Accounts Payable\nLiability ↓]
        EP_C[Credit: 1000 Bank Account\nAsset ↓]
        EP_D -. "amount" .- EP_C
    end

    style IS_D fill:#4ade80,color:#000
    style IP_D fill:#4ade80,color:#000
    style EA_D fill:#fb923c,color:#000
    style EP_D fill:#4ade80,color:#000

Account types and normal balances:

Account Type Normal Balance Increases with Decreases with
Asset Debit Debit Credit
Liability Credit Credit Debit
Equity Credit Credit Debit
Revenue Credit Credit Debit
Expense Debit Debit Credit

Transaction Rules

  1. Debit Account ≠ Credit Account

    • A transaction cannot debit and credit the same account
    • Enforced at API validation layer
  2. Amount > 0

    • Transaction amount must be positive
    • Sign is determined by debit/credit, not amount
  3. Balanced Entries

    • Debit amount = Credit amount
    • No split transactions in MVP (one debit, one credit only)
  4. Locked Transactions

    • Once transaction.locked = true, cannot be edited or deleted
    • Locked at end-of-period close or when reconciled

Common Transaction Patterns

1. Invoice Created (draft → sent)

Debit:  1200 - Accounts Receivable (Asset)    +125,000 RSD
Credit: 4000 - Revenue (Revenue)              +125,000 RSD

Effect: Increases asset (money owed to us), increases revenue.

2. Invoice Paid

Debit:  1000 - Bank Account (Asset)           +125,000 RSD
Credit: 1200 - Accounts Receivable (Asset)    -125,000 RSD

Effect: Increases cash, decreases receivables (converted to cash).

3. Expense Approved

Debit:  5100 - Infrastructure Expense (Expense)  +850 EUR
Credit: 2000 - Accounts Payable (Liability)      +850 EUR

Effect: Increases expense, increases liability (we owe money).

4. Expense Paid

Debit:  2000 - Accounts Payable (Liability)   -850 EUR
Credit: 1000 - Bank Account (Asset)           -850 EUR

Effect: Decreases liability, decreases cash.

Balance Calculation

Account balance = Sum(debits) - Sum(credits) for debit-normal accounts (Asset, Expense)

Account balance = Sum(credits) - Sum(debits) for credit-normal accounts (Liability, Equity, Revenue)

Trial Balance:


2. Invoice Workflow

Status Transitions

draft → sent → viewed → paid
  ↓       ↓       ↓
  └─────→ cancelled
stateDiagram-v2
    [*] --> draft : POST /invoices\n(auto-number: INV-YYYY-NNN)

    draft --> sent : PATCH /invoices/:id/status\naction=send\n[generates PDF → R2]\n[sends email via SendGrid]\n[creates Transaction:\nDR Receivable / CR Revenue]

    sent --> viewed : Email tracking pixel loaded\n[updates invoice.viewedAt]

    viewed --> paid : PATCH status action=mark-paid\n[creates Transaction:\nDR Bank / CR Receivable]

    sent --> paid : PATCH status action=mark-paid\n[creates Transaction:\nDR Bank / CR Receivable]

    draft --> cancelled : PATCH status action=cancel
    sent --> cancelled : PATCH status action=cancel\n[reverses Transaction]
    viewed --> cancelled : PATCH status action=cancel\n[reverses Transaction]

    paid --> [*]
    cancelled --> [*]

    note right of draft
        Editable: items, dates, amounts
        Invoice number locked on first save
    end note

    note right of sent
        LOCKED — cannot edit amounts
        PDF stored in Cloudflare R2
        exchangeRate locked at invoiceDate
    end note

    note right of paid
        2 GL Transactions created total:
        1. draft→sent: DR Receivable / CR Revenue
        2. paid: DR Bank / CR Receivable
    end note

Invoice Calculation Flow

flowchart TD
    ITEMS[Invoice Items\nquantity × unitPrice = lineTotal]
    ITEMS --> SUB[subtotal = SUM all lineTotals]
    SUB --> TAX[taxAmount = SUM lineTotal × taxRate/100]
    TAX --> DISC[Apply discountAmount]
    DISC --> TOTAL[totalAmount = subtotal + taxAmount - discountAmount]
    TOTAL --> BASE[baseAmount = totalAmount × exchangeRate\nexchangeRate locked at invoiceDate]
    BASE --> LOCK[Store — NEVER recalculate\nfrom future exchange rates]

    style LOCK fill:#f87171,color:#fff
    style BASE fill:#ffd700,color:#000

Status rules:

From To Action Transaction Created?
draft sent Send email Yes (Debit Receivable, Credit Revenue)
sent viewed Email opened No
viewed paid Mark paid Yes (Debit Bank, Credit Receivable)
sent paid Mark paid Yes (Debit Bank, Credit Receivable)
any cancelled Cancel Reverses original transaction

Business Rules

Rule 1: Invoice Number Auto-Generation

Rule 2: Draft-Only Editing

Rule 3: Overdue Detection

Rule 4: Subtotal Calculation

subtotal = SUM(lineTotal) for all invoice items
lineTotal = quantity * unitPrice

Rule 5: Tax Calculation

taxAmount = SUM(lineTotal * (taxRate / 100)) for all items

Rule 6: Total Calculation

totalAmount = subtotal + taxAmount - discountAmount

Rule 7: Base Amount Conversion

baseAmount = totalAmount * exchangeRate

Rule 8: PDF Generation

Rule 9: Email Delivery


3. Expense Workflow

Status Transitions

pending → approved → paid
   ↓
rejected
stateDiagram-v2
    [*] --> pending : POST /expenses\n(auto-number: EXP-YYYY-NNN)\ncreatedBy: accountant/admin/owner

    pending --> approved : PATCH /expenses/:id/approve\nRoles: owner, admin ONLY\n[creates Transaction:\nDR Expense / CR Accounts Payable]

    pending --> rejected : PATCH /expenses/:id/reject\nRoles: owner, admin ONLY\n[no Transaction created]

    approved --> paid : PATCH /expenses/:id/pay\n[creates Transaction:\nDR Accounts Payable / CR Bank]

    paid --> [*]
    rejected --> [*]

    note right of pending
        Can be edited before approval
        Receipt upload optional (max 10MB)
        PDF/PNG/JPG formats
    end note

    note right of approved
        Cannot edit after approval
        Stored in Cloudflare R2 receipts/
        exchangeRate locked at expenseDate
    end note

Status rules:

From To Action Transaction Created?
pending approved Approve Yes (Debit Expense, Credit Payable)
pending rejected Reject No
approved paid Mark paid Yes (Debit Payable, Credit Bank)

Business Rules

Rule 1: Expense Number Auto-Generation

Rule 2: Approval Required

Rule 3: Receipt Upload

Rule 4: Category Tracking

Rule 5: Tax Amount

Rule 6: Base Amount Conversion

baseAmount = amount * exchangeRate

4. VAT Calculation

VAT Calculation Flow

flowchart TD
    subgraph OUTPUT [Output VAT — Sales]
        INV[Invoice sent to customer]
        INV --> OLINE[For each line item:\nlineTotal = qty × unitPrice\nlineTaxAmount = lineTotal × taxRate/100]
        OLINE --> OTOT[Invoice taxAmount = SUM all lineTaxAmounts]
        OTOT --> OREC[Recorded as Output VAT\nin VAT Report]
    end

    subgraph INPUT [Input VAT — Purchases]
        EXP[Expense from vendor]
        EXP --> ETAX[expense.taxAmount field\nUser-entered or calculated]
        ETAX --> IREC[Recorded as Input VAT\nin VAT Report]
    end

    subgraph NET [Net VAT Calculation]
        OREC --> CALC[netVAT = outputVAT - inputVAT]
        IREC --> CALC
        CALC --> POS{netVAT > 0?}
        POS -->|Yes| OWE[Owe to tax authority\nFile PDV/VAT return]
        POS -->|No| REF[Tax authority owes refund\nRare for SMBs]
    end

    style OWE fill:#f87171,color:#fff
    style REF fill:#4ade80,color:#000

VAT Rates by Country

Country Standard VAT Reduced VAT Zero VAT
Serbia (RS) 20% 10% 0%
BiH (BA) 17% - 0%
Croatia (HR) 25% 13% 0%

Business Rules

Rule 1: Tax Rate Application

Rule 2: Tax Amount Calculation

For each invoice item:
  lineTotal = quantity * unitPrice
  lineTaxAmount = lineTotal * (taxRate / 100)

For invoice:
  subtotal = SUM(lineTotal)
  taxAmount = SUM(lineTaxAmount)
  totalAmount = subtotal + taxAmount - discountAmount

Rule 3: Output VAT (Sales)

Rule 4: Input VAT (Purchases)

Rule 5: Net VAT Calculation

netVAT = outputVAT - inputVAT

VAT Report Structure

interface VATReport {
  period: { from: string, to: string }

  outputVAT: {
    total: Decimal                    // Total VAT collected
    invoices: Array<{
      invoiceNumber: string
      customerName: string
      invoiceDate: string
      baseAmount: Decimal             // Subtotal
      vatAmount: Decimal              // Tax amount
      vatRate: Decimal                // Tax rate %
    }>
  }

  inputVAT: {
    total: Decimal                    // Total VAT paid
    expenses: Array<{
      expenseNumber: string
      vendorName: string
      expenseDate: string
      baseAmount: Decimal
      vatAmount: Decimal
      vatRate: Decimal
    }>
  }

  netVAT: Decimal                     // outputVAT - inputVAT

  reconciliationStatus: {
    allInvoicesPaid: boolean          // All invoices in period are paid
    allExpensesApproved: boolean      // All expenses in period are approved
    unmatchedTransactions: number     // Unreconciled bank transactions
  }
}

5. Multi-Currency

Supported Currencies

MVP:

Exchange Rate Locking

CRITICAL RULE: Exchange rates are locked at transaction date.

Why:

How it works:

  1. Invoice created on 2026-02-20:

    • currencyCode = 'RSD'
    • exchangeRate = 117.50 (EUR to RSD rate on 2026-02-20)
    • totalAmount = 125,000 RSD
    • baseAmount = 125,000 / 117.50 = 1,063.83 EUR (locked)
  2. Today (2026-03-15), rate is now 120.00:

    • Invoice baseAmount stays 1,063.83 EUR
    • NEVER recalculated to 125,000 / 120.00 = 1,041.67 EUR

Exchange Rate Sources

Primary: European Central Bank (ECB) API

Fallback: fixer.io API

Manual Entry:

Base Currency Conversion

All reports displayed in organization's baseCurrency.

Example:

Total Revenue: 1,063.83 + 3,500 = 4,563.83 EUR


6. Bank Reconciliation

Purpose

Match bank transactions (from statements) to general ledger transactions (from invoices/expenses).

flowchart TD
    CSV[Bank Statement CSV\nDate, Description, Amount, Reference]
    CSV --> PARSE[Parse & validate CSV\nCreate BankTransaction records]
    PARSE --> LINK[Link to BankAccount]

    LINK --> MATCH[Auto-Match Algorithm\nScore 0-100]

    subgraph SCORE [Match Score Calculation]
        S1[+50 pts: Exact amount match]
        S2[+30 pts: Same date\n+20 pts: ±1 day\n+10 pts: ±3 days]
        S3[+20 pts: Reference contains\ninvoice/expense number]
    end

    MATCH --> SCORE
    SCORE --> THRESH{Score?}

    THRESH -->|≥ 90| AUTO[Auto-match\nreconciled = true]
    THRESH -->|70-89| SUGGEST[Suggest to user\nUser confirms]
    THRESH -->|< 70| MANUAL[Manual review\nUser links manually]

    SUGGEST --> CONFIRM{User\nconfirms?}
    CONFIRM -->|Yes| RECONCILE[Set reconciled = true\nmatchedTransactionId = glTxId]
    CONFIRM -->|No| MANUAL

    MANUAL --> RECONCILE

    AUTO --> RECONCILE
    RECONCILE --> REPORT[Reconciliation Report\nbalanceDiscrepancy should = 0]

    style AUTO fill:#4ade80,color:#000
    style MANUAL fill:#fb923c,color:#000
    style REPORT fill:#60a5fa,color:#000

Process

  1. Import bank statement (CSV):

    • Parse CSV file
    • Create BankTransaction records
    • Link to BankAccount
  2. Auto-match transactions:

    • Match by amount + date (within ±3 days)
    • Match by reference (invoice number in description)
    • Calculate confidence score (0-100)
  3. Manual reconciliation:

    • User links BankTransaction to Transaction
    • Set bankTransaction.reconciled = true
    • Set bankTransaction.matchedTransactionId = transaction.id
  4. Unmatched transactions:

    • Flag in reconciliation report
    • User must create manual journal entry or mark as miscellaneous

Matching Algorithm

Score calculation:

function calculateMatchScore(
  bankTx: BankTransaction,
  glTx: Transaction
): number {
  let score = 0

  // Exact amount match
  if (Math.abs(bankTx.amount) === glTx.amount) {
    score += 50
  }

  // Date within ±3 days
  const daysDiff = Math.abs(
    daysBetween(bankTx.transactionDate, glTx.transactionDate)
  )
  if (daysDiff === 0) score += 30
  else if (daysDiff <= 1) score += 20
  else if (daysDiff <= 3) score += 10

  // Reference contains invoice/expense number
  if (glTx.referenceType === 'invoice' && bankTx.description?.includes(glTx.referenceId)) {
    score += 20
  }

  return score
}

Auto-match threshold:

Reconciliation Report

interface ReconciliationReport {
  bankAccount: {
    id: string
    name: string
    currentBalance: Decimal
  }

  period: { from: string, to: string }

  bankTransactions: {
    total: number
    reconciled: number
    unreconciled: number
    totalAmount: Decimal
  }

  glTransactions: {
    total: number
    reconciled: number
    unreconciled: number
    totalAmount: Decimal
  }

  unmatchedBankTransactions: Array<BankTransaction>
  unmatchedGLTransactions: Array<Transaction>

  balanceDiscrepancy: Decimal       // Should be 0 when fully reconciled
}

7. Chart of Accounts

Structure

Hierarchical account codes:

Example Serbian Chart of Accounts:

1000  Assets
  1100  Current Assets
    1110  Cash
    1120  Bank Accounts
      1121  Intesa RSD Account
      1122  Raiffeisen EUR Account
    1200  Accounts Receivable
  1500  Fixed Assets
    1510  Equipment
    1520  Vehicles

2000  Liabilities
  2100  Current Liabilities
    2110  Accounts Payable
    2120  VAT Payable
  2500  Long-term Liabilities
    2510  Loans Payable

3000  Equity
  3100  Share Capital
  3900  Retained Earnings

4000  Revenue
  4100  Service Revenue
  4200  Product Sales

5000  Expenses
  5100  Operating Expenses
    5110  Salaries
    5120  Rent
    5130  Utilities
  5200  Cost of Goods Sold

Business Rules

Rule 1: Account Hierarchy

Rule 2: Account Deactivation

Rule 3: Reserved Accounts


8. Fiscal Year

Definition

Fiscal year: 12-month period for financial reporting.

Default: January 1 - December 31

Configurable: Organization can set custom fiscal year start (e.g., April 1 for UK-style fiscal year)

Business Rules

Rule 1: Year-End Close

Rule 2: Period-Based Reports


9. Audit Trail

Purpose

Immutable log of all data changes for:

What is Logged

ALL INSERT/UPDATE/DELETE operations on:

Captured data:

Implementation

Via Prisma Middleware:

prisma.$use(async (params, next) => {
  const result = await next(params)

  if (['create', 'update', 'delete'].includes(params.action)) {
    await prisma.loggedAction.create({
      data: {
        tableName: params.model,
        userId: getCurrentUserId(),
        action: params.action.toUpperCase(),
        rowData: params.action === 'delete' ? params.args.where : null,
        changedFields: params.action === 'update' ? params.args.data : null,
        clientIp: getClientIp(),
        applicationName: 'bilko-api'
      }
    })
  }

  return result
})

Retention Policy


Summary of Critical Business Rules

  1. Double-entry: Every transaction has one debit and one credit
  2. Debits = Credits: Ledger must always balance
  3. Exchange rate locking: Rates locked at transaction date, NEVER recalculated
  4. Invoice workflow: draft → sent → paid (creates 2 transactions)
  5. Expense workflow: pending → approved → paid (creates 2 transactions)
  6. VAT calculation: taxAmount = lineTotal * (taxRate / 100)
  7. Account hierarchy: Parent-child relationships in Chart of Accounts
  8. Audit trail: ALL changes logged immutably
  9. Fiscal year close: Lock transactions, transfer P&L to Retained Earnings
  10. Reconciliation: Match bank transactions to GL transactions

End of Business Logic Documentation

Middleware Stack

Bilko Middleware Stack

Status: SPECIFICATION (backend not implemented) Last updated: 2026-02-20


Purpose

This document specifies the Express middleware stack for Bilko's backend. Middleware order is CRITICAL — security, authentication, validation, and error handling must execute in the correct sequence.


Middleware Execution Order

The order matters. Middleware executes top-to-bottom:

Full Middleware Pipeline

flowchart TD
    REQ[Incoming HTTP Request]

    REQ --> H[1. Helmet\nSecurity Headers\nHSTS, CSP, X-Frame-Options,\nX-Content-Type-Options]

    H --> CORS[2. CORS\nAllow: bilko.io, localhost:3000\ncredentials: true\nmaxAge: 86400]

    CORS --> BP[3. Body Parser\nexpress.json limit=10mb\nexpress.urlencoded]

    BP --> RL{4. Rate Limiter\nAuth: 5 req/min\nGeneral: 100 req/min}

    RL -->|Exceeded| R429[429 Too Many Requests]
    RL -->|OK| LOG[5. Morgan Logger\nWinston transport\nCombined format]

    LOG --> ROUTE[6. Router\n/api/v1/*]

    ROUTE --> AUTH{authGuard\nVerify Bearer JWT}
    AUTH -->|No token| R401A[401 NO_TOKEN]
    AUTH -->|Expired| R401B[401 TOKEN_EXPIRED]
    AUTH -->|Invalid| R401C[401 INVALID_TOKEN]
    AUTH -->|Valid| ATTACH[Attach req.user\n{id, email, role, orgId}]

    ATTACH --> ROLE{roleGuard\nCheck allowed roles}
    ROLE -->|Insufficient| R403[403 INSUFFICIENT_PERMISSIONS]
    ROLE -->|Authorized| VALID{validate\nZod schema}

    VALID -->|Fails| R422[422 VALIDATION_ERROR]
    VALID -->|Passes| SCOPE[organizationScope\nAttach req.organizationId]

    SCOPE --> HANDLER[Route Handler\nBusiness Logic + Prisma]

    HANDLER --> AUDIT[Prisma Middleware\nLoggedAction INSERT]
    AUDIT --> RESP[200/201/204 Response]

    HANDLER -->|Error thrown| ERR[7. Error Handler\nMUST be last middleware]
    ERR --> FMTERR[Format error response\n{error, code, details}]
    FMTERR --> ERRRESP[4xx/500 Response]

    style REQ fill:#00E5A0,color:#000
    style R429 fill:#f87171,color:#fff
    style R401A fill:#f87171,color:#fff
    style R401B fill:#f87171,color:#fff
    style R401C fill:#f87171,color:#fff
    style R403 fill:#f87171,color:#fff
    style R422 fill:#f87171,color:#fff
    style RESP fill:#4ade80,color:#000
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
import { authGuard, roleGuard } from './middleware/auth'
import { validate } from './middleware/validation'
import { errorHandler } from './middleware/error-handler'

const app = express()

// 1. Security headers (helmet)
app.use(helmet())

// 2. CORS configuration
app.use(cors(corsOptions))

// 3. Body parsing
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true }))

// 4. Rate limiting
app.use(rateLimiter)

// 5. Request logging (Morgan)
app.use(morgan('combined'))

// 6. Routes (with auth + validation per-route)
app.use('/api/v1', routes)

// 7. Error handler (MUST be last)
app.use(errorHandler)

1. Helmet (Security Headers)

Purpose: Sets HTTP security headers to prevent common attacks.

Installation:

npm install helmet

Configuration:

import helmet from 'helmet'

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],  // Allow inline styles for Next.js
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https://r2.bilko.io"],  // Cloudflare R2
      connectSrc: ["'self'", "https://api.bilko.io"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: []
    }
  },
  hsts: {
    maxAge: 31536000,            // 1 year
    includeSubDomains: true,
    preload: true
  },
  frameguard: { action: 'deny' },  // Prevent clickjacking
  noSniff: true,                   // Prevent MIME sniffing
  xssFilter: true                  // Enable XSS filter
}))

Headers set:


2. CORS (Cross-Origin Resource Sharing)

Purpose: Allow frontend (Next.js) to call backend API from different origin.

Installation:

npm install cors

Configuration:

import cors from 'cors'

const corsOptions = {
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://bilko.io',
      'https://www.bilko.io',
      'http://localhost:3000'  // Development only
    ]

    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true)
    } else {
      callback(new Error('Not allowed by CORS'))
    }
  },
  credentials: true,              // Allow cookies (refresh tokens)
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count', 'X-Page-Count'],  // For pagination
  maxAge: 86400                   // Cache preflight for 24h
}

app.use(cors(corsOptions))

Why credentials: true?


3. Body Parsing

Purpose: Parse JSON request bodies.

Built-in Express middleware:

app.use(express.json({
  limit: '10mb',                  // Max request body size
  strict: true,                   // Reject non-arrays/objects
  type: 'application/json'
}))

app.use(express.urlencoded({
  extended: true,
  limit: '10mb'
}))

Limits:


4. Rate Limiting

Purpose: Prevent abuse, brute-force attacks, DDoS.

Installation:

npm install express-rate-limit

Configuration:

import rateLimit from 'express-rate-limit'

// General API rate limiter
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,            // 1 minute
  max: 100,                       // Max 100 requests per minute
  message: {
    error: 'Too many requests',
    code: 'RATE_LIMIT_EXCEEDED',
    retryAfter: 60
  },
  standardHeaders: true,          // Return RateLimit-* headers
  legacyHeaders: false,
  handler: (req, res) => {
    res.status(429).json({
      error: 'Too many requests',
      code: 'RATE_LIMIT_EXCEEDED',
      retryAfter: 60
    })
  }
})

// Auth rate limiter (stricter)
const authLimiter = rateLimit({
  windowMs: 60 * 1000,            // 1 minute
  max: 5,                         // Max 5 login attempts per minute
  skipSuccessfulRequests: true,  // Don't count successful logins
  keyGenerator: (req) => {
    return req.ip                 // Rate limit by IP
  }
})

// Apply to routes
app.use('/api/v1', apiLimiter)
app.use('/api/v1/auth/login', authLimiter)
app.use('/api/v1/auth/register', authLimiter)

Rate limits by endpoint:

Endpoint Limit Window Why
/api/v1/auth/login 5 1 min Prevent brute-force
/api/v1/auth/register 5 1 min Prevent spam registration
/api/v1/* (general) 100 1 min General API protection
Write ops (POST/PUT/PATCH) 50 1 min Prevent resource exhaustion

5. Request Logging

Purpose: Log all HTTP requests for debugging and monitoring.

Installation:

npm install morgan
npm install winston

Configuration:

import morgan from 'morgan'
import winston from 'winston'

// Winston logger
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
})

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }))
}

// Morgan HTTP logging
app.use(morgan('combined', {
  stream: {
    write: (message) => logger.info(message.trim())
  }
}))

Log format:

:remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"

Example:

192.168.1.100 - user@example.com [20/Feb/2026:10:30:15 +0000] "POST /api/v1/invoices HTTP/1.1" 201 512 "https://bilko.io" "Mozilla/5.0..."

Per-Route Middleware Composition

graph LR
    subgraph PUBLIC [Public Routes — No Auth]
        P1["POST /auth/register\n[rateLimiter(5/min)] → handler"]
        P2["POST /auth/login\n[rateLimiter(5/min)] → handler"]
        P3["POST /auth/refresh\n[rateLimiter(100/min)] → handler"]
        P4["GET /track/email/:id\n[handler — tracking pixel]"]
    end

    subgraph VIEWER [Viewer Routes — All Roles]
        V1["GET /invoices\n[auth] → [orgScope] → handler"]
        V2["GET /reports/*\n[auth] → [orgScope] → handler"]
        V3["GET /contacts\n[auth] → [orgScope] → handler"]
    end

    subgraph ACCOUNTANT [Accountant+ Routes]
        A1["POST /invoices\n[auth] → [role:owner,admin,accountant]\n→ [validate] → [orgScope] → handler"]
        A2["POST /expenses\n[auth] → [role:owner,admin,accountant]\n→ [validate] → handler"]
        A3["POST /transactions\n[auth] → [role:owner,admin,accountant]\n→ [validate] → handler"]
    end

    subgraph ADMIN [Admin+ Routes]
        AD1["PATCH /expenses/:id/approve\n[auth] → [role:owner,admin] → handler"]
        AD2["POST /users/invite\n[auth] → [role:owner,admin] → handler"]
        AD3["PUT /organization\n[auth] → [role:owner,admin] → handler"]
    end

    subgraph OWNER [Owner-Only Routes]
        O1["DELETE /users/:id\n[auth] → [role:owner] → handler"]
        O2["PUT /users/:id/role\n[auth] → [role:owner] → handler"]
        O3["DELETE /organization\n[auth] → [role:owner] → handler"]
    end

    style PUBLIC fill:#e2e8f0,color:#000
    style VIEWER fill:#dcfce7,color:#000
    style ACCOUNTANT fill:#fef9c3,color:#000
    style ADMIN fill:#dbeafe,color:#000
    style OWNER fill:#fce7f3,color:#000

6. Authentication Middleware

Purpose: Verify JWT access token, attach user to request.

Implementation:

import jwt from 'jsonwebtoken'
import { Request, Response, NextFunction } from 'express'

interface AuthRequest extends Request {
  user?: {
    id: string
    email: string
    role: 'owner' | 'admin' | 'accountant' | 'viewer'
    organizationId: string
  }
}

export async function authGuard(req: AuthRequest, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'Unauthorized',
      code: 'NO_TOKEN'
    })
  }

  const token = authHeader.substring(7)  // Remove 'Bearer '

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
      sub: string
      email: string
      role: string
      orgId: string
    }

    // Attach user to request
    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role as any,
      organizationId: payload.orgId
    }

    next()
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: 'Token expired',
        code: 'TOKEN_EXPIRED'
      })
    }

    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({
        error: 'Invalid token',
        code: 'INVALID_TOKEN'
      })
    }

    return res.status(500).json({
      error: 'Authentication error',
      code: 'AUTH_ERROR'
    })
  }
}

Usage in routes:

app.get('/api/v1/invoices', authGuard, getInvoices)

7. Role-Based Access Control (RBAC)

Purpose: Restrict endpoints by user role.

Implementation:

type UserRole = 'owner' | 'admin' | 'accountant' | 'viewer'

export function roleGuard(allowedRoles: UserRole[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({
        error: 'Unauthorized',
        code: 'NO_AUTH'
      })
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'Forbidden',
        code: 'INSUFFICIENT_PERMISSIONS',
        details: {
          required: allowedRoles,
          current: req.user.role
        }
      })
    }

    next()
  }
}

Usage in routes:

// Only owner and admin can delete users
app.delete('/api/v1/users/:id',
  authGuard,
  roleGuard(['owner', 'admin']),
  deleteUser
)

// Everyone can view invoices
app.get('/api/v1/invoices',
  authGuard,
  getInvoices
)

// Only owner, admin, accountant can create invoices
app.post('/api/v1/invoices',
  authGuard,
  roleGuard(['owner', 'admin', 'accountant']),
  createInvoice
)

8. Request Validation

Purpose: Validate request body, query, params with Zod schemas.

Installation:

npm install zod

Implementation:

import { z } from 'zod'
import { Request, Response, NextFunction } from 'express'

type ValidateTarget = 'body' | 'query' | 'params'

export function validate(schema: z.ZodSchema, target: ValidateTarget = 'body') {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      const data = req[target]
      const validated = schema.parse(data)

      // Replace with validated data (coerced types)
      req[target] = validated

      next()
    } catch (error) {
      if (error instanceof z.ZodError) {
        return res.status(422).json({
          error: 'Validation failed',
          code: 'VALIDATION_ERROR',
          details: error.flatten().fieldErrors
        })
      }

      return res.status(500).json({
        error: 'Validation error',
        code: 'VALIDATION_ERROR'
      })
    }
  }
}

Usage in routes:

import { z } from 'zod'

const createInvoiceSchema = z.object({
  customerId: z.string().uuid(),
  invoiceDate: z.string().date(),
  dueDate: z.string().date(),
  items: z.array(z.object({
    description: z.string().min(1).max(500),
    quantity: z.number().positive(),
    unitPrice: z.number().positive(),
    taxRate: z.number().min(0).max(100)
  })).min(1)
})

app.post('/api/v1/invoices',
  authGuard,
  roleGuard(['owner', 'admin', 'accountant']),
  validate(createInvoiceSchema, 'body'),
  createInvoice
)

Error response:

{
  "error": "Validation failed",
  "code": "VALIDATION_ERROR",
  "details": {
    "customerId": ["Invalid UUID"],
    "items.0.quantity": ["Must be positive"]
  }
}

9. Organization Scoping

Purpose: Automatically filter all queries by organizationId to enforce multi-tenancy.

Implementation:

export function organizationScope(req: AuthRequest, res: Response, next: NextFunction) {
  if (!req.user) {
    return res.status(401).json({
      error: 'Unauthorized',
      code: 'NO_AUTH'
    })
  }

  // Attach organizationId to request for easy access
  req.organizationId = req.user.organizationId

  next()
}

Usage in Prisma queries:

async function getInvoices(req: AuthRequest, res: Response) {
  const invoices = await prisma.invoice.findMany({
    where: {
      organizationId: req.user!.organizationId  // Always filter by org
    }
  })

  res.json({ data: invoices })
}

CRITICAL: NEVER allow cross-organization queries. Always filter by organizationId.


10. Error Handler

Purpose: Catch all errors, format consistently, log, return to client.

MUST be the last middleware.

flowchart TD
    ERR[Error Thrown by any Middleware or Handler]
    ERR --> LOG_ERR[Log to Winston:\nmessage, stack, path, method, userId]
    LOG_ERR --> TYPE{Error Type?}

    TYPE -->|PrismaClientKnownRequestError| PRISMA{Prisma Code?}
    PRISMA -->|P2002 Unique violation| R400A[400 DUPLICATE_RESOURCE\n{field: target}]
    PRISMA -->|P2003 Foreign key| R404A[404 FOREIGN_KEY_ERROR]
    PRISMA -->|P2025 Record not found| R404B[404 NOT_FOUND]
    PRISMA -->|Other| R500[500 INTERNAL_ERROR]

    TYPE -->|ValidationError| R422[422 VALIDATION_ERROR]
    TYPE -->|JsonWebTokenError| R401A[401 INVALID_TOKEN]
    TYPE -->|TokenExpiredError| R401B[401 AUTH_ERROR]
    TYPE -->|Custom AppError| CUSTOM[err.status / err.code]
    TYPE -->|Unknown| R500

    R400A & R404A & R404B & R422 & R401A & R401B & CUSTOM & R500 --> FORMAT[Format Response:\n{ error, code, details? }]
    FORMAT --> SEND[Send to Client]

    style ERR fill:#f87171,color:#fff
    style R500 fill:#dc2626,color:#fff
    style FORMAT fill:#60a5fa,color:#000

Implementation:

import { Request, Response, NextFunction } from 'express'
import { Prisma } from '@prisma/client'

export function errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
  // Log error
  logger.error('Error:', {
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    user: req.user?.id
  })

  // Prisma errors
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    // Unique constraint violation
    if (err.code === 'P2002') {
      return res.status(400).json({
        error: 'Resource already exists',
        code: 'DUPLICATE_RESOURCE',
        details: { field: err.meta?.target }
      })
    }

    // Foreign key constraint violation
    if (err.code === 'P2003') {
      return res.status(404).json({
        error: 'Related resource not found',
        code: 'FOREIGN_KEY_ERROR'
      })
    }

    // Record not found
    if (err.code === 'P2025') {
      return res.status(404).json({
        error: 'Resource not found',
        code: 'NOT_FOUND'
      })
    }
  }

  // Validation errors (already handled by validate middleware)
  if (err.name === 'ValidationError') {
    return res.status(422).json({
      error: err.message,
      code: 'VALIDATION_ERROR'
    })
  }

  // JWT errors (already handled by authGuard)
  if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
    return res.status(401).json({
      error: 'Authentication failed',
      code: 'AUTH_ERROR'
    })
  }

  // Default error
  res.status(err.status || 500).json({
    error: err.message || 'Internal server error',
    code: err.code || 'INTERNAL_ERROR'
  })
}

Error response format:

{
  "error": "Human-readable error message",
  "code": "MACHINE_READABLE_CODE",
  "details": {
    "field": "Additional context"
  }
}

Complete Middleware Stack Example

import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import morgan from 'morgan'
import rateLimit from 'express-rate-limit'
import { authGuard, roleGuard, organizationScope } from './middleware/auth'
import { validate } from './middleware/validation'
import { errorHandler } from './middleware/error-handler'
import routes from './routes'

const app = express()

// 1. Security headers
app.use(helmet(helmetConfig))

// 2. CORS
app.use(cors(corsOptions))

// 3. Body parsing
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true }))

// 4. Rate limiting
app.use('/api/v1', apiLimiter)
app.use('/api/v1/auth/login', authLimiter)
app.use('/api/v1/auth/register', authLimiter)

// 5. Request logging
app.use(morgan('combined', { stream: logger.stream }))

// 6. Routes
app.use('/api/v1', routes)

// 7. Error handler (MUST be last)
app.use(errorHandler)

// Start server
const PORT = process.env.PORT || 4000
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

Middleware Testing

Unit tests for each middleware:

import { describe, it, expect } from 'vitest'
import request from 'supertest'
import app from '../app'

describe('Auth Middleware', () => {
  it('blocks request without token', async () => {
    const res = await request(app).get('/api/v1/invoices')
    expect(res.status).toBe(401)
    expect(res.body.code).toBe('NO_TOKEN')
  })

  it('blocks request with expired token', async () => {
    const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
    const res = await request(app)
      .get('/api/v1/invoices')
      .set('Authorization', `Bearer ${expiredToken}`)

    expect(res.status).toBe(401)
    expect(res.body.code).toBe('TOKEN_EXPIRED')
  })

  it('allows request with valid token', async () => {
    const validToken = generateToken({ sub: 'user-id', role: 'owner', orgId: 'org-id' })
    const res = await request(app)
      .get('/api/v1/invoices')
      .set('Authorization', `Bearer ${validToken}`)

    expect(res.status).not.toBe(401)
  })
})

describe('Role Guard', () => {
  it('blocks accountant from deleting users', async () => {
    const token = generateToken({ sub: 'user-id', role: 'accountant', orgId: 'org-id' })
    const res = await request(app)
      .delete('/api/v1/users/other-user-id')
      .set('Authorization', `Bearer ${token}`)

    expect(res.status).toBe(403)
    expect(res.body.code).toBe('INSUFFICIENT_PERMISSIONS')
  })
})

End of Middleware Documentation

External Services Integration

Bilko External Services

Status: SPECIFICATION (backend not implemented) Last updated: 2026-02-20


Purpose

This document specifies the external service integrations for Bilko's backend. Covers email delivery, file storage, exchange rates, and PDF generation.


Service Integration Architecture

graph TD
    subgraph BILKO [Bilko Backend — apps/api]
        API[Express API\nPort 4000]
        PDF[PDF Service\nPuppeteer]
        RATE[Exchange Rate Service\nCron: daily 00:00 UTC]
        AUDIT[Prisma Middleware\nAudit Logger]
    end

    subgraph EXTERNAL [External Services]
        SG[SendGrid\nnoreply@bilko.io\n100 emails/day free]
        R2[Cloudflare R2\nbilko-files bucket\n10GB free]
        ECB[ECB API\nexchangerate.host\nFree, daily rates]
        FIXER[Fixer.io\n100 req/month free\nFallback]
        CLAM[ClamAV\nVirus Scanner\nlocalhost:3310]
    end

    subgraph STORAGE [Database]
        PG[PostgreSQL 14+\nAll data\nExchangeRate cache]
    end

    API -->|Invoice email + PDF| SG
    API -->|Receipt upload| R2
    PDF -->|Store generated PDF| R2
    RATE -->|Primary rates fetch| ECB
    RATE -->|Fallback if ECB fails| FIXER
    ECB & FIXER -->|Store in DB| PG
    API -->|Scan uploaded files| CLAM
    API -->|All reads/writes| PG
    AUDIT -->|Append-only log| PG

    style SG fill:#00b0f0,color:#fff
    style R2 fill:#f6821f,color:#fff
    style ECB fill:#0070f3,color:#fff
    style FIXER fill:#6366f1,color:#fff
    style PG fill:#336791,color:#fff
    style CLAM fill:#e53e3e,color:#fff

Table of Contents

  1. SendGrid (Email Delivery)
  2. Cloudflare R2 (File Storage)
  3. Exchange Rate APIs
  4. PDF Generation
  5. Error Handling & Fallbacks

1. SendGrid (Email Delivery)

Purpose

Send invoice emails, payment reminders, user invitations, and password resets.

Invoice Email + Tracking Flow

sequenceDiagram
    participant API as Bilko API
    participant PDF as PDF Service\n(Puppeteer)
    participant R2 as Cloudflare R2
    participant SG as SendGrid
    participant CUSTOMER as Customer Email

    Note over API,CUSTOMER: Invoice Send Flow
    API->>PDF: generateInvoicePDF(invoiceId)
    PDF->>PDF: Launch headless Chromium\nRender HTML template\nExport A4 PDF
    PDF-->>API: PDF Buffer
    API->>R2: PUT invoices/{orgId}/INV-2026-001.pdf
    R2-->>API: Public URL stored
    API->>API: Update invoice.pdfUrl
    API->>SG: sendEmail({ to, subject, html, attachment:pdf })
    SG-->>API: { messageId }
    API->>API: Update invoice.sentAt, status='sent'
    SG->>CUSTOMER: Deliver email with PDF attachment
    CUSTOMER->>API: GET /track/email/{invoiceId}\n[1x1 pixel load]
    API->>API: UPDATE invoice SET status='viewed'\nviewedAt=now()

Setup

Account: SendGrid (free tier: 100 emails/day)

Installation:

npm install @sendgrid/mail

Environment Variables:

SENDGRID_API_KEY=SG.xxxxx
SENDGRID_FROM_EMAIL=noreply@bilko.io
SENDGRID_FROM_NAME=Bilko

Configuration

import sgMail from '@sendgrid/mail'

sgMail.setApiKey(process.env.SENDGRID_API_KEY!)

interface SendEmailOptions {
  to: string | string[]
  cc?: string[]
  bcc?: string[]
  subject: string
  text: string
  html: string
  attachments?: Array<{
    content: string      // Base64 encoded
    filename: string
    type: string         // MIME type
    disposition: 'attachment' | 'inline'
  }>
}

async function sendEmail(options: SendEmailOptions) {
  const msg = {
    to: options.to,
    cc: options.cc,
    bcc: options.bcc,
    from: {
      email: process.env.SENDGRID_FROM_EMAIL!,
      name: process.env.SENDGRID_FROM_NAME!
    },
    subject: options.subject,
    text: options.text,
    html: options.html,
    attachments: options.attachments
  }

  try {
    const response = await sgMail.send(msg)
    return {
      success: true,
      messageId: response[0].headers['x-message-id']
    }
  } catch (error) {
    logger.error('SendGrid error:', error)
    throw new Error('Failed to send email')
  }
}

Email Templates

1. Invoice Email

Template variables:

HTML template:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Invoice {{ invoiceNumber }}</title>
  <style>
    body { font-family: Inter, sans-serif; }
    .container { max-width: 600px; margin: 0 auto; padding: 20px; }
    .header { background: #09090b; color: #fff; padding: 20px; text-align: center; }
    .content { padding: 20px; background: #f9fafb; }
    .footer { padding: 20px; text-align: center; color: #6b7280; }
    .button { display: inline-block; padding: 12px 24px; background: #00E5A0; color: #000; text-decoration: none; border-radius: 6px; }
  </style>
</head>
<body>
  <div class="container">
    <div class="header">
      <h1>{{ organizationName }}</h1>
    </div>
    <div class="content">
      <p>Dear {{ customerName }},</p>
      <p>Your invoice <strong>{{ invoiceNumber }}</strong> is ready.</p>
      <table>
        <tr><td>Amount:</td><td><strong>{{ totalAmount }} {{ currencyCode }}</strong></td></tr>
        <tr><td>Due Date:</td><td>{{ dueDate }}</td></tr>
      </table>
      <p><a href="{{ viewInvoiceUrl }}" class="button">View Invoice</a></p>
      <p>Thank you for your business!</p>
    </div>
    <div class="footer">
      <p>Powered by <a href="https://bilko.io">Bilko</a></p>
    </div>
  </div>
  <!-- Tracking pixel -->
  <img src="{{ trackingPixelUrl }}" width="1" height="1" />
</body>
</html>

Attachment: Invoice PDF (generated via PDF service)

2. User Invitation Email

Template variables:

Subject: {{ inviterName }} invited you to {{ organizationName }} on Bilko

3. Password Reset Email

Template variables:

Subject: Reset your Bilko password

Email Tracking

Purpose: Track when customer views invoice email (for viewedAt timestamp).

How it works:

  1. Embed 1x1 transparent pixel in email HTML
  2. Pixel URL: https://api.bilko.io/track/email/{{ invoiceId }}
  3. When customer opens email, browser loads pixel
  4. Backend endpoint logs view:
app.get('/track/email/:invoiceId', async (req, res) => {
  const { invoiceId } = req.params

  await prisma.invoice.update({
    where: { id: invoiceId },
    data: {
      status: 'viewed',
      viewedAt: new Date()
    }
  })

  // Return 1x1 transparent GIF
  const pixel = Buffer.from(
    'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
    'base64'
  )
  res.writeHead(200, {
    'Content-Type': 'image/gif',
    'Content-Length': pixel.length
  })
  res.end(pixel)
})

Rate Limits

SendGrid free tier:

Recommendation for MVP: Free tier sufficient for testing. Upgrade to paid plan at launch.


2. Cloudflare R2 (File Storage)

Purpose

Store invoice PDFs and expense receipts.

Why R2 over S3:

Setup

Account: Cloudflare (free tier: 10GB storage)

Installation:

npm install @aws-sdk/client-s3
npm install @aws-sdk/s3-request-presigner

Environment Variables:

R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_BUCKET_NAME=bilko-files
R2_PUBLIC_URL=https://r2.bilko.io

Configuration

import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

const s3 = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!
  }
})

interface UploadFileOptions {
  key: string             // File path (e.g., "invoices/INV-2026-001.pdf")
  body: Buffer | Uint8Array
  contentType: string     // MIME type
  metadata?: Record<string, string>
}

async function uploadFile(options: UploadFileOptions): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME!,
    Key: options.key,
    Body: options.body,
    ContentType: options.contentType,
    Metadata: options.metadata
  })

  await s3.send(command)

  // Return public URL
  return `${process.env.R2_PUBLIC_URL}/${options.key}`
}

async function getSignedDownloadUrl(key: string, expiresIn: number = 3600): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: process.env.R2_BUCKET_NAME!,
    Key: key
  })

  return getSignedUrl(s3, command, { expiresIn })
}

File Organization

Bucket structure:

bilko-files/
├── invoices/
│   ├── org-uuid-1/
│   │   ├── INV-2026-001.pdf
│   │   └── INV-2026-002.pdf
│   └── org-uuid-2/
│       └── INV-2026-001.pdf
├── receipts/
│   ├── org-uuid-1/
│   │   ├── EXP-2026-001.jpg
│   │   └── EXP-2026-002.pdf
│   └── org-uuid-2/
└── exports/
    └── org-uuid-1/
        └── report-2026-02-20.xlsx

Key format: {category}/{organizationId}/{filename}

File Upload Workflow

Invoice PDF:

async function storeInvoicePDF(invoiceId: string, organizationId: string, pdf: Buffer): Promise<string> {
  const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } })
  const filename = `${invoice.invoiceNumber}.pdf`
  const key = `invoices/${organizationId}/${filename}`

  const url = await uploadFile({
    key,
    body: pdf,
    contentType: 'application/pdf',
    metadata: {
      invoiceId,
      organizationId,
      uploadedAt: new Date().toISOString()
    }
  })

  await prisma.invoice.update({
    where: { id: invoiceId },
    data: { pdfUrl: url }
  })

  return url
}

Expense Receipt:

async function storeExpenseReceipt(expenseId: string, organizationId: string, file: Express.Multer.File): Promise<string> {
  const expense = await prisma.expense.findUnique({ where: { id: expenseId } })
  const ext = file.mimetype.split('/')[1]  // 'pdf', 'jpeg', 'png'
  const filename = `${expense.expenseNumber}.${ext}`
  const key = `receipts/${organizationId}/${filename}`

  const url = await uploadFile({
    key,
    body: file.buffer,
    contentType: file.mimetype,
    metadata: {
      expenseId,
      organizationId,
      uploadedAt: new Date().toISOString()
    }
  })

  await prisma.expense.update({
    where: { id: expenseId },
    data: { receiptUrl: url }
  })

  return url
}

Security

1. Signed URLs for private files:

2. Virus scanning:

npm install clamscan
import NodeClam from 'clamscan'

const clam = await new NodeClam().init({
  clamdscan: {
    host: 'localhost',
    port: 3310
  }
})

async function scanFile(filePath: string): Promise<boolean> {
  const { isInfected } = await clam.scanFile(filePath)
  return !isInfected
}

3. Exchange Rate APIs

Purpose

Fetch daily exchange rates for multi-currency support.

Exchange Rate Fetch & Fallback Flow

flowchart TD
    CRON[Cron Job\nDaily at 00:00 UTC]
    CRON --> ECB[Fetch from ECB API\nexchangerate.host/latest?base=EUR]

    ECB --> ECB_OK{Success?}
    ECB_OK -->|Yes| STORE[Upsert ExchangeRate records\nsource='ECB']
    ECB_OK -->|No| FIXER[Fallback: Fixer.io\napi.fixer.io/latest]

    FIXER --> FIX_OK{Success?}
    FIX_OK -->|Yes| STORE2[Upsert ExchangeRate records\nsource='fixer.io']
    FIX_OK -->|No| PREV[Use yesterday's rates\nWarn in logs]

    STORE & STORE2 & PREV --> AVAIL[Rates available in DB\nfor transaction date locking]

    subgraph LOOKUP [Rate Lookup at Transaction Time]
        L1[getExchangeRate\nbaseCurrency, targetCurrency, date]
        L2{Same currency?}
        L3[Return rate = 1.0]
        L4[Find exact date in DB]
        L5{Found?}
        L6[Return rate]
        L7[Find nearest available date\norderBy effectiveDate DESC]
        L8[Warn in logs\nReturn nearest rate]

        L1 --> L2
        L2 -->|Yes| L3
        L2 -->|No| L4
        L4 --> L5
        L5 -->|Yes| L6
        L5 -->|No| L7
        L7 --> L8
    end

    AVAIL --> LOOKUP
    style PREV fill:#fb923c,color:#000
    style L8 fill:#fb923c,color:#000

Primary: European Central Bank (ECB)

Endpoint: https://api.exchangerate.host/latest

Free: Yes (unlimited)

Example:

async function fetchECBRates(baseCurrency: string = 'EUR'): Promise<Record<string, number>> {
  const url = `https://api.exchangerate.host/latest?base=${baseCurrency}`

  const response = await fetch(url)
  const data = await response.json()

  if (!data.success) {
    throw new Error('ECB API error')
  }

  return data.rates  // { RSD: 117.50, BAM: 1.95, HRK: 7.53, USD: 1.07 }
}

Fallback: Fixer.io

Endpoint: https://api.fixer.io/latest

Free tier: 100 requests/month

Environment Variables:

FIXER_API_KEY=your-api-key

Example:

async function fetchFixerRates(baseCurrency: string = 'EUR'): Promise<Record<string, number>> {
  const url = `https://api.fixer.io/latest?base=${baseCurrency}&access_key=${process.env.FIXER_API_KEY}`

  const response = await fetch(url)
  const data = await response.json()

  if (!data.success) {
    throw new Error('Fixer.io API error')
  }

  return data.rates
}

Exchange Rate Service

async function updateExchangeRates(): Promise<void> {
  try {
    // Try ECB first
    const rates = await fetchECBRates('EUR')

    // Store in database
    for (const [targetCurrency, rate] of Object.entries(rates)) {
      await prisma.exchangeRate.upsert({
        where: {
          baseCurrency_targetCurrency_effectiveDate: {
            baseCurrency: 'EUR',
            targetCurrency,
            effectiveDate: new Date()
          }
        },
        update: {
          rate,
          source: 'ECB',
          lastUpdated: new Date()
        },
        create: {
          baseCurrency: 'EUR',
          targetCurrency,
          rate,
          effectiveDate: new Date(),
          source: 'ECB'
        }
      })
    }

    logger.info('Exchange rates updated', { source: 'ECB', count: Object.keys(rates).length })
  } catch (error) {
    // Fallback to Fixer.io
    try {
      const rates = await fetchFixerRates('EUR')

      for (const [targetCurrency, rate] of Object.entries(rates)) {
        await prisma.exchangeRate.upsert({
          where: {
            baseCurrency_targetCurrency_effectiveDate: {
              baseCurrency: 'EUR',
              targetCurrency,
              effectiveDate: new Date()
            }
          },
          update: { rate, source: 'fixer.io', lastUpdated: new Date() },
          create: {
            baseCurrency: 'EUR',
            targetCurrency,
            rate,
            effectiveDate: new Date(),
            source: 'fixer.io'
          }
        })
      }

      logger.info('Exchange rates updated', { source: 'fixer.io', count: Object.keys(rates).length })
    } catch (fallbackError) {
      logger.error('Failed to update exchange rates', { error: fallbackError })
      // Use yesterday's rates (better than nothing)
    }
  }
}

// Schedule: Daily at 00:00 UTC
cron.schedule('0 0 * * *', updateExchangeRates)

Get Exchange Rate

async function getExchangeRate(
  baseCurrency: string,
  targetCurrency: string,
  date: Date = new Date()
): Promise<number> {
  // If same currency, rate = 1.0
  if (baseCurrency === targetCurrency) {
    return 1.0
  }

  // Find rate for exact date
  let rate = await prisma.exchangeRate.findUnique({
    where: {
      baseCurrency_targetCurrency_effectiveDate: {
        baseCurrency,
        targetCurrency,
        effectiveDate: date
      }
    }
  })

  // If not found, use nearest available rate
  if (!rate) {
    rate = await prisma.exchangeRate.findFirst({
      where: { baseCurrency, targetCurrency },
      orderBy: { effectiveDate: 'desc' }
    })
  }

  if (!rate) {
    throw new Error(`No exchange rate found for ${baseCurrency} → ${targetCurrency}`)
  }

  return parseFloat(rate.rate.toString())
}

4. PDF Generation

Purpose

Generate invoice PDFs with organization branding.

Option 1: Puppeteer (Server-Side Rendering)

Installation:

npm install puppeteer

Implementation:

import puppeteer from 'puppeteer'

async function generateInvoicePDF(invoiceId: string): Promise<Buffer> {
  const invoice = await prisma.invoice.findUnique({
    where: { id: invoiceId },
    include: {
      customer: true,
      organization: true,
      items: true
    }
  })

  // Render HTML template
  const html = renderInvoiceHTML(invoice)

  // Launch headless browser
  const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  })

  const page = await browser.newPage()
  await page.setContent(html, { waitUntil: 'networkidle0' })

  // Generate PDF
  const pdf = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
  })

  await browser.close()

  return pdf
}

Option 2: @react-pdf/renderer (React Components)

Installation:

npm install @react-pdf/renderer

Implementation:

import { Document, Page, Text, View, StyleSheet, pdf } from '@react-pdf/renderer'

const styles = StyleSheet.create({
  page: { padding: 30 },
  header: { fontSize: 24, marginBottom: 20 },
  table: { display: 'table', width: '100%' },
  row: { flexDirection: 'row', borderBottomWidth: 1, borderColor: '#ddd' },
  cell: { padding: 10 }
})

function InvoicePDF({ invoice }) {
  return (
    <Document>
      <Page style={styles.page}>
        <View style={styles.header}>
          <Text>{invoice.organization.name}</Text>
        </View>
        <Text>Invoice {invoice.invoiceNumber}</Text>
        <View style={styles.table}>
          {invoice.items.map((item) => (
            <View style={styles.row} key={item.id}>
              <Text style={styles.cell}>{item.description}</Text>
              <Text style={styles.cell}>{item.quantity}</Text>
              <Text style={styles.cell}>{item.unitPrice}</Text>
              <Text style={styles.cell}>{item.lineTotal}</Text>
            </View>
          ))}
        </View>
      </Page>
    </Document>
  )
}

async function generateInvoicePDF(invoiceId: string): Promise<Buffer> {
  const invoice = await fetchInvoiceData(invoiceId)
  const doc = <InvoicePDF invoice={invoice} />
  const pdfBlob = await pdf(doc).toBlob()
  return Buffer.from(await pdfBlob.arrayBuffer())
}

Recommendation: Puppeteer for MVP (more flexible HTML/CSS), React PDF for v2 (better TypeScript support).


5. Error Handling & Fallbacks

Circuit Breaker & Retry Pattern

stateDiagram-v2
    [*] --> closed : Initial state

    closed --> open : failures >= threshold (5)\nService marked as unavailable

    open --> half_open : timeout elapsed (60s)\nAttempt single test call

    half_open --> closed : Test call succeeded\nReset failure count

    half_open --> open : Test call failed\nReset timeout

    note right of closed
        Normal operation
        All calls pass through
        Failures counted
    end note

    note right of open
        All calls REJECTED immediately
        Error: "Circuit breaker is open"
        No calls to external service
    end note

    note right of half_open
        Single probe call allowed
        Determines if service recovered
    end note

Retry Strategy

For transient errors (network timeouts, rate limits):

async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  delay: number = 1000
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn()
    } catch (error) {
      if (i === maxRetries - 1) throw error

      logger.warn(`Retry ${i + 1}/${maxRetries}`, { error })
      await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)))
    }
  }

  throw new Error('Retry limit exceeded')
}

// Usage
const rates = await withRetry(() => fetchECBRates('EUR'))

Circuit Breaker

For external services that frequently fail:

class CircuitBreaker {
  private failures = 0
  private threshold = 5
  private timeout = 60000  // 1 minute
  private state: 'closed' | 'open' | 'half-open' = 'closed'
  private nextAttempt = 0

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('Circuit breaker is open')
      }
      this.state = 'half-open'
    }

    try {
      const result = await fn()
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      throw error
    }
  }

  private onSuccess() {
    this.failures = 0
    this.state = 'closed'
  }

  private onFailure() {
    this.failures++
    if (this.failures >= this.threshold) {
      this.state = 'open'
      this.nextAttempt = Date.now() + this.timeout
      logger.warn('Circuit breaker opened', { failures: this.failures })
    }
  }
}

const sendGridBreaker = new CircuitBreaker()

async function sendEmailWithBreaker(options: SendEmailOptions) {
  return sendGridBreaker.execute(() => sendEmail(options))
}

Graceful Degradation

If external service fails, degrade gracefully:

Example: Invoice email delivery

async function sendInvoiceEmail(invoiceId: string) {
  try {
    await sendEmail({ /* ... */ })
    await prisma.invoice.update({
      where: { id: invoiceId },
      data: { sentAt: new Date(), status: 'sent' }
    })
  } catch (error) {
    logger.error('Failed to send invoice email', { invoiceId, error })

    // Fallback: Mark invoice as sent but flag for manual email
    await prisma.invoice.update({
      where: { id: invoiceId },
      data: {
        status: 'draft',  // Keep in draft
        notes: `Email delivery failed: ${error.message}. Please send manually.`
      }
    })

    // Alert admin
    await sendSlackAlert('Invoice email delivery failed', { invoiceId })
  }
}

Environment Variables Summary

# SendGrid
SENDGRID_API_KEY=SG.xxxxx
SENDGRID_FROM_EMAIL=noreply@bilko.io
SENDGRID_FROM_NAME=Bilko

# Cloudflare R2
R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_BUCKET_NAME=bilko-files
R2_PUBLIC_URL=https://r2.bilko.io

# Fixer.io (fallback)
FIXER_API_KEY=your-api-key

# Feature Flags
ENABLE_EMAIL_TRACKING=true
ENABLE_VIRUS_SCANNING=false  # For MVP

End of Services Documentation

API Coverage Report

API Coverage Report

Date: 2026-02-20 Purpose: Map every frontend page to required API endpoints. Verify 100% coverage. Status: Backend NOT implemented (specification only)


Coverage Matrix

Dashboard Page (/)

UI Element Data Required API Endpoint Status
Cash Balance metric Total bank account balances in base currency GET /api/v1/reports/dashboard COVERED
Revenue MTD metric Month-to-date revenue GET /api/v1/reports/dashboard COVERED
Unpaid Invoices metric Total unpaid invoices GET /api/v1/reports/dashboard COVERED
Expenses MTD metric Month-to-date expenses GET /api/v1/reports/dashboard COVERED
Profit MTD metric Month-to-date profit GET /api/v1/reports/dashboard COVERED
Cash Flow Change Percentage change from last month GET /api/v1/reports/dashboard COVERED
P&L Bar Chart 6-month monthly P&L data GET /api/v1/reports/dashboard COVERED
Receivables Aging Chart Receivables breakdown by age (current, 30d, 60d, 90d+) GET /api/v1/reports/dashboard COVERED
Expenses by Category Chart Expenses grouped by category GET /api/v1/reports/dashboard COVERED
Recent Transactions table Last 5 transactions GET /api/v1/transactions?perPage=5&sort=transactionDate&order=desc COVERED

Invoices List Page (/invoices)

UI Element Data Required API Endpoint Status
Invoice list (all filters) Paginated invoices with filter/sort/search GET /api/v1/invoices?status=X&search=Y&fromDate=Z&toDate=W&page=P&perPage=20&sort=field&order=desc COVERED
Status filter options Invoice list filtered by status GET /api/v1/invoices?status={draft|sent|viewed|paid|overdue|cancelled} COVERED
Search (customer/number) Invoice list filtered by search query GET /api/v1/invoices?search={query} COVERED
Date range filter Invoice list filtered by date range GET /api/v1/invoices?fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD COVERED
Summary totals by status Client-side calculation from filtered list N/A (client-side) COVERED
Edit invoice action Get invoice details for editing GET /api/v1/invoices/:id COVERED
Delete invoice action Delete invoice DELETE /api/v1/invoices/:id MISSING
Send invoice action Send invoice via email POST /api/v1/invoices/:id/send COVERED
Download PDF action Get invoice PDF GET /api/v1/invoices/:id/pdf COVERED

MISSING ENDPOINT:


Invoice Creation Wizard (/invoices/new)

Step UI Element Data Required API Endpoint Status
1 Customer dropdown List of customers GET /api/v1/contacts?type=customer COVERED
1 Add customer dialog Create new customer POST /api/v1/contacts COVERED
2 Invoice number Auto-generated invoice number Client-side generation (format: INV-YYYY-NNN) COVERED
2 Currency options List of supported currencies GET /api/v1/currencies COVERED
3 Line items Form input (no API needed) N/A COVERED
3 VAT rate options Tax rate configuration GET /api/v1/settings/tax-rates COVERED
4 Notes/Terms Form input (no API needed) N/A COVERED
5 Preview Client-side rendering of form data N/A COVERED
6 Save as Draft Create invoice with status=draft POST /api/v1/invoices COVERED
6 Send Invoice Create + send invoice POST /api/v1/invoices (then) POST /api/v1/invoices/:id/send COVERED
6 Download PDF Generate PDF GET /api/v1/invoices/:id/pdf COVERED

Expenses List Page (/expenses)

UI Element Data Required API Endpoint Status
Expense list Paginated expenses with filters GET /api/v1/expenses?period=X&category=Y&search=Z&page=P&perPage=20 COVERED
Period filter Expenses filtered by date range GET /api/v1/expenses?fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD COVERED
Category filter Expenses filtered by category GET /api/v1/expenses?category={category} COVERED
Search (description/vendor) Expenses filtered by search query GET /api/v1/expenses (client-side search on fetched data) COVERED
Summary stats Client-side calculation from filtered list N/A (client-side) COVERED
Create expense Create new expense POST /api/v1/expenses COVERED
Upload receipt Upload receipt file POST /api/v1/expenses (multipart with receiptFile) COVERED
Edit expense Update expense (pending only) PUT /api/v1/expenses/:id COVERED
Approve expense Approve expense PATCH /api/v1/expenses/:id/approve COVERED
Delete expense Delete expense (pending only) DELETE /api/v1/expenses/:id COVERED
Download receipt Get receipt file GET /api/v1/expenses/:id/receipt MISSING

MISSING ENDPOINT:


Purchases Page (/purchases)

UI Element Data Required API Endpoint Status
(Same as /expenses) Same data as expenses page Same as /expenses COVERED

Note: This is an alias route to the expenses page. No additional API endpoints needed.


Banking Page (/banking)

Accounts Tab

UI Element Data Required API Endpoint Status
Bank account list List of bank accounts GET /api/v1/bank-accounts COVERED
Add bank account Create new bank account POST /api/v1/bank-accounts COVERED
Account balance Current balance per account GET /api/v1/bank-accounts (included in response) COVERED

Reconcile Tab

UI Element Data Required API Endpoint Status
Account selector List of bank accounts GET /api/v1/bank-accounts COVERED
Unreconciled transactions Unreconciled bank transactions for selected account GET /api/v1/bank-accounts/:id/transactions?reconciled=false COVERED
Match confidence Client-side calculation based on amount/date/description N/A (client-side) COVERED
Approve match Mark transaction as reconciled POST /api/v1/bank-accounts/:id/reconcile COVERED
Link to invoice/expense Link bank transaction to existing record POST /api/v1/bank-accounts/:id/reconcile (with transactionId) COVERED
Create new transaction Create GL transaction from unmatched bank transaction POST /api/v1/transactions COVERED

Transactions Tab

UI Element Data Required API Endpoint Status
All bank transactions All bank transactions (paginated) GET /api/v1/bank-accounts/:id/transactions COVERED
Reconciliation status filter Bank transactions filtered by reconciled status GET /api/v1/bank-accounts/:id/transactions?reconciled={true|false} COVERED
Date range filter Bank transactions filtered by date GET /api/v1/bank-accounts/:id/transactions?fromDate=X&toDate=Y COVERED

Import Transactions

UI Element Data Required API Endpoint Status
Import CSV Upload bank statement CSV POST /api/v1/bank-accounts/:id/import COVERED

Reports Hub Page (/reports)

UI Element Data Required API Endpoint Status
P&L Report preview Profit & Loss data for current month GET /api/v1/reports/profit-loss?from=YYYY-MM-01&to=YYYY-MM-DD COVERED
Balance Sheet preview Balance sheet data (coming soon) GET /api/v1/reports/balance-sheet?date=YYYY-MM-DD COVERED
Cash Flow preview Cash flow data (coming soon) GET /api/v1/reports/cash-flow?from=X&to=Y COVERED
VAT Report preview VAT report data (live at /reports/vat) GET /api/v1/reports/vat?from=X&to=Y COVERED
Trial Balance preview Trial balance data (coming soon) GET /api/v1/reports/trial-balance?date=YYYY-MM-DD COVERED
General Ledger preview Transaction list (coming soon) GET /api/v1/transactions COVERED

VAT Report Page (/reports/vat)

Step UI Element Data Required API Endpoint Status
1 Reconciliation status check Count of unreconciled bank transactions GET /api/v1/bank-accounts (aggregate unreconciled count client-side) PARTIAL
2 VAT transaction table All invoices and expenses with VAT for period GET /api/v1/reports/vat?from=X&to=Y COVERED
2 Summary boxes (collected/paid/due) Calculated from VAT transaction data GET /api/v1/reports/vat?from=X&to=Y COVERED
3 VAT return boxes Formatted VAT return data GET /api/v1/reports/vat?from=X&to=Y COVERED
3 Export PDF Generate PDF report GET /api/v1/reports/vat/export/pdf?from=X&to=Y MISSING
3 Export XML Generate XML for e-filing GET /api/v1/reports/vat/export/xml?from=X&to=Y MISSING
3 Submit return Submit VAT return (Phase 2) POST /api/v1/reports/vat/submit MISSING

MISSING ENDPOINTS:

PARTIAL COVERAGE:


Settings Page (/settings)

Company Section

UI Element Data Required API Endpoint Status
Company profile form Organization data GET /api/v1/organization COVERED
Save company profile Update organization PUT /api/v1/organization COVERED

Users Section

UI Element Data Required API Endpoint Status
User list List of users in organization GET /api/v1/users COVERED
Invite user Send user invite POST /api/v1/users/invite COVERED
Change user role Update user role PUT /api/v1/users/:id/role COVERED
Remove user Delete user DELETE /api/v1/users/:id COVERED

Tax & Compliance Section

UI Element Data Required API Endpoint Status
Tax settings form Tax rate configuration GET /api/v1/settings/tax-rates COVERED
Save tax settings Update tax rates PUT /api/v1/settings/tax-rates COVERED
VAT registration toggle Update organization (vatNumber field) PUT /api/v1/organization COVERED

Integrations Section

UI Element Data Required API Endpoint Status
Connected integrations List of integrations GET /api/v1/integrations MISSING
Available integrations List of integrations GET /api/v1/integrations MISSING
Connect integration Connect to integration POST /api/v1/integrations/:id/connect MISSING
Disconnect integration Disconnect integration DELETE /api/v1/integrations/:id/disconnect MISSING

MISSING ENDPOINTS (Integrations): All integration-related endpoints are missing. These should be Phase 2, but placeholders needed:

Notifications Section

UI Element Data Required API Endpoint Status
Notification preferences User notification settings GET /api/v1/settings/notifications MISSING
Save preferences Update notification settings PATCH /api/v1/settings/notifications MISSING

MISSING ENDPOINTS (Notifications):

Security Section

UI Element Data Required API Endpoint Status
Enable 2FA Enable 2FA for user POST /api/v1/auth/2fa/enable MISSING
Disable 2FA Disable 2FA for user DELETE /api/v1/auth/2fa/disable MISSING
Session timeout User/org settings GET /api/v1/settings/security MISSING
Save security settings Update security settings PATCH /api/v1/settings/security MISSING
View audit log Audit trail GET /api/v1/security/audit-log MISSING
Request data export Export all data POST /api/v1/security/data-export MISSING
Delete company Delete organization DELETE /api/v1/organization MISSING

MISSING ENDPOINTS (Security):


Forms → API Mapping

Form Submit Action API Endpoint Request Body Status
Create Invoice (Step 6) POST POST /api/v1/invoices CreateInvoiceRequest COVERED
Send Invoice (Step 6) POST POST /api/v1/invoices/:id/send SendInvoiceRequest COVERED
Add Customer (Wizard Step 1) POST POST /api/v1/contacts CreateContactRequest COVERED
Create Expense (Dialog) POST POST /api/v1/expenses CreateExpenseRequest (multipart) COVERED
Upload Receipt (Expense Dialog) POST POST /api/v1/expenses (multipart receiptFile) File upload COVERED
Update Company Profile PUT PUT /api/v1/organization UpdateOrganizationRequest COVERED
Update Tax Settings PUT PUT /api/v1/settings/tax-rates UpdateTaxRatesRequest COVERED
Invite User POST POST /api/v1/users/invite InviteUserRequest COVERED
Change User Role PUT PUT /api/v1/users/:id/role ChangeRoleRequest COVERED
Import Bank Statement POST POST /api/v1/bank-accounts/:id/import CSV file upload COVERED
Create Manual Transaction POST POST /api/v1/transactions CreateTransactionRequest COVERED
Update Notification Preferences PATCH PATCH /api/v1/settings/notifications NotificationSettings MISSING
Update Security Settings PATCH PATCH /api/v1/settings/security SecuritySettings MISSING
Connect Integration POST POST /api/v1/integrations/:id/connect Integration-specific MISSING
Submit VAT Return POST POST /api/v1/reports/vat/submit SubmitVATRequest MISSING

Coverage Summary

Page Endpoints Required Endpoints Documented Coverage
Dashboard 2 2 100%
Invoices List 6 5 83% (missing DELETE)
Invoice Wizard 6 6 100%
Expenses 7 6 86% (missing receipt download)
Purchases 7 6 86% (same as expenses)
Banking 7 7 100%
Reports Hub 6 6 100%
VAT Report 7 4 57% (missing PDF/XML export, submit)
Settings - Company 2 2 100%
Settings - Users 4 4 100%
Settings - Tax 2 2 100%
Settings - Integrations 3 0 0% (Phase 2)
Settings - Notifications 2 0 0% (missing)
Settings - Security 6 0 0% (missing)
TOTAL 67 50 75%

Missing Endpoints

High Priority (Core Features)

  1. DELETE /api/v1/invoices/:id — Delete invoice (draft only)

    • Reason: Invoice list has delete action
  2. GET /api/v1/expenses/:id/receipt — Download expense receipt

    • Reason: Expense list shows receipt indicator, needs download link
  3. GET /api/v1/reports/vat/export/pdf — VAT report PDF export

    • Reason: VAT report has export button (placeholder currently)
  4. GET /api/v1/reports/vat/export/xml — VAT report XML export (e-filing)

    • Reason: VAT report has export button (placeholder currently)

Medium Priority (Settings)

  1. GET /api/v1/settings/notifications — Get notification preferences

    • Reason: Settings page has notification section with checkboxes
  2. PATCH /api/v1/settings/notifications — Update notification preferences

    • Reason: Settings page has save button for notification preferences
  3. GET /api/v1/settings/security — Get security settings

    • Reason: Settings page has security section (session timeout, password policy)
  4. PATCH /api/v1/settings/security — Update security settings

    • Reason: Settings page has save button for security settings
  5. GET /api/v1/security/audit-log — Audit log

    • Reason: Settings security section has "View Audit Log" button
  6. POST /api/v1/security/data-export — GDPR data export

    • Reason: Settings security section has "Request Data Export" button
  7. DELETE /api/v1/organization — Delete company

    • Reason: Settings security section has "Delete Company" button (danger zone)
  8. POST /api/v1/auth/2fa/enable — Enable 2FA

    • Reason: Settings security section has "Enable 2FA" button
  9. DELETE /api/v1/auth/2fa/disable — Disable 2FA

    • Reason: Settings security section needs disable option if 2FA enabled

Low Priority (Phase 2 Features)

  1. GET /api/v1/integrations — List integrations

    • Reason: Settings integrations section (Phase 2)
  2. POST /api/v1/integrations/:id/connect — Connect integration

    • Reason: Settings integrations section (Phase 2)
  3. DELETE /api/v1/integrations/:id/disconnect — Disconnect integration

    • Reason: Settings integrations section (Phase 2)
  4. POST /api/v1/reports/vat/submit — Submit VAT return

    • Reason: VAT report has submit button (explicitly marked "Coming in Phase 2")
  5. GET /api/v1/bank-accounts/unreconciled-count — Unreconciled transaction count

    • Reason: VAT report Step 1 checks reconciliation status (currently client-side aggregation, could be dedicated endpoint)

Redundant Endpoints

None identified. All endpoints in API-REFERENCE.md are consumed by at least one frontend page.


Recommendations

1. Add Missing Core Endpoints

Priority: HIGH Scope: Endpoints 1-4 from Missing Endpoints list

These are referenced directly in existing UI actions. Without them, users will encounter broken functionality:

Implementation Order:

  1. DELETE /api/v1/invoices/:id (simplest, no business logic)
  2. GET /api/v1/expenses/:id/receipt (file download)
  3. GET /api/v1/reports/vat/export/pdf (report generation + PDF library)
  4. GET /api/v1/reports/vat/export/xml (report generation + XML serialization)

2. Implement Settings Endpoints

Priority: MEDIUM Scope: Endpoints 5-13 from Missing Endpoints list

Settings page is fully implemented with save buttons, but no backend to persist data. Current behavior: console.log() only.

Recommendation: Implement all settings endpoints together in one feature branch. They share similar patterns (GET/PATCH pairs, org-scoped data).

3. Phase 2 Features as Stubs

Priority: LOW Scope: Endpoints 14-18 from Missing Endpoints list

These are explicitly marked as "Phase 2" or "Coming Soon" in the UI. Consider implementing stub endpoints that return:

This allows frontend to gracefully handle the "not yet implemented" state without errors.

4. Add Endpoint: GET /api/v1/bank-accounts/unreconciled-count

Priority: LOW Scope: New endpoint not in API-REFERENCE.md

VAT report Step 1 checks for unreconciled transactions. Currently, frontend must:

  1. Fetch GET /api/v1/bank-accounts (all accounts)
  2. For each account, fetch GET /api/v1/bank-accounts/:id/transactions?reconciled=false
  3. Aggregate counts

Recommendation: Add dedicated endpoint to avoid N+1 query pattern.

// Proposed endpoint
GET /api/v1/bank-accounts/unreconciled-count

Response:
{
  total: number
  byAccount: Array<{
    accountId: string
    accountName: string
    count: number
  }>
}

5. Verify TypeScript Interface Consistency

Priority: MEDIUM

API-REFERENCE.md defines TypeScript interfaces for request/response bodies. Frontend uses these types in forms and state management.

Action Items:

Example:

// packages/types/src/invoice.ts
import { z } from 'zod'

export const CreateInvoiceRequestSchema = z.object({
  customerId: z.string().uuid(),
  invoiceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  currencyCode: z.enum(['EUR', 'RSD', 'BAM', 'HRK']).optional(),
  items: z.array(z.object({
    description: z.string().min(1),
    quantity: z.number().positive(),
    unitPrice: z.number().nonnegative(),
    taxRate: z.number().nonnegative(),
    accountId: z.string().uuid().optional()
  })),
  notes: z.string().optional(),
  terms: z.string().optional()
})

export type CreateInvoiceRequest = z.infer<typeof CreateInvoiceRequestSchema>

Backend uses schema for validation:

// apps/api/src/routes/invoices.ts
import { CreateInvoiceRequestSchema } from '@bilko/types'

router.post('/invoices', async (req, res) => {
  const data = CreateInvoiceRequestSchema.parse(req.body) // Throws if invalid
  // ...
})

Frontend uses type for forms:

// apps/web/app/(dashboard)/invoices/new/page.tsx
import { CreateInvoiceRequest } from '@bilko/types'

const [formData, setFormData] = useState<CreateInvoiceRequest>({...})

6. Authentication Endpoints Missing from Coverage

Priority: HIGH

Frontend has no login/register pages yet, but API-REFERENCE.md defines 5 auth endpoints:

Recommendation: Create auth pages in Phase 2:

7. Error Handling Pattern

Priority: MEDIUM

API-REFERENCE.md defines error response format:

interface ApiError {
  error: string
  code: string
  details?: Record<string, string[]>
}

Recommendation: Create frontend error handling utility:

// lib/api-error.ts
export function handleApiError(error: Response) {
  const apiError: ApiError = await error.json()

  if (apiError.details) {
    // Field-level validation errors
    return Object.entries(apiError.details).map(([field, errors]) => ({
      field,
      message: errors.join(', ')
    }))
  }

  // Generic error
  return { message: apiError.error, code: apiError.code }
}

Use in forms:

try {
  await fetch('/api/v1/invoices', { method: 'POST', body: JSON.stringify(data) })
} catch (error) {
  const errors = handleApiError(error)
  // Display errors in form
}

Implementation Priority Matrix

Endpoint Priority Reason Est. Effort
DELETE /api/v1/invoices/:id HIGH Invoice list delete button 2h
GET /api/v1/expenses/:id/receipt HIGH Receipt download links 3h
GET /api/v1/reports/vat/export/pdf HIGH VAT export button 8h
GET /api/v1/reports/vat/export/xml HIGH VAT e-filing 6h
GET /api/v1/settings/notifications MEDIUM Settings page persistence 3h
PATCH /api/v1/settings/notifications MEDIUM Settings page persistence 2h
GET /api/v1/settings/security MEDIUM Settings page persistence 4h
PATCH /api/v1/settings/security MEDIUM Settings page persistence 3h
GET /api/v1/security/audit-log MEDIUM Audit log viewer 5h
POST /api/v1/security/data-export MEDIUM GDPR compliance 8h
DELETE /api/v1/organization MEDIUM Delete company action 4h
POST /api/v1/auth/2fa/enable MEDIUM 2FA setup 8h
DELETE /api/v1/auth/2fa/disable MEDIUM 2FA removal 2h
GET /api/v1/integrations LOW Phase 2 feature 6h
POST /api/v1/integrations/:id/connect LOW Phase 2 feature 12h
DELETE /api/v1/integrations/:id/disconnect LOW Phase 2 feature 3h
POST /api/v1/reports/vat/submit LOW Phase 2 feature 16h
GET /api/v1/bank-accounts/unreconciled-count LOW Performance optimization 3h

Total Estimated Effort: 98 hours (~12-15 working days for 1 developer)

Suggested Implementation Phases:

Phase 2a (Core Features) — 19h

Phase 2b (Settings Persistence) — 31h

Phase 2c (Integrations + VAT Submit) — 37h

Phase 2d (Polish) — 11h


Conclusion

Overall Coverage: 75% (50 out of 67 required endpoints documented)

API-REFERENCE.md is 75% complete. The 50 documented endpoints cover all core business logic:

Missing 25%:

No redundant endpoints. Every endpoint in API-REFERENCE.md is consumed by at least one frontend page.

Recommendation: Implement Phase 2a (core features, 19h) before beta launch. Settings persistence can follow in Phase 2b after user feedback.

Next Steps:

  1. Review this coverage report with team
  2. Prioritize missing endpoints based on business needs
  3. Update API-REFERENCE.md with missing endpoint specs
  4. Create implementation tickets in Mission Control
  5. Build apps/api/ following API-REFERENCE.md contract

Bilko Authentication -- Entra External ID (CIAM)

Overview

Bilko uses Microsoft Entra External ID (CIAM) as its sole identity provider. Entra authenticates users; Bilko authorises them. Roles and permissions live exclusively in the Bilko database — no role claims are read from Entra tokens.

Decision anchor (ADR): "Entra authenticates, Bilko authorises; single-role v1; multi-org deferred (MC #103089)."

Stage status: Live on stage (branch stack WP1–WP4, feat/rbac-wp4-retire-legacy-auth commit 3ac1388). Production cutover pending consolidated PR.

Tenant Configuration

FieldValue
Tenant ID20bb17de-9be5-4143-a7e5-8c1ddae6a064
Display nameBilko CIAM
Domainbilkociam.onmicrosoft.com
TypeEntra External ID (CIAM), EU data residency, Norway
BillingMAU — free tier from 2026-06-07
Authority (MSAL)https://bilkociam.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064
Issuer (exact, from OIDC discovery)https://20bb17de-9be5-4143-a7e5-8c1ddae6a064.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0
JWKS URIhttps://bilkociam.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/discovery/v2.0/keys
OIDC discoveryhttps://bilkociam.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0/.well-known/openid-configuration

Issuer note: OIDC discovery returns the issuer with the tenant-ID subdomain (20bb17de-...ciamlogin.com), NOT the named subdomain (bilkociam.ciamlogin.com). The Kotlin backend ENTRA_EXTERNAL_ID_ISSUER env var MUST match the discovery value exactly. See evidence: /tmp/evidence-103076/phase0-config.md.

App Registrations

AppClient IDFlowNotes
Bilko API (resource)fe39e0f5-513e-40af-93f0-c3ee624df56cExposes scopeScope: access_as_user; full scope string: api://fe39e0f5-513e-40af-93f0-c3ee624df56c/access_as_user; audience for token validation = client ID; no client secret (resource app)
Bilko Web SPAc2902239-ea63-41bd-8619-6cf096d7d45aPKCE auth code (SPA)Redirects: localhost:3000, stage Cloud Run URL, bilko-demo.alai.no, app.bilko.cloud, app.bilko.io, app.bilko.company (+ /auth/callback variants); no client secret
Bilko Mobile (native)916bb9f3-658d-4729-b5a0-64b1f157c8c2PKCE auth code (native/public)Redirect: com.alai.bilko://auth, msauth.com.alai.bilko://auth; isFallbackPublicClient=true; Expo Go workaround documented in Phase 0 config

Secrets (none exist — all public clients). Non-secret configuration is stored in GCP Secret Manager (bilko-entra-issuer, bilko-entra-audience, bilko-entra-jwks-url) and Bitwarden ("Bilko CIAM Tenant Config").

Token Claims

Bilko JWTs issued after exchange use the internal Bilko user UUID as the subject claim — email is not the identity anchor in issued tokens.

Authentication Flow — Web (MSAL Direct Bearer)

  1. Browser opens login page → MSAL browser (@azure/msal-browser + @azure/msal-react) initiates PKCE auth code flow
  2. Redirect to bilkociam.ciamlogin.com → user authenticates (email/password or social) → Entra issues auth code
  3. MSAL exchanges code for tokens (PKCE, in memory — NOT localStorage)
  4. MSAL acquires access token for scope api://fe39e0f5.../access_as_user
  5. Web sends Authorization: Bearer <entra-access-token> to Kotlin API
  6. Kotlin EntraExternalIdService.verifyIdToken() validates: RS256 signature via live JWKS, issuer exact match, audience = fe39e0f5..., oid claim present, JWKS URL domain-pinned to ciamlogin.com/microsoftonline.com
  7. JIT provisioning or email-match link (see JIT section below) → Bilko session returned
  8. Session cookie (SameSite=Lax, httpOnly) established; subsequent requests use Bilko refresh token
  9. Sign-out calls Entra logout endpoint to invalidate Entra session + clears local cookie

Authentication Flow — Mobile (Token Exchange)

  1. Expo native app initiates PKCE via expo-auth-session / useEntraAuthRequest
  2. Redirect to Entra → auth code returned to com.alai.bilko://auth
  3. MSAL exchanges code; id_token (not access_token) sent to POST /auth/entra/session
  4. Kotlin backend verifies id_token, runs JIT provisioning or link, returns { accessToken, refreshToken }
  5. Tokens stored in SecureStore; TTL aligns with Bilko 7-day refresh token window

JIT Provisioning + Identity Linking

Implemented in AuthService.createSessionFromEntraIdToken() (SERIALIZABLE transaction — martin-kleppmann race-prevention mandate):

  1. Lookup entra_external_identities by issuer + oid. If found: return session.
  2. If not found: email-match lookup in users (case-normalised, lowercase both sides). If unique match and email_verified: insert entra_external_identities row, log audit event entra_jit_link, return session.
  3. If no match: call UserProvisioningService.provisionNewUserForEntra() — creates a new org + user with role viewer + inserts entra_external_identities row. New user must be promoted by an admin.

Design dissent on record (martin-kleppmann + bruce-momjian): email-match JIT is risky if email is mutable or duplicate. Pre-provision by OID (via admin invite or MS Graph export script) is the safer path. JIT email-match is constrained with a serializable transaction as a partial mitigation. The pre-provision script path (D8 in MC #103075) is the recommended path for production migration.

JWKS Cache + Key Rotation

EntraExternalIdService maintains a time-bound JWKS key cache:

Refresh Token Revocation (Known Limitation)

Bilko refresh tokens are 7-day HMAC validated locally. A disabled Entra account remains valid in Bilko for up to 7 days. Open CEO/Securion decision (OC#4 from MC #103075):

Legacy Email/Password — RETIRED (410 Gone)

As of branch feat/rbac-wp4-retire-legacy-auth (commit 3ac1388), the following endpoints return HTTP 410 Gone with body {"code":"ENDPOINT_RETIRED"}:

Kept active: POST /auth/entra/session, POST /auth/refresh, POST /auth/mobile/refresh, POST /auth/2fa/challenge.

Web login page: email/password form removed; Entra primary CTA only. Self-serve register page removed; shows "contact your admin" message. Forgot/reset password redirects to Entra SSPR portal.

Break-glass (first-admin bootstrap): run ./gradlew :apps:api:bootStrapAdmin with BOOTSTRAP_ADMIN_EMAIL + BOOTSTRAP_ADMIN_PASSWORD env vars. Calls AuthService.register() directly; no HTTP endpoint exposed.

Phase 0–4 Deployment Facts

Phase / WPScopeBranchStatus
Phase 0 (MC #103076)CIAM tenant provisioning, 3 app registrations, JWKS verificationFlowForge standaloneDONE — stage live
WP1 (MC #103141)RBAC permissions catalog V67, PermissionService, BilkoPrincipal, requirePermission, 204 matrix testsfeat/rbac-wp1-permissions-catalogDONE — Proveo PARTIAL (integration test fix applied post-verification)
WP2 (MC #103142)JIT provisioning V68, UserProvisioningService, admin/invite API, role-assign endpointfeat/rbac-wp2-user-provisioningDONE — Proveo PASS
WP3 (MC #103143)Web: Entra primary CTA, register retired, forgot/reset SSPR, RBAC admin UIfeat/rbac-wp3-web-entra-uiDONE — Proveo PASS
WP4 (MC #103144)Retire legacy endpoints (410), web login Entra-only, break-glass documentedfeat/rbac-wp4-retire-legacy-authDONE — Proveo PASS
WP5 (MC #103145)E2E: live CIAM token, OID anchor, JIT provision, RBAC enforcement, invalid token rejectionfeat/rbac-wp3-web-entra-uiDONE — PASS (browser MSAL flow deferred to Proveo pre-prod)

Evidence bundles: /tmp/evidence-103141 through /tmp/evidence-103145, /tmp/evidence-103076/phase0-config.md.

Bilko RBAC -- Users / Roles / Permissions

Overview

Bilko uses a flat RBAC model: users have one role per organisation; roles map to a permission catalog via a DB seed table. Permission resolution is live from the database on every request (no JWT role claim for authorisation). The system was built in WP1 (MC #103141, branch feat/rbac-wp1-permissions-catalog).

Roles

RoleLevelScope
owner3All permissions including billing, account deletion, user management
admin2All permissions except billing and account deletion; can manage users and roles
accountant1Create and manage financial records; cannot delete; cannot manage users
viewer0Read-only access; default for newly JIT-provisioned Entra users

Roles are stored in users.role (VARCHAR 50) with a CHECK constraint added in V67 limiting values to these four. Single role per user per organisation (multi-role/multi-org deferred, MC #103089).

Permissions Catalog (V67 — 52 keys)

Source: apps/api/src/main/resources/db/migration/V67__rbac_permissions_catalog.sql (commit 66629bd). The catalog is stored in the permissions table; all application code references permission keys as string constants.

Format: <resource>:<verb> enforced by a DB CHECK constraint (permission_key_format). Example keys by resource group:

Resource groupExample keys
Invoicesinvoice:read, invoice:create, invoice:update, invoice:delete, invoice:submit
Expensesexpense:read, expense:create, expense:update, expense:delete
Contactscontact:read, contact:create, contact:update, contact:delete
Transactionstransaction:read, transaction:create, transaction:reconcile
Reportsreport:read, report:export
Settings / billingsettings:read, settings:update, billing:read, billing:update
Usersusers:read, users:manage, users:invite
Account adminaccount:delete
Documentsdocument:read, document:upload, document:delete
Articles / productsarticle:read, article:create, article:update, article:delete

Full 52-key baseline stored in: apps/api/src/main/resources/rbac/requireRole-baseline-v67.tsv (commit 0bf18fd, 51 data rows).

Role-to-Permission Seed (Strategy A — Flat Inheritance)

Source: role_permissions table seeded in V67. Each row: (role, permission_key). No runtime inheritance logic — the seed embeds the full flattened set for each role.

RolePermissions countPrinciple
viewer13Read-only: all :read + :export keys
accountant40viewer permissions + create/update on financial resources; no delete, no user management
admin49accountant permissions + delete + user management; no billing:update, no account:delete
owner52All 52 permissions (complete set)

The seed exactly reproduces the behaviour of the legacy requireRole() numeric hierarchy — verified by 204 RbacMatrixTest cases (0 failures). No behaviour regression.

PermissionService — Live DB Resolution

Source: apps/api/src/main/kotlin/no/alai/bilko/services/PermissionService.kt (commit dee4fb1)

BilkoPrincipal + requirePermission

Source: apps/api/src/main/kotlin/no/alai/bilko/auth/BilkoPrincipal.kt and RbacHelper.kt (commit dee4fb1)

Role-to-Permission Matrix

Permission keyvieweraccountantadminowner
invoice:readYYYY
invoice:create-YYY
invoice:update-YYY
invoice:delete--YY
invoice:submit-YYY
expense:readYYYY
expense:create-YYY
expense:delete--YY
users:readYYYY
users:manage--YY
users:invite--YY
billing:read---Y
billing:update---Y
account:delete---Y
settings:readYYYY
settings:update--YY
report:readYYYY
report:exportYYYY
... (52 total)Full catalog in V67 seed

Full read-only matrix visible to admins/owners in the web admin UI at /admin/users (component: lib/permissions.ts ROLE_PERMISSION_MATRIX).

Authorization Audit Log

Source: apps/api/src/main/kotlin/no/alai/bilko/auth/AuthzAuditLogger.kt (commit dee4fb1)

V67/V68 Migration Summary

MigrationContents
V67 (V67__rbac_permissions_catalog.sql)Creates permissions table (52 keys, format CHECK); role_permissions table with full 4-role seed; adds users.role CHECK constraint; GRANT SELECT to bilko_app; no RLS (global catalog)
V68 (V68__rbac_user_provisioning.sql)Adds users:manage and users:invite permission keys; SECURITY DEFINER function bilko_auth.provision_user_with_org(issuer, oid, email, fullName) returning new user UUID; seeds new permissions to admin + owner roles

Test Coverage

Out of Scope (v1)

Bilko Auth Migration Runbook + Admin Guide

Scope

This runbook covers: (1) how an operator bootstraps the first admin after legacy auth is retired, (2) how an admin creates and invites users, (3) how to assign and change roles, (4) the full user lifecycle via Entra, (5) migration notes from the WP1–WP4 branch stack. For architecture detail see Bilko Authentication — Entra External ID (CIAM).

1. Bootstrapping the First Admin (Break-Glass)

After legacy /auth/register is retired (HTTP 410), there is no HTTP endpoint for creating the first user. Use the Gradle break-glass task:

# Set environment variables (never commit these):
export BOOTSTRAP_ADMIN_EMAIL="admin@yourorg.com"
export BOOTSTRAP_ADMIN_PASSWORD="<strong-temporary-password>"

# Run from the api project root:
cd apps/api
./gradlew :apps:api:bootStrapAdmin

What this does: calls AuthService.register() directly (bypasses HTTP routing), creates an organisation + owner user. No HTTP endpoint is exposed — zero backdoor surface. The temporary password should be rotated immediately via Entra SSPR after the admin first signs in.

Full runbook file: apps/api/docs/runbooks/BREAK-GLASS-BOOTSTRAP.md (on branch feat/rbac-wp4-retire-legacy-auth).

2. Creating / Inviting Users (Admin Flow)

User creation is now admin-gated. Self-serve registration is retired.

Via API

POST /api/v1/admin/users
Authorization: Bearer <admin-or-owner-access-token>

{
  "email": "newuser@example.com",
  "fullName": "Full Name",
  "role": "viewer"        // viewer | accountant | admin | owner
}

Response: HTTP 201 Created — returns the new user object including their UUID. The user receives an invite; they sign in via Entra (JIT provisioning links their Entra identity on first sign-in).

Permission required

users:manage — held by admin and owner roles.

Via Web Admin UI

  1. Sign in as admin or owner
  2. Navigate to Settings > Users (/admin/users)
  3. Click Invite User
  4. Enter email, full name, and select role
  5. Submit — user receives invite email (Entra CIAM invitation flow)

Viewers and accountants see a redirect to the dashboard if they navigate to /admin/users.

3. Assigning / Changing Roles

Via API

PUT /api/v1/users/:id/role
Authorization: Bearer <admin-or-owner-access-token>

{
  "role": "accountant"    // viewer | accountant | admin | owner
}

Constraints enforced:

Via Web Admin UI

  1. Navigate to Settings > Users
  2. Find the user row
  3. Click the role dropdown (visible to admin/owner only)
  4. Select the new role — saved immediately via PUT /users/:id/role

4. User Lifecycle

  1. Admin creates user via POST /admin/users (role = viewer by default, or specified role)
  2. User receives Entra invite (email from bilkociam.onmicrosoft.com)
  3. First sign-in: user clicks Entra sign-in on Bilko web login → authenticates in Entra CIAM → Bilko backend calls createSessionFromEntraIdToken():
    • Looks up entra_external_identities by oid → not found (first login)
    • Email-match lookup → finds pre-created user → inserts entra_external_identities row (JIT link) → audit event entra_jit_link
    • Bilko session returned; user is logged in as viewer
  4. Admin promotes role if needed via PUT /users/:id/role
  5. Subsequent logins: Entra → backend finds entra_external_identities by oid → direct session, no email-match step
  6. Sign-out: MSAL calls Entra logout endpoint → Entra session invalidated → Bilko session cookie cleared → next visit redirects to Entra login

5. What Changed — Migration Notes (WP1–WP4)

AreaBefore (pre-WP1)After (WP1–WP4)
Backend auth enforcementrequireRole("admin") inline in 51+ route handlersrequirePermission("invoice:create") via extension fn; 0 residual requireRole in routes
Permission dataNo tables; hardcoded numeric hierarchy in RbacHelperV67: permissions table (52 keys), role_permissions seed; V68: provisioning function
User provisioningSelf-serve POST /auth/registerAdmin invite (POST /admin/users) + JIT Entra link on first sign-in; UserProvisioningService
Web loginEmail/password form + "Sign in with Microsoft" coexistingEntra-only CTA; no email/password form; register page shows "contact your admin"
Legacy endpointsActive: /auth/login, /auth/register, /auth/forgot-password, /auth/reset-passwordHTTP 410 Gone + ENDPOINT_RETIRED body
Password resetEmail-based reset-password flow (V57 table)Redirect to Entra SSPR portal (self-service via Microsoft account)
RBAC admin UINo UI; role changes required direct DB queryWeb: Settings > Users page with role dropdown (admin/owner only)

6. Branch Stack (WP1–WP4 Stacked PRs)

WPBranchLatest commitKey files
WP1 — RBAC catalogfeat/rbac-wp1-permissions-catalog890168d (last route commit)V67 migration, PermissionService, BilkoPrincipal, RbacHelper, 17 route files migrated
WP2 — Provisioningfeat/rbac-wp2-user-provisioninga9fa67cV68 migration, UserProvisioningService, UserManagementRoutes
WP3 — Web UIfeat/rbac-wp3-web-entra-ui3c1c019login/page.tsx (Entra CTA), register/page.tsx (retired), admin/users/page.tsx, lib/permissions.ts
WP4 — Retire legacyfeat/rbac-wp4-retire-legacy-auth3ac1388AuthRoutes.kt (5 x 410), api.ts (removed methods), auth-store.ts, 13 new web tests

These branches are stacked and NOT yet merged to main. Production cutover requires a consolidated merge PR after CEO/Securion sign-off.

7. Rollback Procedure

If the consolidated deploy to main needs to be rolled back:

  1. Feature flag path (if FEATURE_ENTRA_AUTH_ENABLED env var is present): set to false to re-enable the password auth provider path (AuthProvider interface, D5 in MC #103075)
  2. Hard rollback: revert to the pre-WP1 commit; Flyway handles down-migration if reversible V67/V68 down scripts were authored (check migration files)
  3. password_hash: column was made nullable in V66 but existing rows retain their hash values — password-based login can be re-enabled without data loss during the rollback window
  4. Password reset tokens table (V57): NOT dropped. Must not be dropped until the rollback window closes (minimum 30 days post-production cutover). See D8 plan in MC #103075
  5. Entra disable: if Entra must be disabled urgently, also disable users in Bilko DB to enforce immediate revocation (7-day refresh window caveat — OC#4)

8. Database Schema Reference

TableKey columnsNotes
usersid, organization_id, email, password_hash (nullable), role (CHECK owner|admin|accountant|viewer), two_factor_*V66: password_hash nullable; V67: role CHECK added
entra_external_identitiesissuer, subject (= oid), user_id, last_login_atV64: created; UNIQUE(issuer,subject), UNIQUE(user_id,issuer); RLS: V66
permissionspermission_key (PK, CHECK format)V67: 52 keys; global catalog, no RLS, GRANT SELECT bilko_app
role_permissionsrole, permission_keyV67: exhaustive flat seed; V68: users:manage + users:invite added

Evidence Files

ADR-037 -- Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred

ADR-037 — Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred

FieldValue
ADR numberADR-037
Date2026-06-08
StatusAccepted
AuthorJohn (AI Director, ALAI Holding AS)
CEO decisionAlem Basic — confirmed 2026-06-07 (CEO resolution addendum, MC #103075)
Related MCsMC #103075 (Entra migration plan), MC #103141–103146 (WP1–WP6 execution), MC #103089 (multi-org, parked)
SupersedesExisting inline requireRole() pattern (pre-WP1)

Context

Bilko had a custom email/password authentication system and a simple numeric role hierarchy (requireRole() inline in route handlers). No permission catalog, no RBAC tables, no admin UI for user management. The CEO decision (June 2026) was to:

  1. Replace email/password authentication with Microsoft Entra External ID (CIAM) — hard REPLACE, not phased coexist
  2. Build a real permission-catalog RBAC system with a DB-backed role-to-permission mapping

Multiple design forks were evaluated by a multi-agent panel (Parisa Tabriz, Martin Kleppmann, Petter Graff, Bruce Momjian, Devils Advocate — MC #103075 forged prompt). Key unresolved tensions: web direct-bearer vs exchange, email-match JIT vs pre-provision-by-oid, roles-in-Entra-claims vs roles-in-Bilko-DB.

Decision

D1 — Identity Provider Boundary

Entra External ID (CIAM) authenticates. Bilko authorises.

D2 — Single Role per User per Organisation (v1)

One role per user per org: owner | admin | accountant | viewer. The role is stored in users.role (single column). Multi-role per user and multi-org membership are explicitly deferred to a separate epic (MC #103089).

Rationale: zero live clients; single-org Entra tenant; keep scope tightly bounded; multi-org requires a organization_members join table and CIAM tenant model decisions that are not yet resolved.

D3 — Permission Catalog in DB; Flat Inheritance Seed

A permissions catalog table (52 keys, resource:verb format enforced by CHECK) and a role_permissions mapping table (V67) replace the inline requireRole() calls. Seed strategy: flat exhaustive rows per role (Strategy A) — no runtime hierarchy derivation. The seed exactly reproduces existing behaviour (no regression — verified by 204 RbacMatrixTest cases).

D4 — Live DB Permission Resolution; Fail-Closed

PermissionService.resolve(role) queries role_permissions at request time. Unknown role resolves to emptySet() (no permissions). BilkoPrincipal carries the resolved permission set. All route-level checks use requirePermission("resource:verb").

D5 — Multi-Org Deferred

Entra CIAM is provisioned as a single tenant. JIT provisioning assigns a new Entra user to one Bilko organisation. Multi-org (one user in multiple orgs) requires: a organization_members join table, per-org permission resolution, and CIAM tenant model decisions. All deferred to MC #103089.

Consequences

Positive

Negative / Trade-offs

Alternatives Considered

AlternativeRejected reason
Roles in Entra claims (Entra app roles)Couples authorisation to IdP; role changes require Entra admin action not Bilko admin action; prevents clean multi-IdP future. Rejected per petter-graff + parisa-tabriz panel consensus.
Phased coexist (email/password + Entra in parallel for 2+ weeks)CEO confirmed hard REPLACE. Panel devils-advocate raised phased coexist as safer; CEO re-confirmed hard REPLACE given zero live users. AuthProvider interface (D5 MC #103075) technically enables a revert if needed.
Denormalised entra_oid on users table (bruce-momjian alternative)Separate-table V64 model kept; enables multi-IdP future; join cost is negligible at current scale. Fork preserved but not resolved — separate-table remains.
ABAC / policy engine (v1)Premature for current scale and requirements; adds complexity; deferred as explicit out-of-scope with comment in plan.

Open Decisions Not Resolved by This ADR

Bilko Self-Serve Trial — CIAM Architecture and Auth Pattern (MC #103232)

Bilko Self-Serve Trial — CIAM Architecture & Auth Pattern

MC: #103232 | Status: LIVE — Proveo 11/11 PASS | Last updated: 2026-06-09 | Securion verdict: LAUNCH WITH CONDITIONS


1. Overview

A prospect navigates to app.bilko.cloud (or bilko-demo.alai.no), clicks "Sign in or create a free account with your email", and completes a Microsoft CIAM Email-OTP sign-up. On first login, the backend JIT-provisions an empty Bilko organisation with a 7-day trial directly on the real production database (bilko-demo-db). There is no separate demo build, no invite-only flow, and no org Microsoft account required — any personal email address works.

The deployment target is the standard bilko-main-deploy semver-tag trigger. Stage and demo share the same Kotlin/Ktor binary and the same database instance (multi-tenant via RLS). The CIAM tenant (bilkociam) is a dedicated Microsoft Entra External ID tenant, completely separate from the Bilko staff Entra tenant.

1.1 Flow diagram

Prospect → app.bilko.cloud/login
         → "Sign in with Microsoft" (MSAL redirect)
         → bilkociam.ciamlogin.com [BilkoSignUpSignIn user flow]
         → Email OTP verification (8-digit code, ~6s delivery)
         → Consent pages (2 pages on first login only)
         → Redirect to app.bilko.cloud/auth/callback
         → MSAL: LOGIN_SUCCESS fires, payload.idToken available
         → POST /auth/entra/session { idToken }   [B1 exchange fix]
         → bilko-api: JWKS RS256 verify → OID lookup → JIT provision
         → Response: Bilko HMAC JWT + org { trialEndsAt }
         → setAuthFromRegistration()              [B1.2 session fix]
         → checkAuth() in-memory JWT fast-path    [B1.3 session fix]
         → /dashboard — empty org, trial active ("Probno: 6 dana preostalo")

2. CIAM Tenant Configuration

PropertyValue
Tenant namebilkociam
Tenant ID20bb17de-9be5-4143-a7e5-8c1ddae6a064
Tenant typeCIAM (Entra External ID)
SPA app nameBilko Web (SPA)
SPA client IDc2902239-ea63-41bd-8619-6cf096d7d45a
API resource app IDfe39e0f5-513e-40af-93f0-c3ee624df56c
Authority URLhttps://20bb17de-9be5-4143-a7e5-8c1ddae6a064.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0
OIDC issuersame as authority URL (confirmed via discovery endpoint)

2.1 User flow: BilkoSignUpSignIn

PropertyValue
Flow IDaa86084b-01dc-453f-9e10-679dfefdd824
TypeexternalUsersSelfServiceSignUpEventsFlow
Display nameBilkoSignUpSignIn
Identity providerEmailOtpSignup-OAUTH (Email One Time Passcode)
isSignUpAllowedtrue
userTypeToCreatemember (not guest)
Attributes collectedemail (auto-filled by OTP verification)
Linked appc2902239-ea63-41bd-8619-6cf096d7d45a (Bilko Web SPA)

Authority URL note: Unlike Azure AD B2C, Entra External ID CIAM does not require a user flow policy name suffix in the authority URL. The BilkoSignUpSignIn flow is applied automatically at the tenant level when the SPA app is linked to it. The deployed authority URL requires no changes.

2.2 Registered SPA redirect URIs

2.3 Adding identity providers or attributes

To add social identity providers (Google, Apple) or additional signup attributes (e.g. display name, company name): Microsoft Entra admin centre → External Identities → User flows → BilkoSignUpSignIn → Identity providers / Attributes. No code changes or redeploys are required for attribute-only changes. Adding a social provider requires app registration on the provider side and linking in the CIAM tenant.


3. Auth Flow — The Hard-Won Pattern

This section documents three bugs that were discovered and fixed during Proveo E2E validation (MC #103232 WS-V). The fixes are canonical — do not revert them.

B1 — Token exchange (commit 660f410, tag v0.2.45)

Problem: MSAL's LOGIN_SUCCESS event fires with an Entra access_token (RS256, Microsoft-issued). The original code set this directly as the API Bearer header. The Bilko API validates HMAC256 JWTs only — all calls returned 401.

Fix: After MSAL fires, pass payload.idToken (not payload.accessToken) to a POST /auth/entra/session { idToken } call. The backend verifies the CIAM RS256 idToken via JWKS, looks up or JIT-provisions the user, and returns a Bilko HMAC JWT.

// apps/web/lib/msal/msal-provider.tsx — corrected token selection
const idToken = payload.idToken ?? payload.accessToken
if (idToken) { handleEntraLogin(idToken) }

// apps/web/lib/msal/use-entra-auth.ts — exchange call
const sessionResult = await api.auth.entraSession(idToken)
const bilkoJwt = sessionResult?.tokens?.accessToken
setAccessToken(bilkoJwt)

B1.2 — Session persistence via setAuthFromRegistration (commit e1e31c5, tag v0.2.46)

Problem: After the B1 exchange, checkAuth() was called to hydrate the store. checkAuth() internally calls POST /auth/refresh using the httpOnly refresh-token cookie. The CIAM exchange path does not set a cookie — so /auth/refresh returned 401, which cleared the Bilko JWT and redirected back to /login.

Fix: Replace the checkAuth() call in handleEntraLogin with setAuthFromRegistration(), which hydrates the Zustand auth store directly from the /auth/entra/session response body. No cookie round-trip needed.

// apps/web/lib/msal/use-entra-auth.ts — hydrate from session response
const { setAuthFromRegistration } = useAuthStore.getState()
setAuthFromRegistration({
  user: sessionResult.user,
  organization: sessionResult.organization,
  tokens: { accessToken: bilkoJwt },
})
// Navigate to /dashboard — Bilko JWT is in-memory Bearer

B1.3 — checkAuth in-memory JWT fast-path (commit 30a8c85, tag v0.2.47)

Problem: Even with B1.2, setAuthFromRegistration() in handleEntraLogin correctly set isAuthenticated=true. However, AuthProvider mounts on every protected route and calls checkAuth(). That call hit /auth/refresh (cookie path) → 401 → store reset to unauthenticated → redirect to /login on every page navigation.

Fix: Added an in-memory JWT fast-path at the top of checkAuth() in auth-store.ts. If a Bilko JWT is already in memory (set via the CIAM exchange), checkAuth() uses GET /auth/me with that Bearer token instead of falling through to the cookie-refresh path.

// apps/web/lib/stores/auth-store.ts — in-memory fast-path
checkAuth: async () => {
  const inMemoryToken = getAccessToken()
  if (inMemoryToken) {
    try {
      const me = await api.auth.me()
      set({ isAuthenticated: true, isLoading: false,
            user: { ...me, name: me.fullName },
            organization: me?.organization ?? null })
      return true
    } catch {
      set({ isAuthenticated: false, isLoading: false, user: null, organization: null })
      return false
    }
  }
  // Original cookie-refresh fallback (unchanged — non-CIAM sessions)
  ...
}

ENTRA_EXTERNAL_ID_AUDIENCE — critical build var (fixed in v0.2.47 trigger update)

Problem: The bilko-main-deploy Cloud Build trigger had _ENTRA_EXTERNAL_ID_AUDIENCE set to fe39e0f5 (the API resource app ID). This is wrong — the CIAM idToken audience is the SPA client ID (c2902239), because MSAL requests id_tokens scoped to the requesting app. Every new deploy reverted the Cloud Run env to the wrong value, requiring a manual patch.

Fix: The trigger substitution was updated:

# infrastructure/gcp/cloudbuild.yaml — correct value
_ENTRA_EXTERNAL_ID_AUDIENCE: c2902239-ea63-41bd-8619-6cf096d7d45a   # SPA client ID
# NOT: fe39e0f5-513e-40af-93f0-c3ee624df56c  (that is the API resource app — wrong for idToken aud)

This is now stable in the trigger — it will not revert on future deploys.


4. Backend JIT Provisioning

4.1 Database migrations (Flyway V66–V69)

MigrationPurpose
V66__entra_rls_and_password_nullable.sqlMakes password_hash nullable (Entra-only users have no password). Adds RLS policy on entra_external_identities (FORCE + fail-closed). Adds CHECK constraint: issuer must not end with trailing slash.
V67__rbac_permissions_catalog.sqlRBAC permissions catalog seeding.
V68__rbac_user_provisioning.sqlSECURITY DEFINER function bilko_auth.provision_user_with_org(): creates org (7-day trial, trial_starts_at, trial_ends_at = now() + 7 days), creates user (role='viewer', password_hash=NULL), inserts entra_external_identities row (issuer, OID, user_id). Default: country='BA', currency='BAM'.
V69__fix_provision_rls.sqlRLS fix: calls set_config('app.current_org_id', v_org_id, true) before the users INSERT so that RLS policies on the users table pass during JIT provisioning.

4.2 JIT provisioning call flow

POST /auth/entra/session { idToken }
  → EntraExternalIdService.verifyIdToken()         [RS256, JWKS, issuer+audience+exp]
  → AuthUserRepository.findByEntraIdentity(issuer, oid)  → null (new user)
  → AuthUserRepository.findByEmail(email)                → null (no existing Bilko account)
  → UserProvisioningService.provisionNewUserForEntra()
      → bilko_auth.provision_user_with_org()  [SECURITY DEFINER, SERIALIZABLE]
          → INSERT organizations (trial 7 days)
          → INSERT users (role=viewer, password=null)
          → INSERT entra_external_identities (issuer, oid)
  → jwtService.signAccessToken(userId, email, role='viewer', orgId)
  → Response: { user, organization { trialEndsAt }, tokens { accessToken, refreshToken } }

Idempotency: Re-login with the same OID returns the existing user and org; trial end date is not reset. The entra_external_identities table has a UNIQUE constraint on (issuer, subject).

4.3 trialEndsAt in /auth/me

The GET /auth/me response includes organization.trialEndsAt (ISO 8601). The frontend auth store exposes this on the Organization interface. The trial expiry is enforced server-side by TrialGatePlugin which queries the DB on every gated request — the JWT does not embed expiry.

4.4 RLS isolation

All JIT-provisioned tenants are isolated via PostgreSQL Row Level Security. The app.current_org_id session variable is set by OrgScopePlugin from the BilkoPrincipal (JWT-derived, not from any HTTP header). orgTransaction() uses SET LOCAL scoped to the transaction — connection pool does not carry state between requests. Cross-tenant isolation is verified by RlsOrgIsolationV46IntegrationTest.


5. Deploy

PropertyValue
Deploy triggerbilko-main-deploy (europe-north1, project tribal-sign-487920-k0)
Trigger typesemver tag on main: git tag vX.Y.Z && git push origin vX.Y.Z
Configinfrastructure/gcp/cloudbuild.yaml
Current live tagv0.2.47 (commit 30a8c85)
Web revisionbilko-web-demo-00080-tq5
API revisionbilko-api-demo-00155-524
CIAM env vars in triggerNEXT_PUBLIC_ENTRA_CLIENT_ID, NEXT_PUBLIC_ENTRA_AUTHORITY, NEXT_PUBLIC_ENTRA_SCOPE, ENTRA_EXTERNAL_ID_ISSUER, ENTRA_EXTERNAL_ID_AUDIENCE (= c2902239), ENTRA_EXTERNAL_ID_JWKS_URL

ZAKON PI2: Do not run cloudbuild.yaml manually. Use git tag + git push origin only. The stage pipeline (bilko-stage-auto-deploy) fires on every push to main and is unrelated to the demo deploy.


6. Known Follow-Ups

IDPriorityDescription
H1 HIGH — must-fix before scale launch Abuse gate (MC #103245): JIT provisioning has no server-side rate gate on tenant creation. An attacker with many email inboxes can script CIAM sign-ups (each requires a real OTP but automation services exist). Fix: add a platform-level provision rate gate in UserProvisioningService.provisionNewUserForEntra() (max N JIT orgs per hour) + CIAM tenant configuration to block disposable email domains.
B3 MEDIUM Migadu email OTP blocking: Migadu (one.com), used for @alai.no, blocks Microsoft Azure CIAM OTP emails. Prospects with Gmail or Outlook receive OTP in ~6 seconds. Alai staff using @alai.no addresses cannot sign up. Fix: whitelist accountprotection.microsoft.com sender in Migadu SPF settings, or configure a custom CIAM email sender domain.
UX-1 LOW Org display name: JIT-provisioned orgs are named "unknown's Organization" (no display name collected at signup). The user flow only collects email. Fix: add displayName to the BilkoSignUpSignIn attribute collection (Azure config, no code change), or collect it on first post-login screen.
UX-2 LOW Default country/currency: JIT-provisioned org defaults to country='BA', currency='BAM'. Prospects outside Bosnia must update via Settings. A country selection step at signup would improve the onboarding experience (follow-on, not a blocker).
M1 MEDIUM INGRESS_TRAFFIC_ALL (MC #99924): Direct *.run.app access bypasses GCLB, which degrades IP-based rate limiting to per-GFE-region keying. Pre-existing risk, not introduced by CIAM. Fix: lock ingress to internal-only when load balancer is provisioned.
M3 MEDIUM No alert on rapid tenant creation: Add a GCP Cloud Monitoring alert triggering when more than N organisations are JIT-provisioned per hour.

7. Validation Evidence

Proveo — 11/11 PASS (v0.2.47, 2026-06-09T02:55Z)

Real Gmail sign-up (alembasic@gmail.com) end-to-end on bilko-demo.alai.no:

StepResultDetails
1PASSSelf-serve copy present; "Contact your administrator" absent
2PASS"Sign in with Microsoft" → ciamlogin.com (tenant 20bb17de) redirect
3PASSEmail entered on CIAM; OTP sent immediately
4PASSReturning user — OTP sent directly (no create-account needed)
5PASS8-digit OTP (17717965) received via Gmail UID:75644 in 7 seconds
6PASSRedirect back to bilko-demo.alai.no/dashboard
7PASSPOST /auth/entra/session → 200, Bilko HMAC JWT, org 4e96b6ff confirmed
8PASS/dashboard with trial UI ("Probno: 6 dana preostalo"), /auth/me → 200 + trialEndsAt 2026-06-15
9PASS/invoices via SPA nav — empty org (0 invoices), session alive
10PASS/invoices/new — invoice form visible, trial tenant usable
11PASSRegression clear — admin wall absent, self-serve copy confirmed

Zero /auth/refresh calls during SPA navigation after B1.3 fix (confirmed by network capture count=0). Cross-tenant RLS: org 4e96b6ff shows 0 invoices and 0 BAM balances (no data from other tenants).

Securion — LAUNCH WITH CONDITIONS


8. DEPLOY-MAP Reference

The CIAM substitutions live in infrastructure/gcp/cloudbuild.yaml under the bilko-main-deploy trigger. The Cloudflare Turnstile entries in DEPLOY-MAP.md cover the marketing landing forms and are unrelated to the CIAM auth flow. No DEPLOY-MAP.md changes are required for the CIAM self-serve trial feature — the trigger substitutions are already updated.

Do not add CIAM secrets to the DEPLOY-MAP secrets table — these are build-time substitutions injected directly from the trigger, not GCP Secret Manager secrets.


9. Environment Variables Reference

VariableServiceCorrect value note
NEXT_PUBLIC_ENTRA_CLIENT_IDbilko-web-demo (build-time)c2902239-ea63-41bd-8619-6cf096d7d45a (SPA app)
NEXT_PUBLIC_ENTRA_AUTHORITYbilko-web-demo (build-time)https://[tenant-id].ciamlogin.com/[tenant-id]/v2.0 — no user flow suffix needed
NEXT_PUBLIC_ENTRA_SCOPEbilko-web-demo (build-time)api://fe39e0f5.../access_as_user
ENTRA_EXTERNAL_ID_ISSUERbilko-api-demohttps://[tenant-id].ciamlogin.com/[tenant-id]/v2.0
ENTRA_EXTERNAL_ID_AUDIENCEbilko-api-democ2902239-ea63-41bd-8619-6cf096d7d45a (SPA client ID — NOT the API resource ID)
ENTRA_EXTERNAL_ID_JWKS_URLbilko-api-demohttps://[tenant-id].ciamlogin.com/[tenant-id]/discovery/v2.0/keys

Critical: ENTRA_EXTERNAL_ID_AUDIENCE must be the SPA client ID (c2902239), not the API resource app ID. MSAL requests id_tokens with the SPA client as audience. If set to the API app ID, the backend rejects every CIAM idToken with audience mismatch.


Page created by Skillforge (MC #103232 WS-D, 2026-06-09). Source evidence: /tmp/evidence-103232/. Validation: Proveo 11/11 PASS + Securion LAUNCH WITH CONDITIONS.

AI Support Agent — bilko.cloud (HR)

AI Support Agent — bilko.cloud (HR)

Status: Phase 1 operational (internal copilot); Phase 2 customer-facing widget HELD for Securion review
MC Task: #104429
Owner: John → CodeCraft (Petter Graff lead)
Built: 2026-06-28

1. Purpose & Scope

The Bilko Cloud AI Support Agent is a dedicated support system for the bilko.cloud (Croatia) jurisdiction of the Bilko SaaS platform. It handles both technical problems (login/Entra SSO, e-invoice/OIB failures, RLS, deploy errors) and user/accounting questions (PDV rates, HR-FISK/eRačun, invoices, bookkeeping, general ledger) — grounded in the Bilko knowledge base, validated by HR accounting domain experts, and gated by anti-hallucination verification.

Phased deployment:

Jurisdiction: Croatia only. The agent reasons under HR tax/accounting rules and answers in Croatian (hr-HR).

Cost target: $0 per question (runs on local tier-2 models: Qwen 2.5 Coder 32B / MLX).

2. The Team — 5 Agents

RoleAgentRegistryJob
Lead architectPetter Graff~/.claude/agents/petter-graff.mdOwns architecture, gates phases, integration design
Research (Phase 0)Datavera / Exploreephemeral dispatchesHR support taxonomy, competitor patterns, KB extraction (completed)
Support personabilko-support~/.claude/agents/bilko-support.mdOrchestrates answer pipeline, enforces anti-hallucination, escalation
HR accounting expert #1bilko-racunovodstvo-hr~/.claude/agents/bilko-racunovodstvo-hr.mdDvojno knjigovodstvo, RRiF chart of accounts, GL, expenses, financial statements (RDG/bilanca); KB author + validation gate
HR tax/fiscalization expert #2bilko-porez-fiskalizacija-hr~/.claude/agents/bilko-porez-fiskalizacija-hr.mdPDV (25/13/5/0%), HR-FISK 2.0/eRačun (UBL 2.1/EN 16931), OIB validation, FINA, filing deadlines; KB author + validation gate

All three personas (bilko-support + 2 experts) registered in ~/system/agents/specialist-mapping.json:

3. Architecture — Answer Pipeline

Orchestrator: ~/system/tools/bilko-support-answer.js (305 lines, CLI + module.exports).

Pipeline (8 steps, deterministic):

question (+ contextBundle: errorCode, appRoute, country=HR, planTier, orgId…)
 ↓
 [1] classify + route         tier-router.js (local $0 model)
 ↓
 [2] cache check              rag-router.js (flywheel.db hit? early return)
      ⮑ if cached + confidence >= 0.70 (or not acct/tax/e-invoice) → DONE
      ⮑ if cached BUT classification ∈ {accounting, tax, e-invoice}
          AND confidence < 0.70 → escalate:true, return
 ↓
 [3] retrieve context         KB JSONL search (40 Q&A pairs)
                              + LightRAG hybrid (knowledge.db)
                              + HiveMind semantic (hivemind.db, fire-and-forget)
 ↓
 [4] draft answer (Croatian)  tier-2 local model (Qwen/MLX)
 ↓
 [5] anti-hallucination gate  mini-verifier.js
      ⮑ HALLUCINATION (cited file missing/empty) → escalate
      ⮑ DRIFT (file present but ambiguous/language mismatch) → reduce confidence
      ⮑ CONFIRMED (grounded) → boost confidence
      ⮑ SKIP (verifier offline) → neutral
 ↓
 [6] accounting validation    if classification ∈ {accounting, tax, e-invoice}:
      ⮑ re-verify vs domain-specific KB (hr-tax-fiscalization, hr-accounting sources)
      ⮑ if no domain KB → escalate
      ⮑ if HALLUCINATION or CONFIRMED < 0.70 → escalate
 ↓
 [7] confidence floor gate    belowAccountingFloor(cls, confidence)
      ⮑ if acct/tax/e-invoice AND confidence < 0.70 → escalate:true
      (same helper used on BOTH cache path [L139] and pipeline path [L258])
 ↓
 [8] cost log + return        {answer, confidence, evidence_paths,
                               classification, severity, escalate, source}

Output schema:

{
  "answer": "",
  "confidence": 0.0–1.0,
  "evidence_paths": [""],
  "classification": "technical | accounting | tax | e-invoice | billing | onboarding | other",
  "severity": "P1 | P2 | P3 | P4",
  "escalate": boolean,
  "source": "cached | local | escalated"
}

4. Escalation Policy

Hard rule (enforced in code): ACCOUNTING_CONF_FLOOR = 0.70

Any answer with classification ∈ {accounting, tax, e-invoice} AND confidence < 0.70 MUST escalate to a human specialist (or to bilko-racunovodstvo-hr / bilko-porez-fiskalizacija-hr personas in async workflow).

Escalation triggers:

Single source of truth: The belowAccountingFloor(cls, confidence) function (line 62) is called on BOTH the cache early-return path (line 139) and the post-step-6 gate (line 258) — guarantees no drift in threshold logic.

5. Knowledge Base — 40 Source-Cited Q&A Pairs

Storage (current): JSONL files read directly by orchestrator; ingestion into lightrag/knowledge.db is a pending enhancement.

Sources cited:

Each entry has: {q, a, source, confidence: "HIGH" | "MEDIUM", domain: "hr-tax-fiscalization" | "hr-accounting"}.

Confidence levels:

6. Integration with Bilko Backend

Existing infrastructure (reused, not built for this):

Phase 1 workflow:

  1. Staff create/triage ticket via ~/system/tools/support-ticket.js (SLA P1–P4)
  2. Call node ~/system/tools/bilko-support-answer.js "" ''
  3. Agent returns JSON with answer + evidence + classification + escalate flag
  4. Human reviews, approves, sends to customer (via email / admin panel)

Phase 2 workflow (planned, HELD):

  1. Customer opens in-app chat widget on bilko.cloud
  2. Widget calls POST /support/tickets with context_bundle
  3. Backend triggers bilko-support-answer.js (via internal job queue or sync call)
  4. Agent output written to triageJson field: {suggestedAnswer, classification, severity, escalate, confidence, evidencePaths}
  5. Render in UI: if escalate:false AND confidence >= 0.70 → show AI answer with citation; else → "Eskaliran na podršku, očekujte odgovor u "
  6. Guardrails: PII allowlist (already enforced), prompt-injection guard (add Securion layer), scope validation (no cross-org queries)

Security note: Phase 2 requires Securion/Parisa Tabriz security review before live deployment (MC follow-up task).

7. Product Bugs Found by Experts (During KB Build)

The 2 HR accounting experts found 8 Bilko product/documentation defects while authoring the knowledge base:

MCSeverityIssue
#104441P3Bilko MVP GL: only 1 D/P per transaction → PDV not split to konto 450 Obveze za PDV (tracking via reports only, not GL)
#104446P3OIB validation: CROATIA-ERACUN.md missing ISO 7064 MOD 11,10 detail (in code, not doc)
#104447P2CoA setup friction: not available in wizard, only post-onboarding in Settings (USER-ONBOARDING.md gap)
#104442P3Storno logic: cannot cancel paid invoices (needs credit note, not auto-generated in MVP)
#104448P4BUSINESS-LOGIC.md: HR PDV table lists only 25%/13%, omits 5%/0% (available via free input, just not in doc table)
3 additional minor doc inconsistencies logged

These are now tracked separately and do not block the support agent (KB answers work around them with "Bilko MVP limitation" disclaimers where needed).

8. Runbook — How to Invoke

CLI:

node ~/system/tools/bilko-support-answer.js "<question>" '<contextBundleJSON>'

Example:

node ~/system/tools/bilko-support-answer.js \
  "Koja je stopa PDV-a na knjige u Hrvatskoj?" \
  '{"country":"HR","planTier":"PRO","locale":"hr-HR"}'

Output (JSON to stdout):

{
  "answer": "Stopa PDV-a od 5% primjenjuje se na knjige...",
  "confidence": 0.78,
  "evidence_paths": ["/Users/makinja/.../CROATIA-ERACUN.md"],
  "classification": "tax",
  "severity": "P3",
  "escalate": false,
  "source": "local"
}

Module API:

const { answer } = require('~/system/tools/bilko-support-answer');
const result = await answer(question, contextBundle);

Cost tracking (automatic): Each call logs to ~/system/tools/cost-tracker.js with {source: 'bilko-support-agent', backend: 'ollama', model: '...', cost_usd: 0, duration_ms, metadata}.

9. Open Follow-Ups

MCTaskOwner
#104432Proveo UAT — end-to-end verification: empty-KB degradation, offline-model fallback, severity classification coverage, schema validation, real ticket replayProveo / Angie Jones
#104430Phase 1 deployment — staff onboarding, support-ticket.js workflow integration, SLA monitoringFlowForge / Kelsey Hightower
#104431Phase 2 customer-facing widget — frontend component, POST hook, guardrails (HELD pending Securion)Vizu / Brad Frost + Securion / Parisa Tabriz
(backlog)KB ingestion into lightrag/knowledge.db (enhancement: replace JSONL direct-read with Neo4j-backed hybrid retrieval)AgentForge / Chip Huyen
(backlog)Securion review: confidence thresholds, cache-of-escalated-answers audit, PII leak surface, prompt-injection testsSecurion / Parisa Tabriz

10. Verification Evidence

MC #104429 verdict: PASS (2026-06-28, john verified)
Method: Company-Mesh (CodeCraft built, John verified via real CLI runs)
Evidence: ~/system/evidence/104429/verdict.json

Verified behaviors:

2 under-escalation defects caught & fixed: Sub-0.70 tax answers on both pipeline AND cache paths were initially missing the confidence floor check; John's independent runs caught these, and CodeCraft fixed them via the shared belowAccountingFloor() helper.


This page is the deliverable for MC #104433 (ZAKON PLAN mandatory documentation task). Last updated: 2026-06-28.

Bilko HR — Inbound e-Invoice Reject + Monthly Rejection eIzvještavanje (MC #106149 / #106146)

MC #106149 (reject action, M) + #106146 (monthly eIzvještavanje report, H, RED-ZONE HR tax/compliance). Coupled/parent #106145. Status as of 2026-07-21: built and tested in an isolated worktree, NOT merged or pushed — no PR opened yet. This page documents the built state for handoff / next-session continuation.

What was built

IMPORTANT — N/U/O is Bilko's own taxonomy, NOT Porezna's

The domain gate (agent bilko-porez-fiskalizacija-hr) caught and corrected a first-draft modeling error before this shipped:

Full domain-gate detail: ~/system/evidence/106149/domain-gate-verdict.md

Schema

MigrationContents
V140__received_einvoice_reject_reason.sqlAdds to received_einvoice: reason_code CHAR(1) CHECK IN ('N','U','O') (Bilko-internal taxonomy, see above), rejected_at, report_transmission_status CHECK IN ('pending','sent'), report_transmitted_at, report_period. Does not edit V139.
V141__rejection_report_rls_bypass.sqlTwo bilko_auth SECURITY DEFINER functions — find_rejected_einvoices_for_period, mark_einvoice_report_transmission — plus GRANT SELECT, UPDATE ON TABLE public.received_einvoice TO bilko_admin. Same ADR-017 Pattern B shape as V139's own find_org_by_registration_number.

Lessons — RLS/SECURITY DEFINER pattern has now recurred 6x (V33/V39/V100/V111/V139/V141)

Two real bugs were caught live by the Testcontainers integration test (real Postgres + full V1..V141 Flyway chain, no SchemaUtils/mocks) during this build — not assumed, not pre-empted from memory:

  1. Cross-org RLS blind spot. RejectionReportService's monthly batch query initially used a raw Exposed transaction{} against received_einvoice (FORCE ROW LEVEL SECURITY, V139) with no app.current_org_id set. This silently returned ZERO rows for every org, every time — not an error, because RLS here is fail-closed-permissive: the query "succeeds" with an empty result set. Fixed via V141 SECURITY DEFINER functions.
  2. SECURITY DEFINER without table GRANT. After adding the SECURITY DEFINER functions, they still failed with "permission denied for table received_einvoice" — bilko_admin has BYPASSRLS (V30_1/V32) but is not superuser and never received an explicit GRANT on this specific table (V139 only granted bilko_app). Fixed by adding the GRANT in V141, following the exact precedent already set by V33/V39/V100/V111 for other tables bilko_admin needed to read via SECURITY DEFINER functions.

Callout for future FORCE-RLS table + batch/admin-query work: any new table with FORCE ROW LEVEL SECURITY that needs a cross-org admin/batch read path will hit both of these unless it (a) goes through a SECURITY DEFINER function from the start, and (b) that function's owning role has an explicit table GRANT. This is now a 6-occurrence pattern — worth turning into a migration checklist item or lint rule rather than re-discovering it per-feature.

What's explicitly NOT done

Verification performed

Branch / merge status

Next steps

  1. Human/browser session to pull field-level schema from porezna-uprava.gov.hr/fiskalizacija/api/dokumenti/157 (or FINA XSD) before implementing real transmitToPorezna().
  2. Proveo end-to-end verification (reject → report, real evidence).
  3. Open PR: feat/task-106162-inbound-einvoice-persist → main, then feat/task-106149-reject-report → main (or squash/rebase as one PR chain).
  4. FlowForge: wire cron trigger for POST /internal/compliance/rejection-report.
  5. mc.js ready for #106149/#106146 after Proveo pass.

Source evidence

MC #106259 — Bilko Admin Portal Contract Fix

MC #106259 — fix for 4 contract defects found by Angie Jones' platform_admin UAT catalog (MC #106258). Backend DTOs, frontend types/guards, and one missing route were brought back in sync so the admin portal stops crashing and stops showing wrong data.

Defects fixed

#SeverityDefectFix
1 H GET /admin/dashboard returned 404 — no handler existed anywhere server-side despite being called on first admin page load and documented in the OpenAPI spec. Likely the first thing CEO saw fail on /admin. Implemented in AdminPortalRoutes.kt (get("/dashboard") inside the existing route("/admin") block, same requirePlatformAdmin() gate as every other admin route). AdminOrgService.getDashboardStats() added with real DB aggregates: totalOrgs (COUNT non-deleted orgs), activeTrials (BASIC plan + trial not expired), recentAudit (audit_log LEFT JOIN users, DESC limit 10), mrr (explicit 0.0, see limitation below).
2 H /admin/orgs/{id} detail page crashed on every org. Backend OrgDetail DTO never set mrr, so Jackson omitted the key entirely; frontend called Intl.NumberFormat().format(org.mrr)TypeError: Cannot convert undefined to a number, tripping the global Next.js error boundary. This is almost certainly the "admin stranice puca" CEO reported. OrgDetail DTO gained mrr: Double (always explicit, never omitted), plus vatMethod/trialStatus. Frontend defense-in-depth: all three MRR render sites (orgs/[id]/page.tsx, orgs/page.tsx, admin dashboard page.tsx) now guard == null before formatting, not just the one crash site.
3 M OrgListItem (the /admin/orgs list) was missing vatMethod, trialStatus, userCount, mrr even though the frontend TS interface declared them as required. Effect: VAT Method column always "—", Users column always blank, and every org — trial or paid — rendered with the green "paid" badge since trialBadge(undefined) silently fell through. OrgListItem gained all four fields. trialStatus derived via new resolveTrialStatus(planTier, trialEndsAt) (read-model projection only — does not touch TrialGatePlugin/TrialService enforcement). userCount is one batched query per page (grouped/counted in one round-trip), not N+1 per row.
4 L Pagination/total count silently broken. Backend has always returned nested {data, meta:{total,page,perPage,totalPages}}; the frontend AdminOrgsListResponse/AdminOrgUsersResponse TS types declared a flat shape ({data,total,...}). Result: header count never rendered, and pagination controls would silently never appear once org count exceeded the 50-per-page limit (untriggered today at 9 orgs, but a live landmine). Frontend types changed to nest under meta: AdminOrgsPaginationMeta (chosen over flattening the backend, to avoid touching other PaginatedResponse consumers). Call sites in orgs/page.tsx updated to read data.meta.total / data.meta.totalPages.

Branch / commit

Test results

Known limitation

mrr is returned as an explicit 0.0 everywhere (dashboard, org detail, org list) — not fabricated, but not real. There is no Stripe MRR aggregation anywhere in the Bilko codebase today: stripeSubscriptionId is stored per-org but only ever resolved for trial-gate checks and billing/webhook flows, never rolled up into an aggregate revenue figure. Wiring a real MRR source (either a live Stripe API call per org, or a synced mrr_amount column updated via webhook) is separate, larger follow-on work. Flagged in code comments on the 3 DTOs (OrgDetail, OrgListItem, dashboard stats) for whoever picks that up next.

Deliberately not touched

Follow-on: deploy is separate

This ticket covers code + test correctness only (worktree/local verification). PR → CI → merge to azdo/main → stage/demo promotion is a separate follow-on step, not yet done as of this page. Do not treat this fix as live on stage/demo until that pipeline runs and is verified (curl/browser check on the actual environment, per ZAKON PI2).

Evidence

Deep-Link Redirects Survive Login — Mechanism, Consumers, Open Gaps (MC #106384)

Deep-Link Redirects Survive Login — Mechanism, Consumers, and Open Gaps

MC: #106384 | Status: LIVE on azdo/main (merge commit 480d95ca, PR 272) | Verified against: azdo/main @ 50e4223a (2026-07-28) | Independent peer-verify verdict: PARTIAL (angie-106384-peer) | Last updated: 2026-07-28

One-line summary: a user hitting a private URL while logged out is now returned to that exact URL after signing in — except on one landing (an already-open bug, #106423) — where before this fix the redirect parameter was written by middleware and read by nothing, so every login silently discarded it.


1. The problem this replaced

Before this fix there were two independent defects, both required to explain the reported symptom (CEO clicks an emailed link like /expenses/new?type=purchase, has to log in, lands on /dashboard instead):

  1. Half 1middleware.ts built the login-redirect URL from the request pathname only. The query string was silently dropped before the user ever saw the login screen.
  2. Half 2 — even where a redirect parameter did survive, nothing in the app read it. The login success handler hardcoded router.replace('/dashboard'). A parameter that is written and never read is a control that renders and does nothing — the same class of defect as the dead filters/dead locale switcher this audit program was raised to find.

Fixing only those two would still not have worked: a real Entra sign-in does not return to the URL the user was on at all (see §2). The shipped fix (PR 272, two witness rounds + one independent post-merge peer-verify) addresses five loss points end to end. This page documents the mechanism as it exists in the code today, not as originally scoped.


2. End-to-end mechanism

GET /expenses/new?type=purchase                      (unauthenticated)
  → middleware.ts:93  builds /login?redirect=%2Fexpenses%2Fnew%3Ftype%3Dpurchase
                      (carries PATHNAME + SEARCH — this is the Half-1 fix)
  → user clicks "Sign in with Microsoft"
  → use-entra-auth.ts:214  signInWithMicrosoft() reads the CURRENT ?redirect= param
                      and stashes it in sessionStorage — the LAST moment it still exists
  → instance.loginRedirect() hands control to Entra
  → https://<ciam>/authorize?...&redirect_uri=https://app.bilko.cloud&response_mode=fragment
                      (bare origin, no path, no query — see §2.1)
  → back to https://app.bilko.cloud/#code=...
                      window.location.search is EMPTY here — the query param is gone for good
  → use-entra-auth.ts:170  handleEntraLogin() calls resolvePostLoginPath(...),
                      which falls through the (now-empty) query param to the STASH
  → router.replace('/expenses/new?type=purchase')   ← the deep link, recovered

2.1 Why the query param cannot survive the Entra round trip

MSAL is configured (msal-config.ts) with redirectUri defaulting to the bare page origin, navigateToLoginRequestUrl: false, and CIAM returns the auth code in the URL fragment (response_mode=fragment), not the query. This was measured live, credential-free, against production: the authorize request the app actually sends to Entra carries no path and no query, and the browser lands back on https://app.bilko.cloud/#code=... with window.location.search === ''. Any code that reads only window.location.search on the way back — which is exactly what the first version of this fix did — will always fall through to the dashboard fallback, regardless of how carefully the query param was carried on the way in. This was the fifth loss point found in witness round 1 and is why the stash (§3) exists at all.


3. The stash — surviving the round trip

apps/web/lib/safe-redirect.ts defines the mechanism:

PropertyValue
Storage keybilko_post_login_redirect (STASH_KEY, safe-redirect.ts:92)
Storage mechanismsessionStorage, not localStorage — survives a full-page redirect in the same tab, dies with the tab, matches MSAL's own cacheLocation (also sessionStorage, chosen for XSS reasons)
WrittenstashRedirectTarget() — one call site, use-entra-auth.ts:214, inside signInWithMicrosoft(), immediately before loginRedirect()
TTL10 minutes (STASH_TTL_MS, safe-redirect.ts:96) — long enough for a sign-in, short enough that a stale value cannot hijack an unrelated later login in the same tab
Sanitizedon write (invalid target is never stored) AND on read (a stored value is not trusted more than a URL param just because the app put it there)

3.1 Why the read is deliberately non-consuming

readStashedRedirectTarget() (safe-redirect.ts:129) does not delete the key when it reads it. This is deliberate and load-bearing, not an oversight: there are two consumers that both run on the way back from Entra (handleEntraLogin and the auth-provider.tsx safety net, §4), and their execution order is not guaranteed. If the first reader to run cleared the stash, the second would find nothing, fall through to /dashboard, and could overwrite the correct navigation the first reader just made. Both readers see the same answer instead. The value is retired by three other means: the next sign-in click (which unconditionally overwrites or clears it before any navigation happens), logout (§5), and the 10-minute TTL.


4. Two consumers of resolvePostLoginPath — one fixed, one still open

resolvePostLoginPath(params) = readRedirectTarget(params) ?? readStashedRedirectTarget() ?? '/dashboard' (param-first, see §5). There are exactly two call sites in the app that ever consulted the stash-fallback version. A third call site (/demo) was later re-scoped to stop using the fallback — see §4.1.

ConsumerFile:lineScoped to genuine post-Entra return?
handleEntraLoginlib/msal/use-entra-auth.ts:170YES — this function only runs after MSAL fires LOGIN_SUCCESS
Authenticated-landing safety netlib/auth-provider.tsx:109NO — see §4.2, this is the open defect

4.1 /demo — re-scoped, does NOT use the stash

Correction to the originating tickets: the instant-demo page lives at apps/web/app/(auth)/demo/page.tsx, not under a (dashboard) route group as earlier ticket text said. The code is the source of truth here.

demo/page.tsx was the sixth loss point: it hardcoded router.replace('/dashboard'), so the journey deep link → /login?redirect=... → instant sign-in → /demo?...&redirect=... still landed on /dashboard even once the Entra path was fixed, because the cross-domain hop to /demo forwards the whole query string and the page just wasn't reading it.

The fix (demo/page.tsx:190) is not resolvePostLoginPath(searchParams) — it is readRedirectTarget(searchParams) ?? DEFAULT_POST_LOGIN_PATH, i.e. it reads only its own query params, never the stash. This distinction was found the hard way: a first attempt used the stash-fallback version and a witness proved live that an abandoned sign-in leaves a target in the stash, and a later /demo?country=HR visit in the same tab, inside the 10-minute TTL, silently followed the old target instead of going to the demo the user just asked for — a journey where that version of the branch was worse than main. /demo has no Entra redirect of its own to survive, so it never needs the stash; it already carries the param whenever one exists.

4.2 OPEN DEFECT — the safety net was never re-scoped (MC #106423)

The independent post-merge peer-verify (angie-106384-peer, verdict PARTIAL) found that /demo was fixed on the surface where the stash-leak bug was found, but auth-provider.tsx:90-114 — the "authenticated user landing on / or /login" safety net — is the other caller of resolvePostLoginPath and was not given the same treatment. It still falls back to the stash for any authenticated landing on / or /login, not only a genuine post-Entra return.

Proven by rendering the real AuthProvider component directly (not by reading the code): with an already-authenticated client state, a plain navigation to / with no query param, and a stash pre-seeded from an earlier sign-in, router.replace fired with the stale stashed target instead of leaving the user on /.

Reachability, honestly scoped narrower than the original N1 finding: because the stash is non-consuming by design (§3.1), even a fully successful sign-in leaves the target sitting in sessionStorage for up to 10 more minutes. There is no in-app link to bare / from inside the authenticated dashboard shell, so this requires the user to navigate to the site root directly (URL bar, bookmark, external link) within that window while a stash from their own earlier sign-in — completed or abandoned — is still live. Real and user-visible, but a specific navigation habit rather than the default customer-facing path /demo is.

Why no test caught this: no test in the entire suite renders AuthProvider or drives handleEntraLogin — flagged as a residual (R6) in the very first witness round and never closed. The fix that scoped /demo correctly (§4.1) left this sibling caller untouched because nothing exercises it.

Status: OPEN, tracked as MC #106423. The prescribed fix is the same one-line treatment /demo got: this effect should read only whether it is landing here as a genuine post-Entra return (empty search, code in the fragment) versus any other authenticated landing on / or /login, and only consult the stash for the former — plus a test that actually renders AuthProvider, so this class of gap stops being invisible to the suite.


5. Why the cleanup lives in authStore.logout(), not signOutFromEntra

This is the single most useful fact on this page for whoever touches this code next.

use-entra-auth.ts defines and exports a function called signOutFromEntra (line 250) which does the "correct" full teardown: it calls the real logout, clears the marker cookie, clears the locale choice, calls clearStashedRedirectTarget() (line 271), and finally calls MSAL's logoutRedirect to end the Entra session itself. Reading that function in isolation, it looks like the obvious place the stash gets cleaned up.

It is not called anywhere. Grepping lib, app, components for signOutFromEntra at azdo/main today returns exactly: the interface member declaration, the function definition, and the object literal that exports it from the hook. Zero call sites. The two logout paths a user can actually reach in this app are:

Neither of those goes anywhere near signOutFromEntra. So while this fix was being built, the first version of the cleanup was written correctly and placed in signOutFromEntra — and reproduced, inside its own fix, the exact defect class the fix exists to kill: a control that reads correctly and has no consumer. The original bug was a redirect query param nobody read; the accidental recreation was a cleanup function nobody called.

The actual fix: clearStashedRedirectTarget() was moved into lib/stores/auth-store.ts:233, inside logout() itself — the one function both real logout paths in the app call through. That is confirmed reachable from both buttons. The lesson generalises past this one bug: for any fix of this shape, enumerate every caller of the mechanism, including teardown — not just the one the bug report happened to describe.

What this means for signOutFromEntra today: it is dead code on a cleanup path, tracked separately as MC #106421, still open. That ticket deliberately does not conclude "delete it" — it asks whether Bilko's real logout is supposed to also end the user's Microsoft/Entra session (the way signOutFromEntra's logoutRedirect call would do) and currently does not, because nothing calls the function that would do it. If that's the case, the actual bug is that signOutFromEntra is not wired up — a possible security-relevant gap (Entra SSO session surviving a Bilko logout on a shared machine), not simply dead code to delete. That question is still open.


6. Param-first on the return leg — the decision, and its residual

resolvePostLoginPath checks the URL query param before the stash. This was deliberately re-examined (not just carried over) after a reviewer asked whether a fresh param should ever lose to an old stash, and the answer is: it depends which leg you're on.

Param-first was kept on the reasoning that preferring a 10-minute-old stash over a just-expressed navigation would generalise the exact bug found in the /demo case (§4.1) rather than fix anything.

Residual, stated plainly: this reasoning holds only while every writer of the redirect query param is writing genuine, current user intent. Today there are exactly two writers — middleware.ts:93 and auth-provider.tsx:126 — and both were re-checked and confirmed to derive the value from the path the user was actually just bounced from. If a future change adds a third writer of that param for some other purpose, or repurposes one of the two existing writers, param-first silently becomes the wrong rule on this leg. Re-verify this list before trusting the rule again.


7. What is NOT verified live

The real browser round-trip through Entra — redirect_uri, the fragment-mode code return, and sessionStorage surviving the cross-origin navigation to the CIAM host and back — has never been observed by us with a real, credentialed sign-in. There are no Entra credentials in the agent environment this work was built and reviewed in.

What was observed live, credential-free, and should not be conflated with the above:

What remains genuinely unobserved: a successfully authenticated user's landing page after a real password/consent flow. That half of the original defect is verified in code and by component-level tests, not by watching it happen. Do not present this mechanism as fully live-verified — it is proven live up to the boundary Entra credentials impose, and proven in code beyond that boundary.



9. Source

Verified against azdo/main @ 50e4223a (2026-07-28), which includes the merge commit 480d95ca (PR 272, MC #106384).

Evidence: ~/system/evidence/106384/fix-verdict.md, witness-verdict.md (two rounds), peer-verify-verdict.md (independent, post-merge, verdict PARTIAL).