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/corecan 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/databaseare shared — no duplicated interfaces
4.2 Negative Consequences
- Turborepo
turbo.jsonpipeline config must be maintained as packages grow — Mitigation: document pipeline inPIPELINE.md
4.3 Technical Debt Created
packages/uiis 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
NUMERICtype supports arbitrary precision;FLOATdoes not - Prisma maps
NUMERICto itsDecimaltype (backed bydecimal.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
Decimaltype integrates seamlessly
Cons:
- Developers must remember to never use
numberfor money Decimal.jsoperations are slightly more verbose:new Decimal(a).plus(b)vsa + 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 asDecimal
4.2 Negative Consequences
- All API clients must handle monetary values as strings, not numbers — Mitigation: document in API spec; TypeScript types enforce
stringfor 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
Transactionwith 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 Balanceto always balance — a fundamental correctness check - It enables any accounting report to be derived from the same
Transactiontable
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
isBalancedcheck 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
Transactiontable locked = trueon transactions creates an immutable audit trailreconciled = trueflag enables bank reconciliation
4.2 Technical Debt Created
validateDoubleEntry()is called in application code, not enforced by DB constraint —debitAmount = creditAmountrelies 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
baseAmountis 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
exchangeRateandbaseAmountare immutable after creation - The
ExchangeRatetable witheffectiveDatesupports complete historical rate lookup lockExchangeRate()in@bilko/corecentralizes 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_idon 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
organizationIdfilter 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:
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-rsis the most complete plugin; BiH and Croatia can lag @bilko/coreremains 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
localStorageare 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
organizationIdandrolein 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
useContextconsumer 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
devtoolsmiddleware
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 toNUMERIC(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.prismais the single source of truth - Auto-generated TypeScript client with exact types from schema
@db.Decimalmaps directly toNUMERIC(19,4)— no manual conversionprisma.$transaction()for atomic operations (essential for double-entry)- Prisma middleware enables
LoggedActionaudit trail implementation - First-class VS Code extension with IntelliSense
prisma migrate deployis 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
Decimaltype requires care when passing to@bilko/core(which usesdecimal.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
anyin 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 generateregenerates the client after any schema changeprisma.$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.$queryRaw— Mitigation: isolate raw queries inReportService, 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()throwsZodErrorwith field-level messages — maps directly to422responses- 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/joitypes have gaps) - No built-in TypeScript inference — requires manual
interfacedefinitions - 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
experimentalDecoratorsTypeScript 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
422responses 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
errorHandlermiddleware — Mitigation: already implemented inapps/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.tsxnesting — 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.tsxconventions 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
getServerSidePropsor client-side - Layout patterns are more manual (no
layout.tsxnesting) - 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.tsxprovides 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-compilerwarns on component boundary violations
4.3 Technical Debt Created
- Current v1.0 uses mock data from
lib/mock-data.tsin 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ć |
No comments to display
No comments to display