Skip to main content

Architecture Decision Records (ADR)

Architecture Decision Records — Bilko

Project: Bilko Version: 1.1 Date: 2026-02-24 Author: Petter Graff (ADR-001 to ADR-006), Security-Test-Writer Agent (ADR-007 to ADR-013) Status: Active

Document History

Version Date Author Changes
0.1 2026-02-23 Petter Graff Initial draft — ADR-001 to ADR-006
1.1 2026-02-24 Security-Test-Writer Added ADR-007 to ADR-013 — auth, state, framework, hosting, ORM, validation, router

ADR-001 — Turborepo Monorepo with Separate Packages

ADR Number: ADR-001 Title: Use Turborepo monorepo with separate npm packages for core and country plugins Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko is a multi-country accounting SaaS with: (1) a Next.js frontend, (2) an Express backend, (3) a country-agnostic accounting engine, and (4) three country-specific regulatory plugins. These components share TypeScript types and business logic but must be independently versioned and testable.

1.2 Forces & Constraints

Technical forces:

  • TypeScript types (Prisma models, API response shapes) must be shared between frontend and backend without duplication
  • Country plugins must be independently releasable (a VAT rate change in Serbia should not require redeploying Croatia logic)
  • Build times must remain fast as the codebase grows

Business forces:

  • MVP must launch quickly; tooling complexity must be low
  • Team is small — cannot manage multiple separate repositories

1.3 Problem Statement

We need to decide: How to organize the Bilko codebase so that frontend, backend, accounting engine, and country plugins share code but can be independently versioned and tested.

2. Decision

We will: Use Turborepo to manage a monorepo with the structure apps/web, apps/api, packages/core, packages/database, packages/country-{rs|ba|hr}, packages/ui.

Rationale: Turborepo provides incremental builds, shared TypeScript configuration, and workspace management without the overhead of publishing packages to npm. The plugin architecture is enforced at the package boundary — country plugins import @bilko/core but not each other.

3. Alternatives Considered

Option A: Turborepo Monorepo ← Selected

Pros:

  • Fast incremental builds — only rebuilds changed packages
  • Shared TypeScript types across all apps and packages
  • Country plugins are independent packages — different release cadence per country regulation
  • Single repo — one PR, one CI run, one git history

Cons:

  • Turborepo learning curve for new developers
  • All packages share the same git repo — sensitive to large PRs

Cost/Effort: Low — Turborepo setup is ~1 day

Option B: Separate Git Repositories (polyrepo)

Pros:

  • Maximum isolation between packages
  • Per-repo CI/CD pipelines

Cons:

  • Type sharing requires publishing to npm registry — adds publish/version management overhead
  • Cross-repo refactoring is painful
  • Slow for a small team

Why not chosen: Type sharing overhead is too high for MVP pace.

Option C: Single Package (everything in one apps/api and apps/web)

Pros:

  • Simplest setup — no workspace config

Cons:

  • Cannot independently version or test the accounting engine
  • Country tax logic is tangled with API routes
  • Cannot reuse accounting engine in future products

Why not chosen: Violates separation of concerns; makes future product reuse impossible.

4. Consequences

4.1 Positive Consequences

  • @bilko/core can be unit tested without a database
  • Country plugins can update VAT rates in a hotfix without touching API or frontend code
  • Prisma types from @bilko/database are shared — no duplicated interfaces

4.2 Negative Consequences

  • Turborepo turbo.json pipeline config must be maintained as packages grow — Mitigation: document pipeline in PIPELINE.md

4.3 Technical Debt Created

  • packages/ui is an empty scaffold at MVP — not yet used — Plan: populate with shared shadcn components in v1.1

ADR-002 — NUMERIC(19,4) and Decimal.js for All Monetary Values

ADR Number: ADR-002 Title: Use PostgreSQL NUMERIC(19,4) and Decimal.js for all monetary amounts — never IEEE 754 float Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko is an accounting system. Every stored value represents money. JavaScript's native number type uses IEEE 754 double-precision floating point, which cannot represent 0.1 exactly — 0.1 + 0.2 === 0.30000000000000004. This is catastrophic for financial software where rounding errors cause balance mismatches that fail audits.

1.2 Forces & Constraints

Technical forces:

  • Accounting software requires exact decimal arithmetic — not approximations
  • PostgreSQL's NUMERIC type supports arbitrary precision; FLOAT does not
  • Prisma maps NUMERIC to its Decimal type (backed by decimal.js)

Regulatory:

  • Serbian, Croatian, and BiH tax law require monetary precision to 4 decimal places for VAT calculations

1.3 Problem Statement

We need to decide: What data type to use for all monetary values in the database and application layer.

2. Decision

We will: Store all monetary values as NUMERIC(19,4) in PostgreSQL and use Decimal.js (via Prisma's Decimal type) in all application code. JavaScript number is prohibited for any value representing money.

Rationale: NUMERIC(19,4) can represent amounts up to 999,999,999,999,999.9999 (sufficient for SMB range). Decimal.js provides arbitrary-precision arithmetic. The combination eliminates all floating-point rounding errors.

3. Alternatives Considered

Option A: NUMERIC(19,4) + Decimal.js ← Selected

Pros:

  • Exact decimal arithmetic — 0.1 + 0.2 = 0.3000 exactly
  • Range: up to ~1 quadrillion — sufficient for any SMB
  • Prisma's Decimal type integrates seamlessly

Cons:

  • Developers must remember to never use number for money
  • Decimal.js operations are slightly more verbose: new Decimal(a).plus(b) vs a + b

Option B: Store as integers (minor units — e.g., paras/cents)

Pros:

  • Integer arithmetic is exact with no library needed
  • Common approach in payment systems (Stripe stores cents)

Cons:

  • Accounting requires 4 decimal places — minor units would need to be in 1/10000 of a currency unit (unusual)
  • Prisma schema, API responses, and display logic all require conversion
  • Confusing for accountants reviewing data directly

Why not chosen: 4-decimal-place requirement makes minor-unit storage awkward.

Option C: JavaScript number / PostgreSQL DOUBLE PRECISION

Pros:

  • No library needed
  • Simple arithmetic operators

Cons:

  • Fatal flaw: 0.1 + 0.2 !== 0.3 — causes balance sheet mismatches in any real scenario
  • Cannot pass an audit with floating-point rounding errors in ledger balances

Why not chosen: Fundamentally incompatible with financial software requirements.

4. Consequences

4.1 Positive Consequences

  • Trial balance always balances to exactly zero — totalDebits.equals(totalCredits)
  • VAT calculations are exact — no rounding surprises for Serbian PDV declarations
  • API responses return monetary values as strings (e.g., "120000.0000") — clients parse as Decimal

4.2 Negative Consequences

  • All API clients must handle monetary values as strings, not numbers — Mitigation: document in API spec; TypeScript types enforce string for monetary fields

ADR-003 — Double-Entry Bookkeeping as the Core Transaction Model

ADR Number: ADR-003 Title: Use double-entry bookkeeping as the sole transaction model — every financial event creates one Transaction with debit and credit Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko must generate legally valid accounting reports: profit & loss, balance sheet, trial balance, VAT return. These reports require a general ledger where every financial event is recorded as a balanced journal entry. Many SaaS products use simpler "append events to a list" approaches that work for dashboards but fail for formal accounting.

1.2 Forces & Constraints

Regulatory:

  • Serbian, BiH, and Croatian accounting law require double-entry bookkeeping for legal entities
  • VAT filings require a precise split between output VAT (from revenue) and input VAT (from expenses)

Technical forces:

  • Double-entry enables the Trial Balance to always balance — a fundamental correctness check
  • It enables any accounting report to be derived from the same Transaction table

1.3 Problem Statement

We need to decide: How to store financial events — simple event list vs. double-entry journal.

2. Decision

We will: Use double-entry bookkeeping as the sole transaction model. Every financial event creates exactly one Transaction record with debitAccountId, creditAccountId, and amount. The validateDoubleEntry() function in @bilko/core is called before every Prisma transaction to enforce balance.

3. Alternatives Considered

Option A: Double-Entry Bookkeeping ← Selected

Pros:

  • Legally correct for all three target countries
  • Enables Trial Balance, Balance Sheet, P&L from one table
  • isBalanced check provides immediate data integrity verification

Cons:

  • Higher implementation complexity — developers must understand debit/credit semantics
  • Every status change requires finding correct GL accounts

Option B: Simple Ledger (credit/debit as +/- amounts, single account per row)

Pros:

  • Simpler to implement and understand
  • Easier to query (no join to debit/credit accounts)

Cons:

  • Cannot produce a legally valid trial balance
  • VAT report requires cross-referencing invoices and expenses separately — cannot derive from ledger
  • Not compliant with Pravilnik (Serbia), RRiF (Croatia), or FBiH Pravilnik (BiH)

Why not chosen: Regulatory non-compliance disqualifies this option.

Option C: No ledger — compute reports directly from invoices/expenses tables

Pros:

  • Simplest: no separate transaction table
  • Fast to build for MVP

Cons:

  • Cannot produce a balance sheet (assets, liabilities, equity) without a ledger
  • Cannot handle manual journal entries (adjustments, corrections)
  • Cannot reconcile bank transactions against GL

Why not chosen: Insufficient for formal accounting; blocks future chartered accountant use.

4. Consequences

4.1 Positive Consequences

  • All accounting reports (P&L, balance sheet, cash flow, trial balance) derive from the same Transaction table
  • locked = true on transactions creates an immutable audit trail
  • reconciled = true flag enables bank reconciliation

4.2 Technical Debt Created

  • validateDoubleEntry() is called in application code, not enforced by DB constraint — debitAmount = creditAmount relies on application logic — Mitigation: unit test every code path that creates transactions

ADR-004 — Lock Exchange Rates at Transaction Date

ADR Number: ADR-004 Title: Lock exchange rates at transaction date — never recalculate historical amounts Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko supports multi-currency invoicing (EUR, RSD, BAM, HRK, USD). When an invoice is created in a foreign currency, it must be converted to the organization's base currency for the general ledger. Exchange rates change daily. The question is: should historical amounts be recalculated when rates change, or locked at transaction date?

1.2 Forces & Constraints

Regulatory:

  • All three accounting systems (Serbian, BiH, Croatian) require that historical financial records show the exchange rate in effect at the transaction date — recalculation is not permitted for recognized transactions

Technical forces:

  • Recalculating historical amounts on every rate update would invalidate the trial balance and all prior period reports

1.3 Problem Statement

We need to decide: Whether to lock exchange rates at transaction date or recalculate dynamically.

2. Decision

We will: Lock the exchange rate at invoiceDate (or expenseDate) and store it permanently in the exchangeRate field. baseAmount = totalAmount × exchangeRate is computed once and stored. Neither field is ever updated after creation.

3. Alternatives Considered

Option A: Lock at Transaction Date ← Selected

Pros:

  • Legally compliant — historical records reflect the rate in effect at the time
  • baseAmount is stable — prior period reports never change
  • No complex recalculation logic

Cons:

  • If no rate exists for the exact date, fallback to nearest date — risk of slight inaccuracy
  • Exchange rate population is a prerequisite for accuracy

Option B: Recalculate on every report

Pros:

  • Always uses the current rate
  • Simpler — no need to store historical rates

Cons:

  • Illegal under Balkan accounting standards — invoices must show the rate at issue date
  • Prior period P&L reports would change every time rates update
  • Audit trail becomes meaningless if historical amounts drift

Why not chosen: Regulatory non-compliance.

4. Consequences

4.1 Positive Consequences

  • Invoice exchangeRate and baseAmount are immutable after creation
  • The ExchangeRate table with effectiveDate supports complete historical rate lookup
  • lockExchangeRate() in @bilko/core centralizes this logic

4.2 Negative Consequences

  • Exchange rate population (ECB daily cron) must be running before multi-currency invoices are created — Mitigation: warn in UI if no rate found for selected date

ADR-005 — Organization-Scoped Multi-Tenancy via Middleware

ADR Number: ADR-005 Title: Enforce multi-tenancy at the API middleware layer (organizationScope) — not via PostgreSQL RLS Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko is a multi-tenant SaaS. Every database record (invoices, expenses, transactions, etc.) belongs to one organization. Cross-organization data access would be a critical security breach. There are two primary approaches: enforce isolation in the database (RLS) or in the application (middleware).

1.2 Forces & Constraints

Technical forces:

  • Team is more proficient in TypeScript/Node.js than PostgreSQL RLS configuration
  • Prisma ORM does not have first-class RLS support — it would require raw SQL policies
  • Application-layer enforcement is testable with unit tests

Business forces:

  • MVP timeline is tight — RLS adds PostgreSQL configuration complexity

1.3 Problem Statement

We need to decide: Where to enforce that users can only access data from their own organization.

2. Decision

We will: Enforce organization scoping via the organizationScope Express middleware (apps/api/src/middleware/org-scope.ts), which attaches req.user.organizationId from the JWT. All service methods receive organizationId as first parameter and all Prisma queries include where: { organizationId }.

3. Alternatives Considered

Option A: Application-layer middleware ← Selected

Pros:

  • Enforceable in unit tests — mock req.user.organizationId
  • Developers understand it — familiar TypeScript patterns
  • Works with Prisma ORM without raw SQL

Cons:

  • Defense depends on application code correctness — a missed where: { organizationId } is a bug
  • No DB-level enforcement as a second layer

Option B: PostgreSQL Row-Level Security (RLS)

Pros:

  • Enforcement at database level — even direct DB queries are isolated
  • Defense-in-depth — second layer below application

Cons:

  • Requires setting app.current_tenant_id on each connection via Prisma $executeRaw
  • Prisma does not natively support connection-level session variables
  • Complex to test and debug — policy errors show as unexpected empty results

Why not chosen: Prisma integration complexity and team expertise gap. Can be added post-MVP.

4. Consequences

4.1 Positive Consequences

  • Enforcement is testable — every service method test verifies organizationId filter is applied

4.2 Negative Consequences

  • Direct DB access (e.g., migrations, admin scripts) bypasses enforcement — Mitigation: document that all data access must go through API

4.3 Technical Debt Created

  • RLS would provide a stronger defense-in-depth guarantee — Plan: evaluate adding PostgreSQL RLS as a secondary enforcement layer post-MVP

ADR-006 — Country Plugin Architecture as Separate npm Packages

ADR Number: ADR-006 Title: Implement country-specific accounting rules as separate Turborepo packages with a shared interface Date: 2026-02-23 Author: Petter Graff Status: Accepted

1. Context

1.1 Situation

Bilko targets three countries with different VAT rates, tax thresholds, e-invoice platforms, chart of accounts templates, and filing deadlines. These rules change independently (e.g., Serbia changed e-invoice rules in 2023; Croatia mandated eRačun from Jan 2026). Rules must be extensible to future markets (Slovenia, North Macedonia) without modifying core accounting logic.

1.2 Problem Statement

We need to decide: How to organize country-specific accounting rules to allow independent versioning and easy extensibility.

2. Decision

We will: Implement each country as a separate Turborepo package (@bilko/country-rs, @bilko/country-ba, @bilko/country-hr) with a standard module structure: tax/, chart/, fiscal/, filing/, locale/, index.ts. The API selects the plugin at runtime based on org.country.

3. Alternatives Considered

Option A: Separate packages per country ← Selected

Pros:

  • A Serbia VAT rate change does not touch Croatia or BiH code
  • Each plugin can be unit tested independently
  • New countries (e.g., Slovenia) are added as a new package without touching existing code
  • Regulatory changes trigger a focused PR in one package

Cons:

  • Shared interface must be maintained — breaking change to CountryPlugin interface affects all plugins
  • Slightly more files to create per new country

Option B: Single @bilko/countries package with all countries in subdirectories

Pros:

  • Simpler dependency management

Cons:

  • A Serbian VAT rate PR touches the same package as Croatia/BiH — increases risk of cross-country bugs
  • Cannot version countries independently

Why not chosen: Independent versioning is critical given different regulatory cadences per country.

Option C: Country rules stored in database (configurable per org)

Pros:

  • Can update VAT rates without a deployment
  • Admin UI for configuration

Cons:

  • VAT calculation logic (not just rates) varies by country — cannot be reduced to a database record
  • E-invoice XML format generation (UBL 2.1) must be code, not config
  • Much higher complexity at MVP stage

Why not chosen: Regulatory logic (e-invoice XML, fiscal year rules) cannot be stored as configuration.

4. Consequences

4.1 Positive Consequences

  • Serbia launched first — @bilko/country-rs is the most complete plugin; BiH and Croatia can lag
  • @bilko/core remains country-agnostic — reusable in future non-Bilko products

4.2 Negative Consequences

  • Adding a new country requires creating a new package, wiring it in the API country-selector — Mitigation: document standard plugin creation guide in CONTRIBUTING.md

ADR-007 — JWT for Authentication

ADR Number: ADR-007 Title: Use JWT (RS256) with short-lived access tokens and httpOnly refresh tokens — no server-side sessions Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

Bilko's API must authenticate users on every request. The system must be stateless for horizontal scaling. Tokens must be secure against XSS and CSRF attacks. The mobile PWA requires a token-based approach (cookies work; sessions require sticky sessions).

1.2 Forces & Constraints

Technical forces:

  • API is consumed by Next.js frontend (same-domain in production) and potentially mobile PWA in future
  • Access tokens must be short-lived to limit exposure if intercepted
  • Refresh tokens must survive browser tab reloads without re-authentication

Security forces:

  • Access tokens in localStorage are vulnerable to XSS
  • Session cookies without SameSite are vulnerable to CSRF

Business forces:

  • Must avoid per-user costs of managed auth services at MVP scale
  • Must work without Redis (no session store in MVP — see ADR-010)

1.3 Problem Statement

We need to decide: How to authenticate API requests — JWT, session-based, or delegated OAuth.

2. Decision

We will: Use RS256-signed JWT access tokens (15 min TTL, stored in memory by the client) and RS256-signed refresh tokens (7 days, stored as httpOnly; SameSite=Strict cookie). Access tokens contain sub, email, organizationId, role, iat, exp. No server-side session store is required.

Implementation: apps/api/src/utils/jwt.ts (sign/verify), apps/api/src/middleware/auth.ts (authGuard middleware).

3. Alternatives Considered

Option A: JWT (RS256, access + refresh) ← Selected

Pros:

  • Stateless — no Redis or DB session table required
  • RS256 asymmetric signing — public key can be shared for future microservices validation
  • 15-min access token limits breach window
  • httpOnly refresh cookie prevents XSS token theft
  • No per-user cost

Cons:

  • Cannot invalidate individual access tokens before expiry (JWT is stateless)
  • Refresh token rotation requires careful implementation to prevent replay

Option B: Session-based authentication (server-side sessions)

Pros:

  • Instant revocation — delete session from store
  • No token expiry concerns on the client

Cons:

  • Requires server-side session store (Redis) — increases infrastructure complexity for MVP
  • Not naturally stateless — complicates horizontal scaling without sticky sessions
  • Session cookies are CSRF-vulnerable without additional mitigation

Why not chosen: Requires Redis which is not in the MVP stack (ADR-010).

Option C: Delegated OAuth (Auth0, Clerk, Supabase Auth)

Pros:

  • Managed security, automatic token refresh, MFA support
  • Reduces auth implementation burden

Cons:

  • Auth0/Clerk: $23+/month for 1,000+ users — significant cost at MVP scale
  • External dependency — outage on Auth0 = Bilko login down
  • Couples auth to third-party vendor; migration is painful

Why not chosen: Cost and external dependency at MVP stage.

4. Consequences

4.1 Positive Consequences

  • API is fully stateless — scales horizontally without sticky sessions
  • organizationId and role in JWT payload mean 0 DB queries for authorization checks
  • Future microservices can verify tokens independently using the public key

4.2 Negative Consequences

  • Access token cannot be revoked before 15 min expiry — Mitigation: 15 min window is acceptable; force re-login via refresh token revocation if compromise suspected

4.3 Technical Debt Created

  • 2FA TOTP field exists on User model but not yet wired into login flow — Plan: wire 2FA check in auth middleware in v1.1

ADR-008 — Zustand for Frontend State Management

ADR Number: ADR-008 Title: Use Zustand for minimal global frontend state — React hooks for local component state Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

The Next.js frontend (apps/web) needs to share state across components: authenticated user info, current organization, UI preferences (sidebar open/closed, theme). Most application data is server-fetched per page — only a small slice of global state is needed in the client.

1.2 Forces & Constraints

Technical forces:

  • Next.js App Router encourages React Server Components (RSC) for data fetching — most data doesn't need global client-side state
  • Client Components still need access to auth state (user, org) without prop-drilling
  • State management solution must not add heavy bundle to RSC-first architecture

Business forces:

  • MVP pace — no time for Redux boilerplate

1.3 Problem Statement

We need to decide: What library to use for global client-side state management in the Next.js frontend.

2. Decision

We will: Use Zustand 4.5 for a minimal global store containing { user, organization, accessToken, setUser, clearAuth }. All per-page data is fetched server-side in RSCs or client-side with React hooks. Zustand is NOT used as a substitute for server-fetched data.

3. Alternatives Considered

Option A: Zustand ← Selected

Pros:

  • 1KB bundle — negligible impact on LCP
  • Hooks-based API — consistent with React patterns
  • Zero boilerplate — create((set) => ({ ... })) is all that's needed
  • No Provider wrapping required (unlike Context API)
  • Excellent TypeScript inference

Cons:

  • Less opinionated — developers must define store structure themselves
  • No built-in devtools (Redux has excellent browser devtools)

Option B: Redux Toolkit (RTK)

Pros:

  • Battle-tested at large scale
  • Excellent browser devtools (time-travel debugging)
  • Built-in RTK Query for server state caching

Cons:

  • ~50KB bundle (vs 1KB for Zustand)
  • Slice/action/reducer/dispatch boilerplate even with RTK
  • RTK Query would duplicate Next.js RSC data fetching capabilities

Why not chosen: Bundle overhead and boilerplate disproportionate to MVP state needs.

Option C: React Context API

Pros:

  • Built-in — no dependency
  • Familiar to all React developers

Cons:

  • Every useContext consumer re-renders on any state change — causes performance issues with auth state updates
  • No atomic selector pattern — must split contexts manually to avoid cascading re-renders

Why not chosen: Performance characteristics unsuitable for auth state shared across many components.

Option D: Jotai / Recoil

Pros:

  • Atomic state model — precise subscriptions, no unnecessary re-renders
  • Jotai is React-focused, small bundle

Cons:

  • Less community adoption than Zustand
  • Atomic model adds cognitive overhead for simple use case (one auth store)

Why not chosen: Zustand is simpler and better supported for our use case.

4. Consequences

4.1 Positive Consequences

  • Zero boilerplate for auth state management
  • No Provider nesting required — clean component tree
  • Tiny bundle contribution

4.2 Negative Consequences

  • Custom devtools setup required for debugging — Mitigation: Zustand supports Redux DevTools extension via devtools middleware

ADR-009 — Express for Backend API Framework

ADR Number: ADR-009 Title: Use Express.js with TypeScript as the backend API framework Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

Bilko's backend API (apps/api) handles all accounting operations. The framework must support TypeScript, middleware composition (auth, validation, rate limiting), and have a mature ecosystem for the integrations needed (Prisma, Zod, Helmet, express-rate-limit).

1.2 Forces & Constraints

Technical forces:

  • Framework must compose cleanly with Prisma, Zod validators, Helmet, and custom middleware
  • Middleware order matters for security (Helmet must run before routes)
  • TypeScript support must be first-class

Business forces:

  • Team familiarity reduces ramp-up time
  • Must not over-engineer at MVP stage

1.3 Problem Statement

We need to decide: Which Node.js HTTP framework to use for the Express API.

2. Decision

We will: Use Express 4.x with TypeScript. Middleware stack: helmet → cors → json → rate-limit → auth → validate → handler → error-handler. Route modules in apps/api/src/routes/, service layer in apps/api/src/services/.

3. Alternatives Considered

Option A: Express.js ← Selected

Pros:

  • Battle-tested (10+ years, billions of installs)
  • Largest ecosystem — every library has an Express adapter/example
  • Minimal and unopinionated — no forced patterns
  • Team familiarity — zero ramp-up time
  • Excellent TypeScript support via @types/express

Cons:

  • 2-3x slower throughput than Fastify in benchmarks (not meaningful at MVP scale <500 concurrent users)
  • No built-in input validation — requires Zod middleware
  • Verbose error handling without an error middleware

Option B: Fastify

Pros:

  • 2-3x faster than Express in benchmarks
  • Built-in JSON schema validation
  • Strong TypeScript support

Cons:

  • Plugin system is more complex than Express middleware
  • Smaller ecosystem — some libraries require shimming for Fastify
  • Less team familiarity — ramp-up cost

Why not chosen: Performance advantage irrelevant at MVP scale; ecosystem gaps outweigh speed benefit.

Option C: NestJS

Pros:

  • Full framework with DI, modules, decorators
  • Built-in Swagger generation
  • Strong conventions reduce decision fatigue

Cons:

  • Angular-style architecture with heavy boilerplate (modules, providers, controllers, decorators)
  • 10-15x more files than equivalent Express app
  • Forces specific patterns that conflict with our functional service layer approach
  • Adds significant complexity for a small team

Why not chosen: Overkill for MVP. Complexity outweighs conventions benefit for a small team.

Option D: Hono

Pros:

  • Extremely fast, edge-native, very small bundle
  • Excellent TypeScript support (router types)

Cons:

  • Very new (2023) — limited production track record for financial applications
  • Smaller ecosystem — fewer middleware examples

Why not chosen: Unproven at scale for financial SaaS; ecosystem too immature.

4. Consequences

4.1 Positive Consequences

  • Predictable middleware execution order — critical for security (Helmet before routes)
  • Massive example library for every integration (Prisma, Zod, JWT, multer, etc.)
  • Service layer design is framework-agnostic — can migrate to Fastify/Hono in v2 without rewriting services

4.2 Technical Debt Created

  • If Bilko scales to 10K+ concurrent connections, benchmarks should be run to evaluate Fastify migration — Plan: evaluate at post-10K scale

ADR-010 — Vercel + Railway for Hosting

ADR Number: ADR-010 Title: Use Vercel for frontend hosting and Railway for backend API + PostgreSQL — no self-managed infrastructure at MVP Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

Bilko needs hosting for: (1) Next.js 15 frontend, (2) Express API, (3) PostgreSQL database, (4) file storage (Cloudflare R2). The team is small; infrastructure management overhead must be minimal. GDPR compliance requires EU data residency.

1.2 Forces & Constraints

Business forces:

  • MVP budget: <€50/month for hosting
  • No dedicated DevOps resource — infra must be managed with minimal effort
  • EU data residency for GDPR compliance (Serbia, BiH, Croatia users)

Technical forces:

  • Next.js App Router is optimized for Vercel deployment (edge functions, ISR)
  • PostgreSQL must be accessible from the API without VPC configuration complexity

1.3 Problem Statement

We need to decide: Where to host the Bilko MVP — managed platforms vs. cloud VMs vs. self-hosted.

2. Decision

We will: Deploy the Next.js frontend to Vercel (Hobby → Pro tier) and the Express API + PostgreSQL to Railway (EU Frankfurt region). Cloudflare R2 for file storage (PDF invoices, receipts).

Cost breakdown:

Component Service Cost
Frontend Vercel €0 (Hobby) → €20 (Pro)
API + PostgreSQL Railway Starter €5–20/mo
File storage Cloudflare R2 ~€1/mo
Total MVP ~€21/mo

3. Alternatives Considered

Option A: Vercel + Railway ← Selected

Pros:

  • €21/month — minimal cost for MVP
  • Vercel: zero-config Next.js deployment, preview URLs per PR, global CDN, edge network
  • Railway: PostgreSQL included, EU Frankfurt region (GDPR), git-push deploys, no Dockerfile needed
  • No SSL configuration, no Nginx management, no PM2 setup

Cons:

  • Railway has vendor lock-in risk (smaller company than AWS/GCP)
  • Performance headroom limited to Railway's plan tiers

Option B: AWS (EC2 + RDS + CloudFront + Route 53)

Pros:

  • Unmatched scalability and control
  • eu-central-1 (Frankfurt) for GDPR compliance
  • RDS Multi-AZ for HA

Cons:

  • €80–150/month minimum for equivalent services (EC2 t3.small + RDS t3.micro + CloudFront)
  • Requires VPC, security groups, IAM roles, Route 53 — significant DevOps overhead
  • No preview deployments out of the box

Why not chosen: 4-7x more expensive at MVP scale; ops overhead unacceptable for small team.

Option C: GCP (Cloud Run + Cloud SQL + Firebase Hosting)

Pros:

  • Competitive pricing, serverless Cloud Run
  • Firebase Hosting for Next.js

Cons:

  • Similar ops complexity to AWS
  • Less team familiarity
  • Firebase Hosting has limited Next.js App Router support

Why not chosen: Team unfamiliarity; less Next.js optimization than Vercel.

Option D: Self-hosted (Hetzner VPS / dedicated)

Pros:

  • Cheapest at scale (€6/month for CX21)
  • Full control

Cons:

  • Full ops responsibility — nginx, SSL certs, PM2, backups, security updates, monitoring
  • No preview deployments
  • Single point of failure without manual HA setup

Why not chosen: Ops burden unacceptable for a 2-person team at MVP stage.

4. Consequences

4.1 Positive Consequences

  • Zero infrastructure management time — team focuses on product
  • Preview URLs per PR via Vercel — enables QA without staging environments
  • PostgreSQL managed by Railway — automatic backups, connection pooling

4.2 Negative Consequences

  • Railway limits: 2GB RAM, shared CPU on Starter — may require upgrade at 500+ concurrent users — Mitigation: upgrade to Railway Pro (€20/mo) at scale
  • If Railway service is disrupted, requires migration to AWS or Fly.io — Mitigation: keep Terraform modules ready for AWS deployment (in infrastructure/terraform/)

4.3 Scaling Path

  • MVP (<500 users): Vercel Hobby + Railway Starter (€21/mo)
  • Growth (500–2,000 users): Vercel Pro + Railway Pro (€40–50/mo); add Redis for session caching if needed
  • Scale (2,000+ users): Migrate API to AWS ECS/Fargate, RDS Multi-AZ; keep Vercel for frontend

ADR-011 — Prisma as the ORM

ADR Number: ADR-011 Title: Use Prisma 5.x as the ORM with schema-as-code and generated TypeScript client Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

Bilko needs type-safe database access to PostgreSQL with: NUMERIC(19,4) for monetary values, UUID primary keys, Prisma-managed migrations, and Prisma middleware for the LoggedAction audit trail.

1.2 Forces & Constraints

Technical forces:

  • All monetary fields must be Decimal (mapped to NUMERIC(19,4)) — ORM must support this natively
  • Migrations must be version-controlled and safe to run via CI/CD (prisma migrate deploy)
  • Prisma Client must generate TypeScript types from schema — no manual type maintenance

1.3 Problem Statement

We need to decide: Which ORM or database access library to use for Bilko's PostgreSQL interactions.

2. Decision

We will: Use Prisma 5.x (packages/database/prisma/schema.prisma). Prisma generates the TypeScript client (@bilko/database), manages migrations, and maps Decimal fields to NUMERIC(19,4).

3. Alternatives Considered

Option A: Prisma ← Selected

Pros:

  • Declarative schema — schema.prisma is the single source of truth
  • Auto-generated TypeScript client with exact types from schema
  • @db.Decimal maps directly to NUMERIC(19,4) — no manual conversion
  • prisma.$transaction() for atomic operations (essential for double-entry)
  • Prisma middleware enables LoggedAction audit trail implementation
  • First-class VS Code extension with IntelliSense
  • prisma migrate deploy is CI/CD-safe (no interactive prompts)

Cons:

  • N+1 query risk with nested relations (must use include: { ... })
  • Raw SQL sometimes needed for complex financial aggregation queries
  • Prisma's Decimal type requires care when passing to @bilko/core (which uses decimal.js)

Option B: Drizzle ORM

Pros:

  • Excellent TypeScript inference (schema defined in TypeScript, not a DSL)
  • Faster query performance than Prisma in benchmarks
  • Growing ecosystem with strong community

Cons:

  • At time of decision (early 2026), Drizzle's migration system was less mature than Prisma Migrate
  • Decimal type support required additional configuration
  • Fewer examples for financial/accounting use cases

Why not chosen: Less mature migration tooling at time of decision; Prisma's @db.Decimal is a better fit for NUMERIC(19,4).

Option C: TypeORM

Pros:

  • Very mature — exists since 2016
  • Supports both decorator-based and data-mapper patterns

Cons:

  • Decorator-based entities require experimentalDecorators — adds TypeScript config complexity
  • TypeScript inference is weaker than Prisma — more any in practice
  • Decimal support requires @Column({ type: 'decimal', precision: 19, scale: 4 }) on every monetary column

Why not chosen: Inferior TypeScript DX compared to Prisma; decorator-heavy pattern conflicts with functional service layer.

Option D: Knex.js (query builder)

Pros:

  • Lightweight, full SQL control
  • Works with any DB driver

Cons:

  • Not an ORM — no type generation from schema
  • Requires manually maintaining TypeScript interfaces for every table
  • No migration tooling comparable to Prisma Migrate

Why not chosen: No type generation means maintaining types manually — unacceptable for a 15-model schema.

4. Consequences

4.1 Positive Consequences

  • Schema changes are captured in versioned migration files (packages/database/prisma/migrations/)
  • npx prisma generate regenerates the client after any schema change
  • prisma.$transaction() makes double-entry atomic — both GL entries commit or neither does

4.2 Negative Consequences

  • Complex aggregation queries (VAT report, trial balance) require raw SQL via prisma.$queryRawMitigation: isolate raw queries in ReportService, document each query's purpose

ADR-012 — Zod for Request Validation

ADR Number: ADR-012 Title: Use Zod 3.x for all API request validation with full TypeScript type inference Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

All user input entering the Bilko API must be validated before processing. Validation must: (1) reject invalid data with field-level error messages, (2) provide TypeScript types from the schema without duplication, (3) be composable across endpoints (reuse sub-schemas).

1.2 Forces & Constraints

Technical forces:

  • TypeScript types must match runtime validation — manual type + validator duplication causes drift bugs
  • Financial fields (amounts, dates, currency codes) require strict validation patterns
  • Validation schemas are used as the source of truth for request body types in route handlers

Security forces:

  • All user input must be validated before any DB query or business logic (OWASP A03 Injection prevention)

1.3 Problem Statement

We need to decide: Which validation library to use for API request body/query validation.

2. Decision

We will: Use Zod 3.x for all request validation. Zod schemas are defined per endpoint and composed from shared sub-schemas (e.g., monetaryAmountSchema, isoDateSchema). The validate(schema) middleware in apps/api/src/middleware/validate.ts calls schema.parse(req.body) and passes validated, typed data to route handlers.

// Example: createInvoiceSchema
const createInvoiceSchema = 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.string().length(3),
  items: z.array(z.object({
    description: z.string().min(1).max(500),
    quantity: z.number().positive(),
    unitPrice: z.number().min(0),
    taxRate: z.number().min(0).max(100),
    accountId: z.string().uuid().optional(),
  })).min(1),
});
type CreateInvoiceRequest = z.infer<typeof createInvoiceSchema>;

3. Alternatives Considered

Option A: Zod ← Selected

Pros:

  • TypeScript-first — z.infer<typeof schema> generates the type automatically
  • Single source of truth — schema IS the type
  • Composable — z.object().merge(), z.discriminatedUnion(), partial schemas
  • Tree-shakeable — only imports what's used
  • .parse() throws ZodError with field-level messages — maps directly to 422 responses
  • Excellent ecosystem integration (tRPC, react-hook-form, etc. — useful in v2)

Cons:

  • Slightly verbose for complex unions
  • Bundle size (~13KB) — acceptable for server-side

Option B: Yup

Pros:

  • Mature — exists since 2016
  • Async validation support

Cons:

  • TypeScript support is an afterthought — inference is weaker than Zod
  • Schema and type must be maintained separately in practice
  • More verbose .shape() API

Why not chosen: Inferior TypeScript inference — requires manual type maintenance.

Option C: Joi

Pros:

  • Very mature — Hapi ecosystem
  • Rich validation methods

Cons:

  • JavaScript library — TypeScript types are maintained separately (@hapi/joi types have gaps)
  • No built-in TypeScript inference — requires manual interface definitions
  • Verbose for TypeScript projects

Why not chosen: Not TypeScript-native; dual maintenance of types and schemas.

Option D: class-validator + class-transformer

Pros:

  • Decorator-based — familiar to NestJS/Java developers
  • Works well with class instances

Cons:

  • Requires experimentalDecorators TypeScript config
  • Only works with class instances — requires plainToClass() transformation before validation
  • Incompatible with plain object service layer design
  • Tightly coupled to NestJS patterns we are not using

Why not chosen: Requires class instance pattern — incompatible with functional service layer.

4. Consequences

4.1 Positive Consequences

  • Zero type drift between validation schema and TypeScript types
  • 422 responses automatically include field-level error messages from Zod
  • Shared sub-schemas enforce consistent validation across endpoints (e.g., all UUID fields validated the same way)

4.2 Negative Consequences

  • Zod errors must be mapped to Bilko's standard error format in errorHandler middleware — Mitigation: already implemented in apps/api/src/middleware/error-handler.ts

ADR-013 — Next.js App Router

ADR Number: ADR-013 Title: Use Next.js 15 App Router with React Server Components — not Pages Router, Remix, or SvelteKit Date: 2026-02-24 Author: Security-Test-Writer Agent Status: Accepted

1. Context

1.1 Situation

The Bilko frontend is built on Next.js 15. Next.js offers two routing systems: the legacy Pages Router and the modern App Router (stable since Next.js 13, production-ready since Next.js 14). The choice affects how data is fetched, how layouts are composed, and whether React Server Components (RSC) can be used.

1.2 Forces & Constraints

Technical forces:

  • Dashboard, invoice list, and report pages are data-heavy — RSC reduces client-side JS bundle by fetching data server-side
  • Bilko has multiple layout levels: root layout (sidebar + topbar), auth layout, public layout
  • Next.js 15 App Router enables layout.tsx nesting — perfect for Bilko's multi-level navigation

Business forces:

  • App Router is Next.js's strategic direction — Pages Router will be maintained but not enhanced
  • Vercel's deployment optimizations (ISR, edge functions) are App Router-first

1.3 Problem Statement

We need to decide: Whether to use Next.js App Router or Pages Router (and whether Next.js is the right choice vs. alternative frameworks).

2. Decision

We will: Use Next.js 15 App Router with the following patterns:

  • React Server Components for data-fetch pages (invoice list, reports, dashboard)
  • Client Components ("use client") for interactive UI (invoice wizard, date pickers, forms)
  • Nested layouts (layout.tsx) for shared navigation: RootLayout → AppLayout (sidebar) → PageLayout
  • Server Actions for form submissions (planned for v1.1 — v1.0 uses API calls from Client Components)

3. Alternatives Considered

Option A: Next.js App Router ← Selected

Pros:

  • RSC reduces client JS bundle — invoice list page ships no JS for the list rendering itself
  • Nested layouts eliminate per-page layout boilerplate
  • loading.tsx / error.tsx conventions for granular loading states
  • Vercel deployment optimized for App Router (edge streaming, ISR)
  • Future-proof — Pages Router receives no new features

Cons:

  • App Router mental model is newer — some patterns (client/server component boundary) require learning
  • Some third-party libraries not yet compatible with RSC ("use client" workarounds needed)
  • Debugging is more complex (server vs. client stack traces differ)

Option B: Next.js Pages Router

Pros:

  • Stable, well-documented, massive example library
  • No client/server component boundary to reason about

Cons:

  • No React Server Components — all data fetching in getServerSideProps or client-side
  • Layout patterns are more manual (no layout.tsx nesting)
  • Will not receive new features — effectively legacy

Why not chosen: Legacy approach; loses RSC performance benefits; will require migration later anyway.

Option C: Remix

Pros:

  • Excellent loader/action model for data fetching and mutations
  • Very good TypeScript support
  • No client/server boundary confusion (different mental model)

Cons:

  • Cannot deploy on Vercel without adapter configuration
  • Learning curve for team familiar with Next.js
  • Smaller ecosystem than Next.js
  • Less integrated with React Server Components ecosystem

Why not chosen: Team familiarity with Next.js; Vercel deployment optimization; RSC is a better long-term bet.

Option D: SvelteKit

Pros:

  • Excellent performance (minimal JS, true reactivity without VDOM)
  • Simpler mental model than React
  • Good TypeScript support

Cons:

  • Different language (Svelte), not TypeScript + JSX — requires team retraining
  • Smaller ecosystem — fewer accounting/UI component libraries
  • Cannot share React types with backend TypeScript code

Why not chosen: Full framework switch — team TypeScript/React expertise would not transfer; no shared type system with backend.

4. Consequences

4.1 Positive Consequences

  • Invoice list, reports, and dashboard pages are server-rendered — faster TTFB and LCP
  • No prop-drilling for layout — layout.tsx provides shared sidebar/topbar automatically
  • RSC reduces Time to Interactive — less JavaScript shipped to browser

4.2 Negative Consequences

  • Client/server boundary requires care — passing non-serializable props to Client Components causes runtime errors — Mitigation: ESLint rule react-compiler warns on component boundary violations

4.3 Technical Debt Created

  • Current v1.0 uses mock data from lib/mock-data.ts in Client Components — these must be replaced with RSC data fetching when the backend API is built — Plan: replace all mock data with RSC fetches in v1.1 (backend integration milestone)

Approval

Role Name Date Signature
Author (ADR-001–006) Petter Graff 2026-02-23
Author (ADR-007–013) Security-Test-Writer Agent 2026-02-24
Tech Lead
CTO / Architect Alem Bašić