Backend
- Backend — Target Architecture
- Database — Schema & Models
- API Reference
- Database Schema
- Authentication & Authorization
- Business Logic
- Middleware Stack
- External Services Integration
- API Coverage Report
- Bilko Authentication -- Entra External ID (CIAM)
- Bilko RBAC -- Users / Roles / Permissions
- Bilko Auth Migration Runbook + Admin Guide
- ADR-037 -- Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred
- Bilko Self-Serve Trial — CIAM Architecture and Auth Pattern (MC #103232)
- AI Support Agent — bilko.cloud (HR)
- Bilko HR — Inbound e-Invoice Reject + Monthly Rejection eIzvještavanje (MC #106149 / #106146)
- MC #106259 — Bilko Admin Portal Contract Fix
- Deep-Link Redirects Survive Login — Mechanism, Consumers, Open Gaps (MC #106384)
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
- Framework: Express + TypeScript
- Database: PostgreSQL 15 via Prisma
- Auth: JWT (15min access + 7d refresh) + Passport.js
- Validation: Zod
- Middleware: helmet, cors, rate-limit, auth-guard, zod-validation, error-handler
Route Structure
All routes under /api/v1/{resource}:
/api/v1/auth— login, register, refresh, logout/api/v1/organizations— org CRUD/api/v1/users— user management/api/v1/accounts— chart of accounts/api/v1/invoices— invoice CRUD/api/v1/expenses— expense CRUD/api/v1/transactions— transaction ledger/api/v1/contacts— customer/vendor contacts/api/v1/banking— bank account integration/api/v1/reports— financial reports
Middleware Stack (Order Matters)
- helmet — Security headers
- cors — CORS with whitelist
- express.json() — Body parser
- rate-limit — 100 req/15min per IP
- auth-guard — JWT validation (protected routes)
- zod-validation — Request validation
- route-handler — Business logic
- error-handler — Centralized error responses
Error Response Format
{
"error": "Error message",
"code": "ERROR_CODE",
"details": {} // optional
}
HTTP Status Codes:
- 400 — Validation error
- 401 — Unauthorized (missing/invalid token)
- 403 — Forbidden (insufficient permissions)
- 404 — Not found
- 500 — Internal server error
Database Access
- ORM: Prisma Client from
@bilko/databasepackage - Connection: Read
DATABASE_URLfrom env - Transactions: Use Prisma transactions for multi-step operations
- NEVER: Raw SQL for business logic (use for migrations only)
Authentication
- Strategy: JWT (access + refresh tokens)
- Access token: 15min expiry, httpOnly cookie
- Refresh token: 7d expiry, httpOnly cookie, stored in DB
- Password: bcrypt hash with salt rounds = 12
- 2FA: Optional TOTP (stored in User.twoFactorSecret)
Validation Rules
All requests validated with Zod schemas:
- Money: Must be string or number, converted to Decimal
- Currency: 3-letter ISO code (EUR, RSD, BAM, HRK)
- Dates: ISO 8601 format (YYYY-MM-DD)
- UUIDs: Valid v4 UUIDs for all IDs
- Emails: RFC 5322 compliant
Double-Entry Rules (CRITICAL)
Every financial transaction MUST:
- Have both debit and credit accounts
- Equal amounts (debit = credit)
- Reference the source (invoice ID, expense ID)
- Lock exchange rate at transaction date
- Create audit log entry (LoggedAction)
Development Rules
- NEVER hold money — This is an accounting tool, not a payment processor
- Immutable transactions — Once locked, NEVER modify
- Audit everything — All mutations logged to LoggedAction
- Multi-currency always — Even single-currency orgs need exchange rate support
- 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:
- Organization — Multi-tenant root (baseCurrency, country, language)
- User — RBAC (owner, admin, accountant, viewer)
Chart of Accounts:
- AccountType — Asset, Liability, Equity, Revenue, Expense
- Account — Hierarchical CoA with parent-child relations
Contacts:
- Contact — Customers, vendors, or both (type enum)
Invoicing:
- Invoice — Sales invoices with multi-currency support
- InvoiceItem — Line items with tax rates
Expenses:
- Expense — Purchase tracking with approval workflow
Transactions:
- Transaction — Double-entry ledger (debit + credit accounts)
Banking:
- BankAccount — Bank account metadata
- BankTransaction — Bank statement imports for reconciliation
Multi-Currency:
- Currency — Currency definitions (EUR, RSD, BAM, HRK, etc.)
- ExchangeRate — Historical exchange rates by date
Audit:
- LoggedAction — Immutable audit trail (APPEND-ONLY)
- SchemaVersion — Migration tracking
Key Design Decisions
1. NUMERIC(19,4) for Money
NEVER use float or JavaScript number for currency.
- Prisma type:
Decimal(maps to PostgreSQL NUMERIC) - Precision: 19 digits total, 4 decimal places
- Range: -999,999,999,999,999.9999 to +999,999,999,999,999.9999
2. Double-Entry Bookkeeping
Every financial event creates a Transaction with:
debitAccountId— Account to debitcreditAccountId— Account to creditamount— MUST be equal for both sides- Balance = sum(debits) - sum(credits) per account
3. Multi-Currency with Rate Locking
Invoice.exchangeRate— Locked at invoice dateTransaction.exchangeRate— Locked at transaction datebaseAmount— Amount converted to org's baseCurrency- NEVER recalculate historical transactions with current rates
4. Immutable Audit Trail
LoggedAction table:
- APPEND-ONLY — NEVER delete or update
- Captures: table name, user ID, action (INSERT/UPDATE/DELETE), old/new values
- Used for: compliance, debugging, rollback simulation
5. Transaction Locking
Transaction.locked— Once true, record is immutable- Locked transactions cannot be edited or deleted
- Used for: end-of-period close, tax reporting
6. Organization-Scoped Multi-Tenancy
- Every record has
organizationIdforeign key - Queries MUST filter by org (enforced in API middleware)
- No cross-org data access
7. UUID Primary Keys
- All IDs:
uuid_generate_v4()(PostgreSQL function) - NEVER use auto-increment for business data
- Portable across systems, no collisions
Migration Rules
- Never edit existing migrations — Always create new ones
- Test migrations on copy — Never run on production first
- Backward compatible — Additive changes only
- Data migrations separate — Use Prisma seed or custom scripts
- Rollback plan — Document how to undo breaking changes
Naming Conventions
- DB columns: snake_case (via
@map) - Prisma fields: camelCase
- Indexes:
idx_{table}_{column(s)} - Foreign keys: Auto-generated by Prisma
Indexes
Defined for:
- All foreign keys (automatic)
- Common query patterns (org + date, org + status)
- Unique constraints (org + code, org + invoice number)
Enums
- UserRole: owner, admin, accountant, viewer
- NormalBalance: debit, credit
- ContactType: customer, vendor, both
- InvoiceStatus: draft, sent, viewed, paid, overdue, cancelled
- ExpenseStatus: pending, approved, paid, rejected
- AuditAction: INSERT, UPDATE, DELETE
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
- NUMERIC for money — NEVER float
- Double-entry enforced — Every transaction has debit + credit
- Exchange rates locked — At transaction date, NEVER recalculate
- Audit is append-only — NEVER delete LoggedAction records
- 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/v1Last updated: 2026-02-20
Purpose
This document is the implementation contract for Bilko's backend. All ~35 endpoints are specified with:
- HTTP method + path
- Authentication requirements
- Request/response TypeScript interfaces
- Query parameters
- Error responses
- Example requests/responses
CRITICAL: Backend is NOT BUILT. This is the spec that apps/api/ MUST implement.
Table of Contents
- Authentication (5 endpoints)
- Organization (2 endpoints)
- Users (4 endpoints)
- Contacts (5 endpoints)
- Invoices (8 endpoints)
- Expenses (6 endpoints)
- Bank Accounts (4 endpoints)
- Reports (7 endpoints)
- Chart of Accounts (3 endpoints)
- Transactions (2 endpoints)
- Settings (2 endpoints)
- 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:
page(default: 1)perPage(default: 20, max: 100)sort(field name, default varies by endpoint)order(ascordesc, default:desc)
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:
400 Bad Request— Invalid request body/params401 Unauthorized— Missing or invalid auth token403 Forbidden— User lacks required role404 Not Found— Resource does not exist422 Unprocessable Entity— Validation failed500 Internal Server Error— Server error
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:
400— Email already exists422— Validation failed (weak password, invalid country, etc.)
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:
401— Invalid credentials403— Account disabled or requires 2FA
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:
401— Invalid or expired refresh token
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:
422— Validation failed (invalid currency code, etc.)
3. Users
GET /api/v1/users
List all users in organization.
Auth: Bearer token Roles: owner, admin Rate limit: 100 req/min
Query:
role(filter by role)
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:
400— Email already exists in organization422— Invalid role
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:
403— Cannot change owner role or demote yourself404— User not found
DELETE /api/v1/users/:id
Remove user from organization.
Auth: Bearer token Roles: owner Rate limit: 10 req/min
Response (204): No content
Errors:
403— Cannot delete owner or yourself404— User not found
4. Contacts
GET /api/v1/contacts
List contacts (customers/vendors).
Auth: Bearer token Roles: All Rate limit: 100 req/min
Query:
type(customer,vendor,both)page,perPage,sort,order
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:
422— Validation failed (invalid country code, currency code, etc.)
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:
400— Contact has active invoices or expenses
5. Invoices
GET /api/v1/invoices
List invoices.
Auth: Bearer token Roles: All Rate limit: 100 req/min
Query:
status(draft,sent,viewed,paid,overdue,cancelled)customerId(UUID)fromDate,toDate(ISO dates)page,perPage,sort,order
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:
404— Customer not found422— Validation failed (invalid date, negative amount, etc.)
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:
400— Invoice is not in draft status
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:
send: draft → sent (generates PDF, sends email via SendGrid)mark-paid: sent/viewed → paid (creates Transaction: debit BankAccount, credit AccountsReceivable)cancel: any → cancelled (reverses Transaction if paid)
Errors:
400— Invalid status transition
GET /api/v1/invoices/:id/pdf
Get invoice PDF.
Auth: Bearer token Roles: All Rate limit: 100 req/min
Response (200):
- Content-Type: application/pdf
- Content-Disposition: attachment; filename="INV-2026-001.pdf"
Errors:
404— Invoice or PDF not found
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:
400— Customer has no email500— SendGrid error
6. Expenses
GET /api/v1/expenses
List expenses.
Auth: Bearer token Roles: All Rate limit: 100 req/min
Query:
status(pending,approved,paid,rejected)categoryvendorIdfromDate,toDatepage,perPage,sort,order
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:
422— Validation failed (negative amount, invalid date, etc.)413— File too large
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:
400— Expense is not pending
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:
- Creates Transaction: debit ExpenseAccount, credit AccountsPayable
Errors:
400— Expense already approved/paid/rejected
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:
400— Expense is not pending
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:
404— Account not found422— Account is not Asset type
GET /api/v1/bank-accounts/:id/transactions
Get bank transactions.
Auth: Bearer token Roles: All Rate limit: 100 req/min
Query:
fromDate,toDatereconciled(true/false)page,perPage,sort,order
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:
- Multipart form:
file(CSV, max 5MB)
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:
422— Invalid CSV format413— File too large
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:
404— Bank transaction or GL transaction not found400— Already reconciled
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:
from(ISO date, required)to(ISO date, required)
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:
date(ISO date, default: today)
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:
from,to(ISO dates, required)
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:
from,to(ISO dates, required)
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:
date(ISO date, default: today)
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:
accountTypeId(filter by type)isActive(true/false)
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:
400— Code already exists404— Parent account not found422— Invalid account type
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:
400— Cannot deactivate account with transactions
10. Transactions
GET /api/v1/transactions
List general ledger transactions.
Auth: Bearer token Roles: All Rate limit: 100 req/min
Query:
fromDate,toDateaccountId(show transactions for specific account)referenceType(invoice,expense,payment,manual)page,perPage,sort,order
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:
404— Account not found422— Validation failed (debit = credit account, negative amount, etc.)
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:
base(currency code, required)target(currency code, required)date(ISO date, default: today)
Response (200):
interface ExchangeRateResponse {
baseCurrency: string
targetCurrency: string
rate: string // Decimal as string
effectiveDate: string
source: string // "ECB", "fixer.io", "manual"
lastUpdated: string
}
Errors:
404— No rate found for date (return nearest available)
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
- General: 100 req/min per user
- Auth: 5 req/min per IP
- Write ops: 10-50 req/min per user
File Uploads
- Max size: 10MB (receipts), 5MB (CSV)
- Allowed: PDF, PNG, JPG, CSV
- Storage: Cloudflare R2
- Virus scanning: ClamAV
CORS
- Allowed origins:
https://bilko.io,http://localhost:3000 - Credentials: true (cookies)
Error Logging
- Sentry for production errors
- Winston for structured logs
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.prismaDatabase: 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:
- Primary key:
id
Business rules:
- baseCurrency determines default currency for all transactions
- country determines tax rules (Serbia 20%, BiH 17%, Croatia 25%)
- fiscalYearStart used for annual reports
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 |
| 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:
- Primary key:
id - Unique:
email - Foreign key:
organizationId→ Organization(id) - Index:
idx_users_organizationon organizationId - Index:
idx_users_emailon email
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:
- One owner per organization (enforced in API)
- Cannot delete owner
- Password must be bcrypt hashed, NEVER plain text
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:
- Primary key:
id - Unique:
(organizationId, code) - Index:
idx_accounts_organizationon organizationId - Index:
idx_accounts_typeon accountTypeId
Business rules:
- Code MUST be unique within organization
- Cannot delete account with transactions
- Parent-child hierarchy for sub-accounts (e.g., 1000 → 1001, 1002)
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 |
| 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:
- Primary key:
id - Index:
idx_contacts_organizationon organizationId - Index:
idx_contacts_typeon type
Enums:
enum ContactType {
customer // Invoice recipient
vendor // Expense payee
both // Can be both customer and vendor
}
Business rules:
- Soft delete (isActive = false) if has invoices/expenses
- currencyCode determines default invoice/expense currency
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:
- Primary key:
id - Unique:
(organizationId, invoiceNumber) - Index:
idx_invoices_organizationon organizationId - Index:
idx_invoices_customeron customerId - Index:
idx_invoices_statuson status - Index:
idx_invoices_due_dateon dueDate - Composite:
idx_invoices_org_status_dateon (organizationId, status, invoiceDate)
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:
- invoiceNumber auto-generated on first save
- exchangeRate locked at invoiceDate (NEVER recalculate)
- baseAmount = totalAmount * exchangeRate
- Cannot edit invoice unless status = draft
- When status changes to 'paid', create Transaction (debit BankAccount, credit AccountsReceivable)
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:
- Primary key:
id - Index:
idx_invoice_items_invoiceon invoiceId
Business rules:
- lineTotal = quantity * unitPrice (calculated before save)
- Tax amount = lineTotal * (taxRate / 100)
- accountId determines which revenue account is credited
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:
- Primary key:
id - Unique:
(organizationId, expenseNumber) - Index:
idx_expenses_organizationon organizationId - Index:
idx_expenses_vendoron vendorId - Index:
idx_expenses_categoryon category - Index:
idx_expenses_dateon expenseDate - Composite:
idx_expenses_org_date_categoryon (organizationId, expenseDate, category)
Enums:
enum ExpenseStatus {
pending // Awaiting approval
approved // Approved, ready to pay
paid // Payment made
rejected // Approval denied
}
Business rules:
- expenseNumber auto-generated
- exchangeRate locked at expenseDate
- baseAmount = amount * exchangeRate
- When status → approved, create Transaction (debit ExpenseAccount, credit AccountsPayable)
- When status → paid, create Transaction (debit AccountsPayable, credit BankAccount)
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:
- Primary key:
id - Index:
idx_transactions_organizationon organizationId - Index:
idx_transactions_dateon transactionDate - Index:
idx_transactions_debiton debitAccountId - Index:
idx_transactions_crediton creditAccountId - Index:
idx_transactions_referenceon (referenceType, referenceId) - Composite:
idx_transactions_org_dateon (organizationId, transactionDate)
Business rules:
- DEBITS = CREDITS — Every transaction has exactly one debit and one credit
- debitAccountId ≠ creditAccountId (enforced in API)
- Cannot edit if locked = true
- Cannot delete if reconciled = true
- exchangeRate locked at transactionDate
- baseAmount = amount * exchangeRate
Common transaction patterns:
-
Invoice created (draft → sent):
- Debit: Accounts Receivable (Asset)
- Credit: Revenue (Revenue)
-
Invoice paid:
- Debit: Bank Account (Asset)
- Credit: Accounts Receivable (Asset)
-
Expense approved:
- Debit: Expense Account (Expense)
- Credit: Accounts Payable (Liability)
-
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:
- Primary key:
id - Index:
idx_bank_accounts_organizationon organizationId
Business rules:
- accountId MUST be Asset type account
- currentBalance updated when transactions created
- Soft delete (isActive = false)
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:
- Primary key:
id - Index:
idx_bank_transactions_accounton bankAccountId - Index:
idx_bank_transactions_dateon transactionDate
Business rules:
- Imported from CSV bank statements
- Matched to GL transactions via reconciliation workflow
- reconciled = true when matched
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:
- Primary key:
id - Unique:
(baseCurrency, targetCurrency, effectiveDate) - Index:
idx_exchange_rates_dateon effectiveDate - Index:
idx_exchange_rates_pairon (baseCurrency, targetCurrency)
Business rules:
- Rates fetched daily from ECB or fixer.io API
- When creating transaction, rate is locked at transaction date
- If no rate for exact date, use nearest available (warn in logs)
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:
- Primary key:
eventId - Index:
idx_logged_actions_timestampon actionTimestamp - Index:
idx_logged_actions_tableon tableName - Index:
idx_logged_actions_useron userId
Enums:
enum AuditAction {
INSERT
UPDATE
DELETE
}
Business rules:
- APPEND-ONLY — NEVER delete or update records
- Triggered via Prisma middleware (automatic)
- Used for: compliance, debugging, rollback simulation
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:
- Updated by Prisma migrations
- Used to verify schema version matches application version
Data Types & Precision
NUMERIC(19,4) for ALL Money
CRITICAL: NEVER use float, double, or JavaScript number for currency.
- Precision: 19 digits total, 4 decimal places
- Range: -999,999,999,999,999.9999 to +999,999,999,999,999.9999
- Prisma type:
Decimal - PostgreSQL type:
NUMERIC(19,4)
Why:
- JavaScript
numberhas 53-bit precision (safe up to 2^53 - 1 = 9,007,199,254,740,991) - Financial calculations require exact decimal precision
- Example: 0.1 + 0.2 = 0.30000000000000004 (float error)
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
- User.email
- Account.(organizationId, code)
- Invoice.(organizationId, invoiceNumber)
- Expense.(organizationId, expenseNumber)
- ExchangeRate.(baseCurrency, targetCurrency, effectiveDate)
Check Constraints
(Enforced in API layer, not database):
- amount > 0 for all financial amounts
- dueDate >= invoiceDate for invoices
- debitAccountId ≠ creditAccountId for transactions
Indexes Strategy
Query Patterns Optimized
-
List by organization + filter:
(organizationId, status, date)composite index on invoices(organizationId, category, date)composite index on expenses
-
Foreign key lookups:
- All foreign keys have indexes
-
Date range queries:
- Dedicated indexes on
transactionDate,invoiceDate,expenseDate,dueDate
- Dedicated indexes on
-
Reconciliation:
- Index on
(referenceType, referenceId)for transaction lookups
- Index on
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:
- Validate request body (email uniqueness, password strength, country/currency codes)
- Hash password with bcrypt (12 rounds)
- Create database transaction:
- Create Organization
- Create User (role = 'owner')
- Create default Chart of Accounts (seed accounts based on country)
- Generate JWT access token (15 min expiry)
- Generate refresh token (7 days expiry)
- Set refresh token in httpOnly cookie
- Return user + organization + tokens
Password Requirements:
- Minimum 8 characters
- At least 1 uppercase letter
- At least 1 lowercase letter
- At least 1 number
- Optional: 1 special character
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:
400 Bad Request— Email already exists422 Unprocessable Entity— Weak password, invalid country code
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:
- Find user by email
- Verify password with bcrypt.compare()
- If 2FA enabled, send TOTP challenge (not covered in MVP)
- Update user.lastLoginAt
- Generate JWT access token (15 min expiry)
- Generate refresh token (7 days or 30 days if rememberMe = true)
- Set refresh token in httpOnly cookie
- Return user + tokens
Rate Limiting:
- Max 5 login attempts per 1 minute per IP address
- After 5 failed attempts, return
429 Too Many Requests - Lockout duration: 15 minutes
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:
- Extract refresh token from httpOnly cookie
- Verify refresh token signature
- Check if token is blacklisted (revoked)
- Check expiry
- Generate new access token (15 min expiry)
- Return new access token
Refresh Token Storage:
- Stored in httpOnly cookie (prevents XSS attacks)
- Secure flag = true (HTTPS only)
- SameSite = Strict (prevents CSRF attacks)
- Path = /api/v1/auth/refresh
Token Revocation:
- On logout, add refresh token to blacklist (Redis or PostgreSQL)
- Blacklist stores token JTI (JWT ID) + expiry
- Expired blacklist entries auto-deleted after 30 days
Errors:
4. Logout
Endpoint: POST /api/v1/auth/logout
Steps:
- Extract refresh token from cookie
- Add token JTI to blacklist
- Clear httpOnly cookie
- 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:
- On logout, JTI added to blacklist
- On password change, all refresh tokens revoked
- On user deletion, all refresh tokens revoked
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:
- Use different secrets for access and refresh tokens
- Rotate secrets every 90 days (requires re-login for all users)
- Store in environment variables, NEVER in code
- Use Vaultwarden or similar secret manager in production
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:
- User enables 2FA in settings
- Generate TOTP secret (32-char base32 string)
- Display QR code (Google Authenticator, Authy compatible)
- User scans QR code
- User enters 6-digit code to verify
- Store
twoFactorSecret(encrypted) inuserstable - Set
twoFactorEnabled = true
Login with 2FA:
- User enters email + password
- If
twoFactorEnabled = true, return403withrequiresTwoFactor: true - Frontend prompts for 6-digit code
- User submits code via
POST /api/v1/auth/verify-2fa - Verify TOTP code (30-second window)
- 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):
- No server-side session storage
- All state in JWT claims
- Fast, scales horizontally
- Cannot revoke access tokens (must wait for expiry)
Option 2: Redis sessions (for v2):
- Store session data in Redis
- JWT contains only session ID
- Can revoke immediately
- Requires Redis infrastructure
Recommended for MVP: JWT-only (stateless)
Session Invalidation
On password change:
- Hash new password
- Update
users.passwordHash - Delete all refresh tokens from blacklist older than 1 hour (force re-login)
- Return success
On account deletion:
- Soft-delete user (set
isActive = false) - Add all user's refresh tokens to blacklist
- Revoke access immediately
Security Best Practices
1. Password Storage
- NEVER store plain text passwords
- Use bcrypt with 12 rounds (2^12 iterations)
- Bcrypt auto-salts (no need to store salt separately)
2. Token Security
- Access tokens in
Authorizationheader (NOT cookies to avoid CSRF) - Refresh tokens in httpOnly cookies (prevent XSS)
- Use Secure flag (HTTPS only)
- Use SameSite=Strict (prevent CSRF)
3. Rate Limiting
- Login: 5 attempts per minute per IP
- Register: 5 attempts per minute per IP
- Refresh: 100 attempts per minute per user
- All other endpoints: 100 requests per minute per user
4. HTTPS Only
- All traffic over HTTPS in production
- Redirect HTTP → HTTPS
- HSTS header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
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
- Validate all inputs with Zod schemas
- Sanitize SQL inputs (Prisma prevents SQL injection)
- Escape HTML in user-generated content
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
- Double-Entry Bookkeeping
- Invoice Workflow
- Expense Workflow
- VAT Calculation
- Multi-Currency
- Bank Reconciliation
- Chart of Accounts
- Fiscal Year
- 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
-
Debit Account ≠ Credit Account
- A transaction cannot debit and credit the same account
- Enforced at API validation layer
-
Amount > 0
- Transaction amount must be positive
- Sign is determined by debit/credit, not amount
-
Balanced Entries
- Debit amount = Credit amount
- No split transactions in MVP (one debit, one credit only)
-
Locked Transactions
- Once
transaction.locked = true, cannot be edited or deleted - Locked at end-of-period close or when reconciled
- Once
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:
- Sum of all debit balances = Sum of all credit balances
- If unbalanced, there is an error in the ledger
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
- Format:
INV-YYYY-NNN(e.g.,INV-2026-001) - Generated on first save (when status changes from null → draft)
- Sequential within organization per year
- NEVER reuse cancelled invoice numbers
Rule 2: Draft-Only Editing
- Can only edit invoice if
status = 'draft' - Once sent, cannot change line items or amounts
- Can still update notes/terms
Rule 3: Overdue Detection
- Invoice becomes
overdueifdueDate < today AND status != 'paid' - Checked automatically via scheduled job (daily at 00:00 UTC)
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
exchangeRatelocked atinvoiceDate- NEVER recalculated
Rule 8: PDF Generation
- PDF generated when status → sent
- Stored in Cloudflare R2
- URL saved to
invoice.pdfUrl - PDF includes: org branding, line items, tax breakdown, payment terms
Rule 9: Email Delivery
- Sent to
contact.email - Subject: "Invoice [invoiceNumber] from [organizationName]"
- Attachment: PDF
- Tracking pixel for
viewedAttimestamp
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
- Format:
EXP-YYYY-NNN(e.g.,EXP-2026-001) - Generated on creation
- Sequential within organization per year
Rule 2: Approval Required
- Expenses created with
status = 'pending' - Only
owneroradmincan approve accountantcan create but cannot approve- Once approved, cannot be edited
Rule 3: Receipt Upload
- Optional but recommended
- Max file size: 10MB
- Allowed formats: PDF, PNG, JPG
- Stored in Cloudflare R2
- URL saved to
expense.receiptUrl
Rule 4: Category Tracking
- Free-text category field
- Common categories suggested: Infrastructure, Software, Office, Travel, Marketing, Utilities
- Used for expense reports by category
Rule 5: Tax Amount
- Optional
taxAmountfield - If provided, represents input VAT (can be deducted from output VAT)
- Used in VAT report
Rule 6: Base Amount Conversion
baseAmount = amount * exchangeRate
exchangeRatelocked atexpenseDate
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
- Invoice items have
taxRatefield (percentage) - Default to organization's country standard rate
- User can override per line item
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)
- VAT collected on invoices sent to customers
- Recorded when invoice status → sent
- Included in VAT report as "Output VAT"
Rule 4: Input VAT (Purchases)
- VAT paid on expenses from vendors
- Recorded from
expense.taxAmountfield - Included in VAT report as "Input VAT"
Rule 5: Net VAT Calculation
netVAT = outputVAT - inputVAT
- If positive: owe VAT to tax authority
- If negative: tax authority owes refund (rare for small businesses)
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:
- EUR (Euro) — default
- RSD (Serbian Dinar)
- BAM (Bosnian Mark)
- HRK (Croatian Kuna)
- USD (US Dollar)
Exchange Rate Locking
CRITICAL RULE: Exchange rates are locked at transaction date.
Why:
- Financial reports must be consistent over time
- Cannot recalculate historical transactions with current rates
- Accounting standards require rate at transaction date
How it works:
-
Invoice created on 2026-02-20:
currencyCode = 'RSD'exchangeRate = 117.50(EUR to RSD rate on 2026-02-20)totalAmount = 125,000 RSDbaseAmount = 125,000 / 117.50 = 1,063.83 EUR(locked)
-
Today (2026-03-15), rate is now 120.00:
- Invoice
baseAmountstays 1,063.83 EUR - NEVER recalculated to
125,000 / 120.00 = 1,041.67 EUR
- Invoice
Exchange Rate Sources
Primary: European Central Bank (ECB) API
- Free
- Daily updates
- Reliable
Fallback: fixer.io API
- Freemium (1000 requests/month free)
- Real-time rates
Manual Entry:
Base Currency Conversion
All reports displayed in organization's baseCurrency.
Example:
- Organization baseCurrency = EUR
- Invoice 1: 125,000 RSD → 1,063.83 EUR (rate 117.50)
- Invoice 2: 3,500 EUR → 3,500 EUR (rate 1.0)
- Expense 1: 850 USD → 794.39 EUR (rate 1.07)
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
-
Import bank statement (CSV):
- Parse CSV file
- Create
BankTransactionrecords - Link to
BankAccount
-
Auto-match transactions:
- Match by amount + date (within ±3 days)
- Match by reference (invoice number in description)
- Calculate confidence score (0-100)
-
Manual reconciliation:
- User links
BankTransactiontoTransaction - Set
bankTransaction.reconciled = true - Set
bankTransaction.matchedTransactionId = transaction.id
- User links
-
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:
- Score ≥ 90 → Auto-match
- Score 70-89 → Suggest match (user confirms)
- Score < 70 → No match suggested
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:
- 1xxx = Assets
- 2xxx = Liabilities
- 3xxx = Equity
- 4xxx = Revenue
- 5xxx = Expenses
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
- Parent account codes must exist before creating child accounts
- Cannot delete parent account if child accounts exist
- Sub-account balance rolls up to parent
Rule 2: Account Deactivation
- Cannot deactivate account with transactions
- Deactivated accounts hidden from dropdowns but visible in reports
Rule 3: Reserved Accounts
- System creates default accounts on organization registration
- Cannot delete: Cash, Bank Account, Accounts Receivable, Accounts Payable, Revenue, Expense
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
- At fiscal year end, close Revenue and Expense accounts
- Transfer net profit/loss to Retained Earnings
- Lock all transactions for closed fiscal year
- Cannot edit locked transactions
Rule 2: Period-Based Reports
- Profit & Loss: always for a period (from → to)
- Balance Sheet: always as of a date (point in time)
- Cash Flow: always for a period
9. Audit Trail
Purpose
Immutable log of all data changes for:
- Compliance (GDPR, financial regulations)
- Debugging (track down errors)
- Rollback simulation (undo mistakes)
What is Logged
ALL INSERT/UPDATE/DELETE operations on:
- Invoices
- Expenses
- Transactions
- Contacts
- Users
- Organization settings
Captured data:
- Table name
- User ID (who made the change)
- Timestamp
- Action (INSERT, UPDATE, DELETE)
- Old values (before change)
- New values (after change)
- Client IP address
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
- Audit logs retained for 7 years (financial compliance requirement)
- After 7 years, archived to cold storage (AWS S3 Glacier)
- NEVER deleted
Summary of Critical Business Rules
- Double-entry: Every transaction has one debit and one credit
- Debits = Credits: Ledger must always balance
- Exchange rate locking: Rates locked at transaction date, NEVER recalculated
- Invoice workflow: draft → sent → paid (creates 2 transactions)
- Expense workflow: pending → approved → paid (creates 2 transactions)
- VAT calculation:
taxAmount = lineTotal * (taxRate / 100) - Account hierarchy: Parent-child relationships in Chart of Accounts
- Audit trail: ALL changes logged immutably
- Fiscal year close: Lock transactions, transfer P&L to Retained Earnings
- 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:
Strict-Transport-Security— Force HTTPSX-Content-Type-Options: nosniff— Prevent MIME sniffingX-Frame-Options: DENY— Prevent clickjackingX-XSS-Protection: 1; mode=block— Enable XSS filterContent-Security-Policy— Restrict resource loading
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?
- Allows httpOnly cookies (refresh tokens)
- Frontend must set
credentials: 'include'in fetch()
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:
- 10MB for file uploads (receipts, CSV imports)
- Reject if Content-Length > 10MB
- Return
413 Payload Too Large
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
- SendGrid (Email Delivery)
- Cloudflare R2 (File Storage)
- Exchange Rate APIs
- PDF Generation
- 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:
{{ organizationName }}{{ invoiceNumber }}{{ customerName }}{{ totalAmount }}{{ currencyCode }}{{ dueDate }}{{ viewInvoiceUrl }}
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:
{{ organizationName }}{{ inviterName }}{{ inviteLink }}{{ role }}
Subject: {{ inviterName }} invited you to {{ organizationName }} on Bilko
3. Password Reset Email
Template variables:
{{ resetLink }}{{ expiresIn }}(e.g., "15 minutes")
Subject: Reset your Bilko password
Email Tracking
Purpose: Track when customer views invoice email (for viewedAt timestamp).
How it works:
- Embed 1x1 transparent pixel in email HTML
- Pixel URL:
https://api.bilko.io/track/email/{{ invoiceId }} - When customer opens email, browser loads pixel
- 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:
- 100 emails/day
- 40,000 emails first 30 days
- After 30 days: $0.00025/email
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:
- S3-compatible API (easy migration)
- Zero egress fees (S3 charges $0.09/GB)
- Cheaper storage: $0.015/GB (vs S3 $0.023/GB)
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:
- Invoice PDFs: public (anyone with URL can view)
- Expense receipts: private (require signed URL)
- Signed URLs expire in 1 hour
2. Virus scanning:
- Use ClamAV to scan uploaded files
- Reject if virus detected
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:
- DELETE /api/v1/invoices/:id — Delete invoice (only draft invoices should be deletable)
- Method: DELETE
- Auth: Bearer token
- Roles: owner, admin, accountant
- Rate limit: 10 req/min
- Response: 204 No Content
- Errors:
- 400 — Invoice is not in draft status
- 404 — Invoice not found
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:
- GET /api/v1/expenses/:id/receipt — Download expense receipt
- Method: GET
- Auth: Bearer token
- Roles: All
- Rate limit: 100 req/min
- Response: 200 OK
- Content-Type: image/jpeg, image/png, or application/pdf
- Content-Disposition: attachment; filename="receipt-{expenseNumber}.{ext}"
- Errors:
- 404 — Expense or receipt not found
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:
-
GET /api/v1/reports/vat/export/pdf — Export VAT report as PDF
- Method: GET
- Auth: Bearer token
- Roles: All
- Query: from (ISO date), to (ISO date)
- Rate limit: 50 req/min
- Response: 200 OK
- Content-Type: application/pdf
- Content-Disposition: attachment; filename="vat-report-{from}-{to}.pdf"
- Errors: 422 — Invalid date range
-
GET /api/v1/reports/vat/export/xml — Export VAT report as XML for e-filing
- Method: GET
- Auth: Bearer token
- Roles: All
- Query: from (ISO date), to (ISO date)
- Rate limit: 50 req/min
- Response: 200 OK
- Content-Type: application/xml
- Content-Disposition: attachment; filename="vat-return-{from}-{to}.xml"
- Errors: 422 — Invalid date range
-
POST /api/v1/reports/vat/submit — Submit VAT return to tax authority (Phase 2)
- Method: POST
- Auth: Bearer token
- Roles: owner, admin, accountant
- Rate limit: 5 req/min
- Request:
interface SubmitVATRequest { period: string // ISO date range, e.g., "2026-02" confirmationEmail: string } - Response: 201 Created
interface SubmitVATResponse { submissionId: string submittedAt: string status: 'pending' | 'accepted' | 'rejected' confirmationNumber: string | null } - Errors: 400 — VAT period already submitted
PARTIAL COVERAGE:
- Reconciliation status check requires aggregating unreconciled count from GET /api/v1/bank-accounts response. Consider dedicated endpoint:
- GET /api/v1/bank-accounts/unreconciled-count
- Returns:
{ total: number, byAccount: Array<{accountId: string, count: number}> }
- Returns:
- GET /api/v1/bank-accounts/unreconciled-count
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:
-
GET /api/v1/integrations — List integrations
- Method: GET
- Auth: Bearer token
- Roles: All
- Response: 200 OK
interface IntegrationsResponse { connected: Array<{ id: string name: string type: string status: 'active' | 'inactive' | 'error' connectedAt: string lastSync: string | null }> available: Array<{ id: string name: string type: string description: string icon: string }> }
-
POST /api/v1/integrations/:id/connect — Connect integration
- Method: POST
- Auth: Bearer token
- Roles: owner, admin
- Request body varies by integration type
- Response: 201 Created
-
DELETE /api/v1/integrations/:id/disconnect — Disconnect integration
- Method: DELETE
- Auth: Bearer token
- Roles: owner, admin
- Response: 204 No Content
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):
-
GET /api/v1/settings/notifications — Get notification preferences
- Method: GET
- Auth: Bearer token
- Roles: All
- Response: 200 OK
interface NotificationSettings { email: { invoicePaid: boolean invoiceOverdue: boolean expenseApproved: boolean bankAccountSynced: boolean } inApp: { invoiceUpdates: boolean expenseUpdates: boolean reconciliationMatches: boolean } }
-
PATCH /api/v1/settings/notifications — Update notification preferences
- Method: PATCH
- Auth: Bearer token
- Roles: All
- Request: Same as GET response (partial updates allowed)
- Response: 200 OK (updated settings)
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):
-
POST /api/v1/auth/2fa/enable — Enable 2FA
- Response: QR code + backup codes
-
DELETE /api/v1/auth/2fa/disable — Disable 2FA
- Requires current password confirmation
-
GET /api/v1/settings/security — Get security settings
- Returns: session timeout, password policy, etc.
-
PATCH /api/v1/settings/security — Update security settings
-
GET /api/v1/security/audit-log — Get audit log (paginated)
- Query: fromDate, toDate, userId, action, tableName
- Response: Paginated list of LoggedAction records
-
POST /api/v1/security/data-export — Request GDPR data export
- Response: Job ID, email sent when ready
-
DELETE /api/v1/organization — Delete organization (danger zone)
- Requires password confirmation
- Cascades to all related data
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)
-
DELETE /api/v1/invoices/:id — Delete invoice (draft only)
- Reason: Invoice list has delete action
-
GET /api/v1/expenses/:id/receipt — Download expense receipt
- Reason: Expense list shows receipt indicator, needs download link
-
GET /api/v1/reports/vat/export/pdf — VAT report PDF export
- Reason: VAT report has export button (placeholder currently)
-
GET /api/v1/reports/vat/export/xml — VAT report XML export (e-filing)
- Reason: VAT report has export button (placeholder currently)
Medium Priority (Settings)
-
GET /api/v1/settings/notifications — Get notification preferences
- Reason: Settings page has notification section with checkboxes
-
PATCH /api/v1/settings/notifications — Update notification preferences
- Reason: Settings page has save button for notification preferences
-
GET /api/v1/settings/security — Get security settings
- Reason: Settings page has security section (session timeout, password policy)
-
PATCH /api/v1/settings/security — Update security settings
- Reason: Settings page has save button for security settings
-
GET /api/v1/security/audit-log — Audit log
- Reason: Settings security section has "View Audit Log" button
-
POST /api/v1/security/data-export — GDPR data export
- Reason: Settings security section has "Request Data Export" button
-
DELETE /api/v1/organization — Delete company
- Reason: Settings security section has "Delete Company" button (danger zone)
-
POST /api/v1/auth/2fa/enable — Enable 2FA
- Reason: Settings security section has "Enable 2FA" button
-
DELETE /api/v1/auth/2fa/disable — Disable 2FA
- Reason: Settings security section needs disable option if 2FA enabled
Low Priority (Phase 2 Features)
-
GET /api/v1/integrations — List integrations
- Reason: Settings integrations section (Phase 2)
-
POST /api/v1/integrations/:id/connect — Connect integration
- Reason: Settings integrations section (Phase 2)
-
DELETE /api/v1/integrations/:id/disconnect — Disconnect integration
- Reason: Settings integrations section (Phase 2)
-
POST /api/v1/reports/vat/submit — Submit VAT return
- Reason: VAT report has submit button (explicitly marked "Coming in Phase 2")
-
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:
- Delete invoice button will not work
- Receipt download links will not work
- VAT export buttons will not work
Implementation Order:
- DELETE /api/v1/invoices/:id (simplest, no business logic)
- GET /api/v1/expenses/:id/receipt (file download)
- GET /api/v1/reports/vat/export/pdf (report generation + PDF library)
- 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:
- 501 Not Implemented
- Or minimal placeholder data
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:
- Fetch GET /api/v1/bank-accounts (all accounts)
- For each account, fetch GET /api/v1/bank-accounts/:id/transactions?reconciled=false
- 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:
- POST /api/v1/auth/register
- POST /api/v1/auth/login
- POST /api/v1/auth/refresh
- POST /api/v1/auth/logout
- GET /api/v1/auth/me
Recommendation: Create auth pages in Phase 2:
/loginpage/registerpage/logoutaction (server action)- Auth middleware to protect all /dashboard routes
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
- DELETE /api/v1/invoices/:id
- GET /api/v1/expenses/:id/receipt
- GET /api/v1/reports/vat/export/pdf
- GET /api/v1/reports/vat/export/xml
Phase 2b (Settings Persistence) — 31h
- All settings endpoints (notifications, security, audit log, data export, org delete, 2FA)
Phase 2c (Integrations + VAT Submit) — 37h
- Integrations endpoints (stub or full implementation)
- VAT submit endpoint (requires external API integration)
Phase 2d (Polish) — 11h
- Unreconciled count endpoint
- Shared types package
- Error handling utilities
- Auth pages
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:
- Invoice CRUD (except delete)
- Expense CRUD (except receipt download)
- Banking & reconciliation
- Reporting (except export formats)
- User management
- Organization settings
- Chart of accounts
- Transactions
Missing 25%:
- 4 high-priority endpoints (delete invoice, receipt download, VAT exports)
- 9 medium-priority settings endpoints
- 4 low-priority Phase 2 endpoints
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:
- Review this coverage report with team
- Prioritize missing endpoints based on business needs
- Update API-REFERENCE.md with missing endpoint specs
- Create implementation tickets in Mission Control
- 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
| Field | Value |
|---|---|
| Tenant ID | 20bb17de-9be5-4143-a7e5-8c1ddae6a064 |
| Display name | Bilko CIAM |
| Domain | bilkociam.onmicrosoft.com |
| Type | Entra External ID (CIAM), EU data residency, Norway |
| Billing | MAU — 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 URI | https://bilkociam.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/discovery/v2.0/keys |
| OIDC discovery | https://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
| App | Client ID | Flow | Notes |
|---|---|---|---|
| Bilko API (resource) | fe39e0f5-513e-40af-93f0-c3ee624df56c | Exposes scope | Scope: 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 SPA | c2902239-ea63-41bd-8619-6cf096d7d45a | PKCE 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-64b1f157c8c2 | PKCE 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
oid— object ID, immutable cross-app anchor; mandatory identity key (built-in in CIAM, always present)sub— pairwise pseudonymous per app; NOT used as identity anchor (changes on app re-registration). The backend logs a warning whensub != oid(expected in CIAM) and usesoidexclusively. Confirmed live: E2E test showedsub=053nt0lkvsoid=3b53a25a.email/preferred_username— informational only; mutable; NOT used for identity resolution in JWT claims issued by Bilkoname,family_name,given_name— optional 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)
- Browser opens login page → MSAL browser (
@azure/msal-browser+@azure/msal-react) initiates PKCE auth code flow - Redirect to
bilkociam.ciamlogin.com→ user authenticates (email/password or social) → Entra issues auth code - MSAL exchanges code for tokens (PKCE, in memory — NOT localStorage)
- MSAL acquires access token for scope
api://fe39e0f5.../access_as_user - Web sends
Authorization: Bearer <entra-access-token>to Kotlin API - Kotlin
EntraExternalIdService.verifyIdToken()validates: RS256 signature via live JWKS, issuer exact match, audience =fe39e0f5...,oidclaim present, JWKS URL domain-pinned tociamlogin.com/microsoftonline.com - JIT provisioning or email-match link (see JIT section below) → Bilko session returned
- Session cookie (
SameSite=Lax, httpOnly) established; subsequent requests use Bilko refresh token - Sign-out calls Entra logout endpoint to invalidate Entra session + clears local cookie
Authentication Flow — Mobile (Token Exchange)
- Expo native app initiates PKCE via
expo-auth-session/useEntraAuthRequest - Redirect to Entra → auth code returned to
com.alai.bilko://auth - MSAL exchanges code; id_token (not access_token) sent to
POST /auth/entra/session - Kotlin backend verifies id_token, runs JIT provisioning or link, returns
{ accessToken, refreshToken } - 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):
- Lookup
entra_external_identitiesbyissuer + oid. If found: return session. - If not found: email-match lookup in
users(case-normalised, lowercase both sides). If unique match andemail_verified: insertentra_external_identitiesrow, log audit evententra_jit_link, return session. - If no match: call
UserProvisioningService.provisionNewUserForEntra()— creates a new org + user with roleviewer+ insertsentra_external_identitiesrow. 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:
- TTL: 12 hours per key (stored as
Pair<RSAPublicKey, Instant>; evicted on read if age > 12h) - On
kidmiss: force re-fetch regardless of other cached keys - JWKS URL domain-pinned: must match
^https://([a-z0-9-]+\.)*ciamlogin\.com/or^https://login\.microsoftonline\.com/ - Startup fail-closed: if
ENTRA_EXTERNAL_ID_ISSUERset but any config absent or URL fails domain assertion →IllegalStateExceptionat Ktor module init (not lazy 503) - JWKS verification: live E2E test confirmed 6 RSA keys, all
kty=RSA, TLS valid 2026-11-22
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):
- Option A (not yet implemented): revalidate Entra account status on every refresh (~50ms latency)
- Option B (current default): 7-day window; immediate revocation requires an admin to also disable the user in the Bilko DB. Documented as a risk-acceptance decision in
AuthService.kt(code comment references 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"}:
POST /auth/registerPOST /auth/loginPOST /auth/forgot-passwordGET /auth/reset-passwordPOST /auth/reset-password
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 / WP | Scope | Branch | Status |
|---|---|---|---|
| Phase 0 (MC #103076) | CIAM tenant provisioning, 3 app registrations, JWKS verification | FlowForge standalone | DONE — stage live |
| WP1 (MC #103141) | RBAC permissions catalog V67, PermissionService, BilkoPrincipal, requirePermission, 204 matrix tests | feat/rbac-wp1-permissions-catalog | DONE — Proveo PARTIAL (integration test fix applied post-verification) |
| WP2 (MC #103142) | JIT provisioning V68, UserProvisioningService, admin/invite API, role-assign endpoint | feat/rbac-wp2-user-provisioning | DONE — Proveo PASS |
| WP3 (MC #103143) | Web: Entra primary CTA, register retired, forgot/reset SSPR, RBAC admin UI | feat/rbac-wp3-web-entra-ui | DONE — Proveo PASS |
| WP4 (MC #103144) | Retire legacy endpoints (410), web login Entra-only, break-glass documented | feat/rbac-wp4-retire-legacy-auth | DONE — Proveo PASS |
| WP5 (MC #103145) | E2E: live CIAM token, OID anchor, JIT provision, RBAC enforcement, invalid token rejection | feat/rbac-wp3-web-entra-ui | DONE — 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
| Role | Level | Scope |
|---|---|---|
owner | 3 | All permissions including billing, account deletion, user management |
admin | 2 | All permissions except billing and account deletion; can manage users and roles |
accountant | 1 | Create and manage financial records; cannot delete; cannot manage users |
viewer | 0 | Read-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 group | Example keys |
|---|---|
| Invoices | invoice:read, invoice:create, invoice:update, invoice:delete, invoice:submit |
| Expenses | expense:read, expense:create, expense:update, expense:delete |
| Contacts | contact:read, contact:create, contact:update, contact:delete |
| Transactions | transaction:read, transaction:create, transaction:reconcile |
| Reports | report:read, report:export |
| Settings / billing | settings:read, settings:update, billing:read, billing:update |
| Users | users:read, users:manage, users:invite |
| Account admin | account:delete |
| Documents | document:read, document:upload, document:delete |
| Articles / products | article: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.
| Role | Permissions count | Principle |
|---|---|---|
| viewer | 13 | Read-only: all :read + :export keys |
| accountant | 40 | viewer permissions + create/update on financial resources; no delete, no user management |
| admin | 49 | accountant permissions + delete + user management; no billing:update, no account:delete |
| owner | 52 | All 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)
- Interface method:
fun resolve(role: String): Set<String>(2 implementations: interface +DbPermissionService) - Live DB query against
role_permissionson every resolve call - Fail-closed: if role is unknown or DB returns empty set, resolves to
emptySet()— no permissions granted - CEO OCD O1 decision: global per-role cache (4 known values); result cached per role string key. Per CEO spec, cache keyed
userId+role-versionwas the ideal; current implementation uses global per-role cache (simpler, advisory gap noted in Proveo verdict)
BilkoPrincipal + requirePermission
Source: apps/api/src/main/kotlin/no/alai/bilko/auth/BilkoPrincipal.kt and RbacHelper.kt (commit dee4fb1)
BilkoPrincipalcarriespermissions: Set<String>— resolved at authentication time viaPermissionServiceRoutingContext.requirePermission(permissionKey: String)— Kotlin extension function; throwsForbiddenException(HTTP 403BILKO-AUTH-003) if key not in principal's permission set; callsAuthzAuditLogger- All 51 formerly-
requireRole()call sites migrated torequirePermission()(17 route files, 0 residualrequireRolein routes — verified by grep) requireRole()is kept as a thin compatibility shim (RbacHelper.kt)
Role-to-Permission Matrix
| Permission key | viewer | accountant | admin | owner |
|---|---|---|---|---|
invoice:read | Y | Y | Y | Y |
invoice:create | - | Y | Y | Y |
invoice:update | - | Y | Y | Y |
invoice:delete | - | - | Y | Y |
invoice:submit | - | Y | Y | Y |
expense:read | Y | Y | Y | Y |
expense:create | - | Y | Y | Y |
expense:delete | - | - | Y | Y |
users:read | Y | Y | Y | Y |
users:manage | - | - | Y | Y |
users:invite | - | - | Y | Y |
billing:read | - | - | - | Y |
billing:update | - | - | - | Y |
account:delete | - | - | - | Y |
settings:read | Y | Y | Y | Y |
settings:update | - | - | Y | Y |
report:read | Y | Y | Y | Y |
report:export | Y | Y | Y | Y |
| ... (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)
- Every
requirePermission()call logs anauthz_decisionevent (SLF4J structured log) - Log fields:
userId,orgId,permissionKey,granted(boolean),route RbacHelper.ktreferencesAuthzAuditLoggerat 4 call sites (verified)
V67/V68 Migration Summary
| Migration | Contents |
|---|---|
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
- 204 RbacMatrixTest cases (all 51 call sites x 4 roles): 0 failures —
feat/rbac-wp1-permissions-catalog - 8 UserProvisioningWp2Test cases (T1–T8: JIT, admin CRUD, role guards, self-escalation block): PASS
- Total test suite: 2534 tests (1070 unit + 1283 integration + 181 web), 0 failures — WP5 E2E evidence
/tmp/evidence-103145
Out of Scope (v1)
- Multi-role per user (single role per org; MC #103089)
- Multi-org membership (single org per user; MC #103089)
- ABAC / conditional permissions (e.g. "delete only own drafts")
- Accountant Portal multi-tier permissions (Collaborator/Approver roles from ACCOUNTANT-PORTAL-SPEC.md §2.2)
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
- Sign in as admin or owner
- Navigate to Settings > Users (
/admin/users) - Click Invite User
- Enter email, full name, and select role
- 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:
- Caller must have
users:managepermission (admin+) - A user cannot change their own role (self-escalation blocked — HTTP 403)
- The
ownerrole cannot be changed via this endpoint (owner is protected inSettingsService.changeUserRole()) - Invalid role values return HTTP 400
Via Web Admin UI
4. User Lifecycle
- Admin creates user via
POST /admin/users(role = viewer by default, or specified role) - User receives Entra invite (email from
bilkociam.onmicrosoft.com) - First sign-in: user clicks Entra sign-in on Bilko web login → authenticates in Entra CIAM → Bilko backend calls
createSessionFromEntraIdToken():- Looks up
entra_external_identitiesbyoid→ not found (first login) - Email-match lookup → finds pre-created user → inserts
entra_external_identitiesrow (JIT link) → audit evententra_jit_link - Bilko session returned; user is logged in as viewer
- Looks up
- Admin promotes role if needed via
PUT /users/:id/role - Subsequent logins: Entra → backend finds
entra_external_identitiesbyoid→ direct session, no email-match step - 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)
| Area | Before (pre-WP1) | After (WP1–WP4) |
|---|---|---|
| Backend auth enforcement | requireRole("admin") inline in 51+ route handlers | requirePermission("invoice:create") via extension fn; 0 residual requireRole in routes |
| Permission data | No tables; hardcoded numeric hierarchy in RbacHelper | V67: permissions table (52 keys), role_permissions seed; V68: provisioning function |
| User provisioning | Self-serve POST /auth/register | Admin invite (POST /admin/users) + JIT Entra link on first sign-in; UserProvisioningService |
| Web login | Email/password form + "Sign in with Microsoft" coexisting | Entra-only CTA; no email/password form; register page shows "contact your admin" |
| Legacy endpoints | Active: /auth/login, /auth/register, /auth/forgot-password, /auth/reset-password | HTTP 410 Gone + ENDPOINT_RETIRED body |
| Password reset | Email-based reset-password flow (V57 table) | Redirect to Entra SSPR portal (self-service via Microsoft account) |
| RBAC admin UI | No UI; role changes required direct DB query | Web: Settings > Users page with role dropdown (admin/owner only) |
6. Branch Stack (WP1–WP4 Stacked PRs)
| WP | Branch | Latest commit | Key files |
|---|---|---|---|
| WP1 — RBAC catalog | feat/rbac-wp1-permissions-catalog | 890168d (last route commit) | V67 migration, PermissionService, BilkoPrincipal, RbacHelper, 17 route files migrated |
| WP2 — Provisioning | feat/rbac-wp2-user-provisioning | a9fa67c | V68 migration, UserProvisioningService, UserManagementRoutes |
| WP3 — Web UI | feat/rbac-wp3-web-entra-ui | 3c1c019 | login/page.tsx (Entra CTA), register/page.tsx (retired), admin/users/page.tsx, lib/permissions.ts |
| WP4 — Retire legacy | feat/rbac-wp4-retire-legacy-auth | 3ac1388 | AuthRoutes.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:
- Feature flag path (if
FEATURE_ENTRA_AUTH_ENABLEDenv var is present): set tofalseto re-enable the password auth provider path (AuthProvider interface, D5 in MC #103075) - Hard rollback: revert to the pre-WP1 commit; Flyway handles down-migration if reversible V67/V68 down scripts were authored (check migration files)
- 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
- 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
- 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
| Table | Key columns | Notes |
|---|---|---|
users | id, organization_id, email, password_hash (nullable), role (CHECK owner|admin|accountant|viewer), two_factor_* | V66: password_hash nullable; V67: role CHECK added |
entra_external_identities | issuer, subject (= oid), user_id, last_login_at | V64: created; UNIQUE(issuer,subject), UNIQUE(user_id,issuer); RLS: V66 |
permissions | permission_key (PK, CHECK format) | V67: 52 keys; global catalog, no RLS, GRANT SELECT bilko_app |
role_permissions | role, permission_key | V67: exhaustive flat seed; V68: users:manage + users:invite added |
Evidence Files
- WP1 verification:
/tmp/evidence-103141/wp1-verification.md, proveo-verdict.json - WP2 verification:
/tmp/evidence-103142/wp2-verification.md, proveo-wp2-verdict.json - WP3 Proveo:
/tmp/evidence-103143/proveo-wp3-validation.md - WP4 verification:
/tmp/evidence-103144/wp4-verification.md - WP5 E2E:
/tmp/evidence-103145/wp5-e2e.md, verification.json - Phase 0 config:
/tmp/evidence-103076/phase0-config.md
ADR-037 -- Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred
ADR-037 — Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred
| Field | Value |
|---|---|
| ADR number | ADR-037 |
| Date | 2026-06-08 |
| Status | Accepted |
| Author | John (AI Director, ALAI Holding AS) |
| CEO decision | Alem Basic — confirmed 2026-06-07 (CEO resolution addendum, MC #103075) |
| Related MCs | MC #103075 (Entra migration plan), MC #103141–103146 (WP1–WP6 execution), MC #103089 (multi-org, parked) |
| Supersedes | Existing 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:
- Replace email/password authentication with Microsoft Entra External ID (CIAM) — hard REPLACE, not phased coexist
- 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.
- Entra issues tokens; Bilko backend validates JWKS RS256 signature, issuer, audience
- Bilko reads
oidfrom the Entra token as the sole identity anchor (subis pairwise-pseudonymous per app and must NOT be used) - Bilko issues its own access + refresh tokens after Entra token exchange; downstream services consume Bilko tokens, not Entra tokens directly
- Role and permission data live in
users.role+role_permissions(Bilko DB). No role or permission claims are read from Entra tokens
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
- Authentication complexity moved to Microsoft (password policies, MFA, SSPR, account lifecycle)
- Bilko no longer stores password hashes for new users (
password_hashis nullable) - Permission model is auditable and admin-configurable without code changes (role-to-permission seed is data)
- Authz decisions are logged (
AuthzAuditLogger) for incident investigation - Admin UI for user + role management (no more raw SQL for role changes)
Negative / Trade-offs
- Entra CIAM has MAU-based pricing; cost gate was raised (OC#1, MC #103075) — free tier starts June 2026
- 7-day refresh token revocation window: a disabled Entra account remains valid in Bilko for up to 7 days (documented risk OC#4; mitigation: admin disables user in Bilko DB)
- Email-match JIT carries race risk if email is mutable or duplicated (martin-kleppmann + bruce-momjian dissent on record); serializable transaction is a partial mitigation; pre-provision-by-OID is the recommended production path
- Single-role v1 limits fine-grained delegation scenarios (e.g. "viewer + approve-only on specific documents") — documented as out of scope
Alternatives Considered
| Alternative | Rejected 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
- OC#4 — Refresh revalidation vs risk acceptance: Option A (revalidate Entra account status on refresh, ~50ms) vs Option B (7-day window, documented risk). Requires CEO/Securion explicit decision. Code stub for Option A is in
AuthService.ktreferencing MC #103075. - OC#2 — Hard REPLACE confirmed but AuthProvider interface (D5, MC #103075) enables reversion if needed.
- Web direct-bearer vs mobile exchange (parisa-tabriz dissent LIVE): Web: MSAL acquires Entra access token, sends as Bearer to API. Mobile: id_token exchange at
POST /auth/entra/session. Web direct-bearer is implemented; exchange path preserved as commented stub per spec.
Document Links
- Bilko Authentication — Entra External ID (CIAM)
- Bilko RBAC — Users / Roles / Permissions
- Bilko Auth Migration Runbook + Admin Guide
- Source plan:
/Users/makinja/system/specs/bilko-web-entra-cutover-and-rbac-plan-2026-06-08.md - Forged prompt (panel dissent log):
/Users/makinja/system/prompts/forged/103075.md - Phase 0 config:
/tmp/evidence-103076/phase0-config.md
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
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
| Property | Value |
|---|---|
| Tenant name | bilkociam |
| Tenant ID | 20bb17de-9be5-4143-a7e5-8c1ddae6a064 |
| Tenant type | CIAM (Entra External ID) |
| SPA app name | Bilko Web (SPA) |
| SPA client ID | c2902239-ea63-41bd-8619-6cf096d7d45a |
| API resource app ID | fe39e0f5-513e-40af-93f0-c3ee624df56c |
| Authority URL | https://20bb17de-9be5-4143-a7e5-8c1ddae6a064.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0 |
| OIDC issuer | same as authority URL (confirmed via discovery endpoint) |
2.1 User flow: BilkoSignUpSignIn
| Property | Value |
|---|---|
| Flow ID | aa86084b-01dc-453f-9e10-679dfefdd824 |
| Type | externalUsersSelfServiceSignUpEventsFlow |
| Display name | BilkoSignUpSignIn |
| Identity provider | EmailOtpSignup-OAUTH (Email One Time Passcode) |
| isSignUpAllowed | true |
| userTypeToCreate | member (not guest) |
| Attributes collected | email (auto-filled by OTP verification) |
| Linked app | c2902239-ea63-41bd-8619-6cf096d7d45a (Bilko Web SPA) |
2.2 Registered SPA redirect URIs
- https://app.bilko.cloud/auth/callback and https://app.bilko.cloud
- https://app.bilko.company/auth/callback and https://app.bilko.company
- https://app.bilko.io/auth/callback and https://app.bilko.io
- https://bilko-demo.alai.no/auth/callback and https://bilko-demo.alai.no
- https://bilko-web-stage-dh4m46blja-lz.a.run.app/auth/callback and .a.run.app
- http://localhost:3000/auth/callback and http://localhost:3000
2.3 Adding identity providers or attributes
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)
| Migration | Purpose |
|---|---|
| V66__entra_rls_and_password_nullable.sql | Makes 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.sql | RBAC permissions catalog seeding. |
| V68__rbac_user_provisioning.sql | SECURITY 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.sql | RLS 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
| Property | Value |
|---|---|
| Deploy trigger | bilko-main-deploy (europe-north1, project tribal-sign-487920-k0) |
| Trigger type | semver tag on main: git tag vX.Y.Z && git push origin vX.Y.Z |
| Config | infrastructure/gcp/cloudbuild.yaml |
| Current live tag | v0.2.47 (commit 30a8c85) |
| Web revision | bilko-web-demo-00080-tq5 |
| API revision | bilko-api-demo-00155-524 |
| CIAM env vars in trigger | NEXT_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
| ID | Priority | Description |
|---|---|---|
| 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:
| Step | Result | Details |
|---|---|---|
| 1 | PASS | Self-serve copy present; "Contact your administrator" absent |
| 2 | PASS | "Sign in with Microsoft" → ciamlogin.com (tenant 20bb17de) redirect |
| 3 | PASS | Email entered on CIAM; OTP sent immediately |
| 4 | PASS | Returning user — OTP sent directly (no create-account needed) |
| 5 | PASS | 8-digit OTP (17717965) received via Gmail UID:75644 in 7 seconds |
| 6 | PASS | Redirect back to bilko-demo.alai.no/dashboard |
| 7 | PASS | POST /auth/entra/session → 200, Bilko HMAC JWT, org 4e96b6ff confirmed |
| 8 | PASS | /dashboard with trial UI ("Probno: 6 dana preostalo"), /auth/me → 200 + trialEndsAt 2026-06-15 |
| 9 | PASS | /invoices via SPA nav — empty org (0 invoices), session alive |
| 10 | PASS | /invoices/new — invoice form visible, trial tenant usable |
| 11 | PASS | Regression 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
- CRITICAL: None found.
- HIGH: H1 (JIT provisioning rate gate) — must fix before scale launch (MC #103245).
- PASS areas: RS256 JWKS verification, issuer/audience pinning, OID as identity anchor, alg:none bypass blocked, org_id derived from DB (not Entra token), RLS fail-closed, FORCE RLS on all 9 tenant tables, role=viewer hardcoded (no self-escalation), trial re-signup blocked, refresh token rotation (jti-based single-use), legacy auth endpoints retired (HTTP 410).
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
| Variable | Service | Correct value note |
|---|---|---|
| NEXT_PUBLIC_ENTRA_CLIENT_ID | bilko-web-demo (build-time) | c2902239-ea63-41bd-8619-6cf096d7d45a (SPA app) |
| NEXT_PUBLIC_ENTRA_AUTHORITY | bilko-web-demo (build-time) | https://[tenant-id].ciamlogin.com/[tenant-id]/v2.0 — no user flow suffix needed |
| NEXT_PUBLIC_ENTRA_SCOPE | bilko-web-demo (build-time) | api://fe39e0f5.../access_as_user |
| ENTRA_EXTERNAL_ID_ISSUER | bilko-api-demo | https://[tenant-id].ciamlogin.com/[tenant-id]/v2.0 |
| ENTRA_EXTERNAL_ID_AUDIENCE | bilko-api-demo | c2902239-ea63-41bd-8619-6cf096d7d45a (SPA client ID — NOT the API resource ID) |
| ENTRA_EXTERNAL_ID_JWKS_URL | bilko-api-demo | https://[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:
- Phase 1 — Internal copilot (current): Assists ALAI/SnowIT support staff via ticketing workflow; human approves and sends answers. No direct customer exposure.
- Phase 2 — Customer-facing widget (planned, HELD): In-app chat widget on
bilko.cloudthat auto-drafts answers intriageJsonwith guardrails (PII allowlist, prompt-injection guard, scope, escalation). Requires Securion security review before go-live.
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
| Role | Agent | Registry | Job |
|---|---|---|---|
| Lead architect | Petter Graff | ~/.claude/agents/petter-graff.md | Owns architecture, gates phases, integration design |
| Research (Phase 0) | Datavera / Explore | ephemeral dispatches | HR support taxonomy, competitor patterns, KB extraction (completed) |
| Support persona | bilko-support | ~/.claude/agents/bilko-support.md | Orchestrates answer pipeline, enforces anti-hallucination, escalation |
| HR accounting expert #1 | bilko-racunovodstvo-hr | ~/.claude/agents/bilko-racunovodstvo-hr.md | Dvojno knjigovodstvo, RRiF chart of accounts, GL, expenses, financial statements (RDG/bilanca); KB author + validation gate |
| HR tax/fiscalization expert #2 | bilko-porez-fiskalizacija-hr | ~/.claude/agents/bilko-porez-fiskalizacija-hr.md | PDV (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:
bilko-support→ companyALAI, domainproduct-supportbilko-racunovodstvo-hr+bilko-porez-fiskalizacija-hr→ companyFinverge, domainhr-accounting
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:
- Verifier verdict =
HALLUCINATION(fabricated citation) - Unknown question (no KB match, no LightRAG answer)
- Accounting/tax claim: no domain-specific KB evidence OR domain-verified confidence < 0.70
- Confidence < 0.70 on cache hit for acct/tax/e-invoice (uses same
belowAccountingFloor()helper as pipeline path) - Severity P1 (critical production blocker)
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.
~/system/reports/bilko-kb/hr-tax-fiscalization.jsonl— 20 pairs (PDV rates/deadlines, HR-FISK 2.0/eRačun B2B mandatory 2026-01-01, OIB validation ISO 7064 MOD 11,10, FINA rejection reasons, B2G since 2019-07-01, UBL 2.1/EN 16931, EUR since 2023-01-01)~/system/reports/bilko-kb/hr-accounting.jsonl— 20 pairs (RRiF chart classes 0-7, GL journal on invoice lifecycle sent→paid, expense approve→paid, storno/cancel logic, tečaj lock, RDG/bilanca/trial balance, fiscal year 1.1.–31.12., audit trail 7y retention)
Sources cited:
~/business/ALAI-Holding-AS/products/Bilko/docs/regulatory/CROATIA-ERACUN.md(authoritative HR doc, HIGH confidence)~/business/ALAI-Holding-AS/products/Bilko/packages/domain-hr/src/tax/index.ts~/business/ALAI-Holding-AS/products/Bilko/packages/domain-hr/src/chart/index.ts(RRiF CoA implementation)~/business/ALAI-Holding-AS/products/Bilko/packages/domain-hr/src/fisk/index.ts(OIB validateOIB)~/business/ALAI-Holding-AS/products/Bilko/packages/domain-hr/src/filing/index.ts(eRačun UBL 2.1 generation)~/business/ALAI-Holding-AS/products/Bilko/docs/backend/BUSINESS-LOGIC.md
Each entry has: {q, a, source, confidence: "HIGH" | "MEDIUM", domain: "hr-tax-fiscalization" | "hr-accounting"}.
Confidence levels:
- HIGH — direct from CROATIA-ERACUN.md or implemented code (domain-hr packages)
- MEDIUM — inferred deadlines or regulatory interpretations (recommend user confirm with Porezna uprava / FINA / certified accountant)
6. Integration with Bilko Backend
Existing infrastructure (reused, not built for this):
apps/api/src/routes/SupportTicketRoutes.kt(437 lines)support_ticketstable (org_id, subject, description, status open/in_progress/resolved/closed, priority P1–P4, created_at, updated_at)POST /support/tickets— idempotency key(org_id, request_id)GET /admin/support/tickets,PATCH /admin/support/tickets/:id- PII-guarded
context_bundlefield (errorCode, appRoute, planTier, locale — allowlist enforced server-side) - Reserved field:
triageJson(line 62 comment: "for AI assistant V2") — this is the Phase 2 hook
Phase 1 workflow:
- Staff create/triage ticket via
~/system/tools/support-ticket.js(SLA P1–P4) - Call
node ~/system/tools/bilko-support-answer.js "" '' - Agent returns JSON with answer + evidence + classification + escalate flag
- Human reviews, approves, sends to customer (via email / admin panel)
Phase 2 workflow (planned, HELD):
- Customer opens in-app chat widget on
bilko.cloud - Widget calls
POST /support/ticketswithcontext_bundle - Backend triggers
bilko-support-answer.js(via internal job queue or sync call) - Agent output written to
triageJsonfield:{suggestedAnswer, classification, severity, escalate, confidence, evidencePaths} - Render in UI: if
escalate:falseANDconfidence >= 0.70→ show AI answer with citation; else → "Eskaliran na podršku, očekujte odgovor u " - 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:
| MC | Severity | Issue |
|---|---|---|
| #104441 | P3 | Bilko MVP GL: only 1 D/P per transaction → PDV not split to konto 450 Obveze za PDV (tracking via reports only, not GL) |
| #104446 | P3 | OIB validation: CROATIA-ERACUN.md missing ISO 7064 MOD 11,10 detail (in code, not doc) |
| #104447 | P2 | CoA setup friction: not available in wizard, only post-onboarding in Settings (USER-ONBOARDING.md gap) |
| #104442 | P3 | Storno logic: cannot cancel paid invoices (needs credit note, not auto-generated in MVP) |
| #104448 | P4 | BUSINESS-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
| MC | Task | Owner |
|---|---|---|
| #104432 | Proveo UAT — end-to-end verification: empty-KB degradation, offline-model fallback, severity classification coverage, schema validation, real ticket replay | Proveo / Angie Jones |
| #104430 | Phase 1 deployment — staff onboarding, support-ticket.js workflow integration, SLA monitoring | FlowForge / Kelsey Hightower |
| #104431 | Phase 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 tests | Securion / 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:
- Tax Q 'PDV na knjige' → 5% grounded (CROATIA-ERACUN.md + domain-hr/src/tax)
- Unknown Q →
escalate:true,source:escalated - Pipeline tax conf 0.62 →
escalate:true - Cache tax conf 0.6547 →
escalate:true(floor enforced on cache path) - Cache tax conf 1.0 →
escalate:false(no over-escalation) belowAccountingFloordefined once (L62), called L139 (cache) + L258 (pipeline) — no threshold drift
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
- Reject action on
received_einvoice(inbound/AP e-invoice): transitionsprocessing_statusand records a Bilko-internal rejection reason code, reject timestamp, and eIzvještavanje transmission tracking fields. RejectionReportService: builds the monthly batch of rejected inbound e-invoices Bilko must report to Porezna uprava under čl.52 (rok: 20th of the month, for the prior calendar month — domain-gate CONFIRMED, web-verified via 6 convergent sources, distinct from the separate NN 151/2025 PDV-filing deadline change).
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:
- Porezna's actual N/U/O are three different eIzvještavanje REPORT TYPES, not three rejection sub-reasons:
- N = Naplata — issuer reports collection/payment of an issued e-invoice (unrelated to rejection)
- U = Usluge/Isporuke — paper-fallback reporting when an e-invoice couldn't be issued (unrelated to rejection)
- O = Odbijanje — recipient reports a REJECTED inbound e-invoice under čl.52. This is the only one relevant to Bilko's use case.
- Every row Bilko reports to Porezna is type O — there is no per-rejection N/U/O choice on the Porezna side.
- Fix applied:
received_einvoice.reason_codeis documented (Kotlin KDoc + V140 SQL comment) as Bilko's own internal rejection-reason taxonomy for the UI/audit trail (satisfying čl.52 st.1's "obrazloženo"/justified requirement), reusing the N/U/O letters only for DB/product convenience — not a claim of matching Porezna's N/U/O. RejectionReportService.RECORD_TYPEis hardcoded to"O"and is never derived fromreceived_einvoice.reason_code. Tested explicitly (RejectionReportIntegrationTesttest 5:recordType == "O"whilebilkoReasonCode == "U", asserted as two independently-preserved fields).
Full domain-gate detail: ~/system/evidence/106149/domain-gate-verdict.md
Schema
| Migration | Contents |
|---|---|
V140__received_einvoice_reject_reason.sql | Adds 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.sql | Two 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:
- Cross-org RLS blind spot.
RejectionReportService's monthly batch query initially used a raw Exposedtransaction{}againstreceived_einvoice(FORCE ROW LEVEL SECURITY, V139) with noapp.current_org_idset. 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. - SECURITY DEFINER without table GRANT. After adding the SECURITY DEFINER functions, they still failed with "permission denied for table received_einvoice" —
bilko_adminhas BYPASSRLS (V30_1/V32) but is not superuser and never received an explicit GRANT on this specific table (V139 only grantedbilko_app). Fixed by adding the GRANT in V141, following the exact precedent already set by V33/V39/V100/V111 for other tablesbilko_adminneeded 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
- Actual HTTP submission to Porezna is not implemented.
transmitToPorezna()is a clearly-marked TODO stub that always returnsfalse(even withSVERACUN_HR_LIVE=true), with a loudlog.warnexplaining why.submitMonthlyReport()therefore never falsely claims "sent". - Reason: payload/schema for the real eIzvještavanje submission is not confirmed at field level.
https://porezna-uprava.gov.hr/fiskalizacija/api/dokumenti/157returns HTTP 200 but serves a client-rendered SPA shell ("Naslovna"), not the raw document body — the literal field-level schema needs either human/browser (JS-rendering) access to that page, or the FINA/Porezna eIzvještavanje XSD. - No cron wiring yet. The
POST /internal/compliance/rejection-reportendpoint is not wired to an active ACA scheduled trigger in this task — same "safe to deploy inert" posture as the existing send-reminders sibling endpoint. FlowForge/John to wire per the KDoc go-live note. - No UI. API-only, M-tier task, out of scope per the original brief.
Verification performed
./gradlew compileKotlin compileTestKotlin— clean.RejectionReportIntegrationTest(new, Testcontainers postgres:16-alpine, real V1..V141 Flyway chain, no mocks): 8/8 passed. Covers reject persistence + status transition, no-op same-reason re-reject vs tracked different-reason correction, org isolation both directions, rejecting an already-accepted row throws, report payload recordType=O with correct bilkoReasonCode, non-rejected rows excluded, transmission state null→pending (never falsely 'sent'), report_period stamping, and idempotency (a row already 'sent' for a period is not re-transmitted).- Regression: pre-existing
ReceivedEInvoiceIntegrationTest(MC #106162) re-run after V140/V141/Tables.kt changes — still 0 failures. - Full suite:
./gradlew test— 1808 tests, 25 failed, all 25 isolated to 3 unrelated pre-existing classes (root cause:SVERACUN_SENDER_VAT not configured, a local-env config gap in outbound sveRacun e-invoicing, grep-verified unrelated to ReceivedEInvoice*/RejectionReport*/ComplianceCron*).
Branch / merge status
- Branch:
feat/task-106149-reject-report, commitfaf3aa78. - Based on:
feat/task-106162-inbound-einvoice-persist, commit06812347(V139received_einvoicetable + persist). - Neither branch is merged or pushed to azdo main. No PR opened yet.
- Built in isolated worktree
.claude/worktrees/codecraft-106149— the main Bilko working tree was left untouched (it had unrelated in-flight work onfeat/task-106147).
Next steps
- Human/browser session to pull field-level schema from
porezna-uprava.gov.hr/fiskalizacija/api/dokumenti/157(or FINA XSD) before implementing realtransmitToPorezna(). - Proveo end-to-end verification (reject → report, real evidence).
- Open PR:
feat/task-106162-inbound-einvoice-persist→ main, thenfeat/task-106149-reject-report→ main (or squash/rebase as one PR chain). - FlowForge: wire cron trigger for
POST /internal/compliance/rejection-report. mc.js readyfor #106149/#106146 after Proveo pass.
Source evidence
~/system/evidence/106149/build-verdict.md— full build verdict (CodeCraft, agent idcodecraft-106149, PASS)~/system/evidence/106149/domain-gate-verdict.md— domain/legal correction (agentbilko-porez-fiskalizacija-hr, CORRECTION NEEDED on Q1, applied)
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
| # | Severity | Defect | Fix |
|---|---|---|---|
| 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
- Repo:
Bilko(canonical remoteazdo) - Branch:
feat/106259-admin-contract-fix, created offazdo/mainatcb3746d6 - Commit:
5513d0e6339864b31c2d3507ca0219ede5b052ba— pushed toazdo, verified on remote viagit ls-remote - 8 files changed:
AdminPortalRoutes.kt,AdminOrgService.kt,AdminPortalRoutesHttpIntegrationTest.kt(new tests T16–T19b),orgs/[id]/page.tsx,orgs/page.tsx, admin dashboardpage.tsx,lib/api.ts, and a new frontend test fileadmin-contract-106259.test.tsx
Test results
- Backend:
AdminPortalRoutesHttpIntegrationTest— 23/23 passed (9 new tests T16–T19b, one per fix + auth-gate coverage, plus 14 pre-existing T1–T15 unmodified, zero regressions). Sibling admin suites unmodified and green:AdminPortalRoutesP2Test10/10,AdminRoutesHttpIntegrationTest6/6. - Frontend: new
admin-contract-106259.test.tsx— 9/9 passed (React Testing Library, real rendered component tree — MRR undefined/null guards, real-number formatting, vatMethod/trialStatus/userCount render, nestedmeta.total/meta.totalPagesreads, pagination-controls visibility). Full productionnext buildsucceeds (65/65 routes),tsc --noEmitclean, pre-pushturbo run type-checkacross all 12 workspace packages green. - Independent verification (Angie Jones / Proveo) — read code directly in the isolated worktree at commit
5513d0e6(did not trust the builder's self-report), re-ran backend integration tests and frontend Vitest herself, and diffed the commit against base to confirm scope boundaries. Verdict: PASS on all 4 items. Confirmed independently via her own grep that no aggregate MRR computation exists anywhere in the codebase, and via direct diff that zero auth files were touched.
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
AdminAuthPlugin.kt,JwtService.kt,Authentication.kt, and thebilko-jwtverifier — confirmed untouched by direct diff (git diff cb3746d6 5513d0e6 --name-only, zero overlap). Angie's original catalog established that the/admin/orgs401 CEO/UAT saw was a stale/expired access-token test artefact, not a server-side auth defect — nothing in the auth stack needed fixing./admin/users405 — by design, only aPOSThandler exists; no frontend page callsGET /admin/users. Not a bug.- "Report error" support-ticket button — confirmed working live during the original catalog investigation (real
POST /support/tickets→ 201). - Flyway V146 warning — not reproducible; DB and deployed image agreed on schema version at investigation time.
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
- Defect catalog (spec):
~/system/evidence/106258/admin-defect-catalog.md - Build evidence:
~/system/evidence/106259/build-evidence.md - Independent verify (PASS):
~/system/evidence/106259/angie-verify-2026-07-24.md
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):
- Half 1 —
middleware.tsbuilt the login-redirect URL from the request pathname only. The query string was silently dropped before the user ever saw the login screen. - Half 2 — even where a
redirectparameter did survive, nothing in the app read it. The login success handler hardcodedrouter.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:
| Property | Value |
|---|---|
| Storage key | bilko_post_login_redirect (STASH_KEY, safe-redirect.ts:92) |
| Storage mechanism | sessionStorage, 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) |
| Written | stashRedirectTarget() — one call site, use-entra-auth.ts:214, inside signInWithMicrosoft(), immediately before loginRedirect() |
| TTL | 10 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 |
| Sanitized | on 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.
| Consumer | File:line | Scoped to genuine post-Entra return? |
|---|---|---|
handleEntraLogin | lib/msal/use-entra-auth.ts:170 | YES — this function only runs after MSAL fires LOGIN_SUCCESS |
| Authenticated-landing safety net | lib/auth-provider.tsx:109 | NO — 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:
components/top-bar.tsx:203—await useAuthStore.getState().logout(), the user-menu "Log out" buttoncomponents/DemoBannerWrapper.tsx— "exit demo" also routes through the reallogout()
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.
- The true post-Entra return (bare origin, code in the fragment) has no query at all —
window.location.searchis empty (§2.1). The stash wins here by default; param-vs-stash does not even arise. - The contested case is
/login?redirect=Xreached with a livebilko_authcookie but no in-tab MSAL session — e.g. a link opened in a new tab, or a repeat visit later the same day. HereXwas written seconds ago, bymiddleware.ts:93orauth-provider.tsx:126, from the path the user just requested. The stash, by construction, is older — written up to 10 minutes earlier, at the start of a previous sign-in.
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:
- The authorize request the app sends to Entra (redirect_uri = bare origin, response_mode = fragment) — captured from a real headless click on the "Sign in with Microsoft" button, no password entered.
- The mechanism the whole fix rests on — a value written to
sessionStorageonapp.bilko.cloud, invisible while the browser is on the CIAM host (that is the trap this fix exists to survive), then intact again back onapp.bilko.cloudwith an emptywindow.location.search— reproduced against production with a real navigation to the CIAM host and back. - The
/demojourney end to end, including the open-redirect guard surviving a real navigation decision, on a production build.
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.
8. Related open tickets
| MC # | Status | What it is |
|---|---|---|
| #106423 | OPEN | Safety-net caller (auth-provider.tsx:109) not re-scoped like /demo was — §4.2 above. Same bug class, one caller behind. |
| #106412 | OPEN | Both the locale choice AND the redirect stash leak on session expiry / token-drop — four token-drop paths in lib/api.ts (lines 413, 476, 1028, 2835 on current main) do not clear either. Note: traced from the code, not yet live-verified in a browser; the stash's own writer-overwrite property (§3) makes its exposure narrower than the locale cookie's. |
| #106421 | OPEN | Dead signOutFromEntra function — §5 above. Possibly a security gap (Entra session not ended on Bilko logout) rather than simply dead code; do not close as "delete" until that's checked. |
| #106422 | OPEN | A separate, deliberately-not-merged-back-in question: whose language choice wins when an anonymous visitor's locale pick meets an org default on first login. Related to the same stash/cookie mechanism family but a distinct product decision, not a bug in this mechanism. |
9. Source
Verified against azdo/main @ 50e4223a (2026-07-28), which includes the merge commit 480d95ca (PR 272, MC #106384).
apps/web/middleware.tsapps/web/lib/safe-redirect.tsapps/web/lib/msal/use-entra-auth.tsapps/web/lib/msal/msal-config.tsapps/web/lib/auth-provider.tsxapps/web/lib/stores/auth-store.tsapps/web/app/(auth)/demo/page.tsxapps/web/components/top-bar.tsx
Evidence: ~/system/evidence/106384/fix-verdict.md, witness-verdict.md (two rounds), peer-verify-verdict.md (independent, post-merge, verdict PARTIAL).