Database Schema
DropBilko Database Schema
Source:Status:IMPLEMENTED (src/shared/db/schema.tsDrizzle ORMPrisma schema—exists)singleLocation:source/Users/makinja/ALAI/products/Bilko/packages/database/prisma/schema.prismaofDatabase:truthPostgreSQLfor14+allORM:environments)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
Core Tables
1. Organization
Drop uses PostgreSQL 16Purpose: asMulti-tenant theroot. soleEvery database engine in all environments (development, CI, staging, production).
Database accessbusiness is viaone 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 |
Drizzle ORMIndexes:. There is no SQLite dependency and no dual-driver abstraction.
LocalPrimarydev:PostgreSQL 16 in Docker (docker compose up -d), port 5433CI:PostgreSQL 16 service container in GitHub ActionsProduction:PostgreSQL 16 on AWS RDS (db.t3.small)Schema definition:key:src/shared/db/schema.ts(Drizzle schema, TypeScript, PostgreSQL-native)Migrations:managed bydrizzle-kitid
SeeBusiness ADR-014rules:
- baseCurrency determines default currency for
theallfulltransactions - country determines tax rules (Serbia 20%, BiH 17%, Croatia 25%)
- fiscalYearStart used for annual reports
2. User
Total tables:Purpose: 19Users 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 |
| 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
TablesChart of Accounts
users3. AccountType
Purpose: Defines account categories for double-entry bookkeeping.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | INT | PK, AUTOINCREMENT | Primary |
| 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 | |
|---|---|---|---|
| id | |||
| NOT NULL | |||
ID format: usr_ + 16 hex charscode (generatede.g., by"1000", randomId("usr"4000") in utils-server.ts:4)
Password hashing: bcrypt with 12 rounds (utils-server.ts:8-11)
recipients
Saved remittance recipients per user.
| name | NOT NULL | ||
| 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 | |
| NOT NULL | |||
Supported countries:Indexes: RS, BA, PL, PK, TR (enforced at API level, not DB level)
Index:
- Primary key:
idx_recipients_userid - Unique:
(organizationId, code) - Index:
idx_accounts_organizationonorganizationIduser_id - Index:
onidx_merchants_orgidx_accounts_typeaccountTypeIdorg_number
merchants
Registered merchant profiles for QR payments.
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)
transactions5. Contact
AllPurpose: financial transactionsCustomers (remittancesinvoice recipients) and QRvendors payments)(expense payees).
| Column | Type | Constraints | |
|---|---|---|---|
| id | |||
| type | |||
| NOT NULL | |||
| 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 | ||
Indexes:
- Primary key:
idx_transactions_userid - Index:
idx_contacts_organizationonuser_id,organizationId - Index:
onidx_transactions_merchantidx_contacts_typetypemerchant_id
Notes:Enums:
enum ContactType {
customer // Invoice recipient
vendor // Expense payee
both // Can be both customer and vendor
}
Business rules:
RemittancesSofthavedelete(isActiverecipient_idset,=false)merchant_idNULLif has invoices/expensesQRcurrencyCodepaymentsdetermineshavedefaultinvoice/expensemerchant_idset,recipient_idNULLsend_*/receive_*/exchange_ratefields are populated for remittances onlycurrency
Invoicing
exchange_rates6. Invoice
CurrencyPurpose: exchangeSales ratesinvoices (fromoutgoing). NOK).Revenue recognition.
| Column | Type | Constraints | |
|---|---|---|---|
| id | |||
| FK → Contact, NOT NULL | Invoice recipient | ||
| invoiceNumber | VARCHAR(50) | NOT NULL | |
| NOT NULL | |||
| 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 | ||
| 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 |
Seed data (db.ts:531-545):Indexes:
idx_invoices_status | on |
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)
bank_accounts7. InvoiceItem
Linked bank accounts (AISP — Open Banking read in production, mock balances in dev).
Pass-through model:Purpose:DropLineNEVER holds customer money. Thebalancecolumn stores the last AISP-read balance from the user's real bank account — it is a cached read-only value, not a Drop-held balance. In dev/demo mode, mock balances are seeded for testing.
Index: items on idx_bank_accounts_useruser_id
cards (FUTURE — feature-flagged)
Note:Cards are a FUTURE feature, gated behind feature flags (all default tofalse). This table exists in the schema but is not actively used until a card issuing partner is integrated.
Virtual and physical payment cards.invoices.
| Column | Type | Constraints | |
|---|---|---|---|
| id | |||
| NOT NULL | |||
| NOT NULL | |||
| quantity * unitPrice | |||
| accountId | UUID | FK → Account, NULL | Revenue |
| createdAt | TIMESTAMP | NOT NULL | Record creation |
Index:Indexes:
- Primary key:
idx_cards_userid - Index:
idx_invoice_items_invoiceoninvoiceIduser_id
Business rules:
- lineTotal = quantity * unitPrice (calculated before save)
- Tax amount = lineTotal * (taxRate / 100)
- accountId determines which revenue account is credited
Expenses
sessions8. Expense
JWTPurpose: sessionPurchase tracking for(incoming). revocationExpense support.recognition.
| Column | Type | Constraints | |
|---|---|---|---|
| id | |||
| FK → Contact, NULL | Vendor (optional) | ||
| expenseNumber | VARCHAR(50) | NOT NULL | |
| 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:
idx_sessions_userid - Unique:
(organizationId, expenseNumber) - Index:
idx_expenses_organizationonuser_id,organizationId - Index:
on vendorIdidx_sessions_tokenidx_expenses_vendor - Index:
on categorytoken_hashidx_expenses_category - Index:
idx_expenses_dateon expenseDate - Composite:
idx_expenses_org_date_categoryon (organizationId, expenseDate, category)
TokenEnums:
enum ExpenseStatus {
pending // Awaiting approval
approved // Approved, ready to pay
paid // Payment made
rejected // Approval denied
}
Business rules:
- expenseNumber
theauto-generated - exchangeRate
stringlocked at expenseDate - baseAmount = amount * exchangeRate
- When status → approved, create Transaction (
auth.ts:59)debit ExpenseAccount, credit AccountsPayable) - When status → paid, create Transaction (debit AccountsPayable, credit BankAccount)
Transactions (Double-Entry Ledger)
notifications9. Transaction
In-appPurpose: notifications.General ledger transactions. Every financial event creates a transaction.
| Column | Type | Constraints | |
|---|---|---|---|
| id | |||
| NOT NULL | |||
| NOT NULL | |||
| FK → Account, NOT NULL | Account to debit | ||
| creditAccountId | UUID | FK → Account, NOT NULL | Account to credit |
| amount | DECIMAL(19,4) | NOT NULL | |
| 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 | ||
| createdBy | UUID | FK → User, NULL | Creator user |
| createdAt | TIMESTAMP | NOT NULL | Record creation |
Index:Indexes:
- Primary key:
idx_notifications_userid - Index:
idx_transactions_organizationon organizationId - Index:
on transactionDateuser_ididx_transactions_date - 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
settings10. BankAccount
Per-userPurpose: preferences.Bank account metadata.
| Column | Type | Constraints | |
|---|---|---|---|
| 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)
spending_limits11. (FUTURE — feature-flagged)BankTransaction
Note:Purpose:TiedBanktostatementtheimports.cardsForfeature. Only active when card feature flags are enabled.
Card spending limits.reconciliation.
| Column | Type | Constraints | |
|---|---|---|---|
| id | |||
| NOT NULL | |||
| amount | NOT NULL | ||
| 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:
idx_spending_limits_userid - Index:
idx_bank_transactions_accountonuser_id,bankAccountId - Index:
onidx_spending_limits_cardidx_bank_transactions_datetransactionDatecard_id
LimitBusiness typesrules:
- Imported
weekly,frommonthly,CSVbank statementstransaction - Matched to GL transactions via reconciliation workflow
- reconciled = true when matched
dailyMulti-Currency
rate_limits12. Currency
PersistentPurpose: rateSupported limiting store.currencies.
| Column | Type | Constraints | |
|---|---|---|---|
| NOT NULL | |||
| NULL | Currency symbol | ||
| decimalPlaces | SMALLINT | NOT NULL, default 2 | Decimal precision |
| isActive | BOOLEAN | NOT NULL, default true | Active status |
| createdAt | TIMESTAMP | NOT NULL |
UsedSeed bydata:
middleware.ts:rateLimit()EUR | Euro | € | 2 RSD | Serbian Dinar | din. | 2 BAM | Bosnian Mark | KM | 2 HRK | Croatian Kuna | kn | 2 USD | US Dollar | $ | 2for IP-based rate limiting. Expired entries are cleaned on each call (middleware.ts:11).
Compliance13. & GDPR Tables
Added: 2026-02-16 (compliance infrastructure)
These tables support Drop's compliance requirements for Norwegian financial services regulation, GDPR, and AML/KYC requirements per hvitvaskingsloven.
audit_logExchangeRate
UserPurpose: actionHistorical auditexchange trail for compliance and security monitoring.rates.
| Column | Type | Constraints | ||
|---|---|---|---|---|
| id | ||||
| FK → |
||||
| NOT NULL | ||||
| NULL | ECB, fixer.io, manual | |||
Indexes:
- Primary key:
idx_audit_log_userid - Unique:
(baseCurrency, targetCurrency, effectiveDate) - Index:
idx_exchange_rates_dateonuser_id,effectiveDate - Index:
onidx_audit_log_timestampidx_exchange_rates_pairtimestamp,(baseCurrency,targetCurrency)idx_audit_log_action
actionBusiness 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: TracksImmutable audit log. Captures all significant user actions (login, transaction, settings change, etc.) for audit purposes.
aml_alerts
AML (Anti-Money Laundering) transaction monitoring alerts.INSERT/UPDATE/DELETE.
| Column | Type | Constraints | |
|---|---|---|---|
| TEXT | NOT NULL | ||
| 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, |
|
Indexes:
- Primary key:
idx_aml_alerts_usereventId - Index:
idx_logged_actions_timestamponuser_id,actionTimestamp - Index:
on tableNameidx_aml_alerts_statusidx_logged_actions_table - Index:
on userIdstatusidx_logged_actions_user
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: RecordsMigration suspicious transaction patterns flagged by AML monitoring rules (e.g., structuring, velocity, high-risk corridors).
str_reports
STR (Suspicious Transaction Reports) filed with financial authorities.tracking.
| Column | Type | Constraints | |
|---|---|---|---|
| NOT NULL, | |||
Purpose: Tracks STRs filed with Økokrim/EFE (Norwegian financial intelligence unit) per hvitvaskingsloven requirements.
screening_results
Results from sanctions/PEP (Politically Exposed Persons) screening.
Indexes: idx_screening_user on user_id
Purpose: Stores results from automated screening against PEP lists, sanctions lists (OFAC, UN, EU), and adverse media databases.
consents
GDPR consent tracking for user data processing.
Indexes: idx_consents_user on user_id
Consent Types (API-enforced): terms, privacy, marketing, cookies_analytics, cookies_marketing
Purpose: Tracks when users grant or withdraw consent for different types of data processing, with IP address as proof of consent action.
data_access_requests
GDPR data access/erasure/rectification requests (Art. 15-17).
Indexes: idx_data_requests_user on user_id
Purpose: Tracks GDPR data subject access requests. export requests generate full data export, erasure triggers account deletion.
complaints
Customer complaints per Finansavtaleloven §3-53 (15-day response requirement).
| description | TEXT | ||
Indexes: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 idx_complaints_useronuse , user_idfloat, or JavaScript idx_complaints_statusdoublenumber onfor currency.
- Precision: 19 digits total, 4 decimal places
- Range: -999,999,999,999,999.9999 to +999,999,999,999,999.9999
- Prisma type:
statusDecimal - PostgreSQL type:
NUMERIC(19,4)
CategoriesWhy:
- JavaScript
numberhas 53-bit precision (API-enforced):safetransaction,upservice,tofees,2^53privacy,-technical,1= 9,007,199,254,740,991)other - Financial calculations require exact decimal precision
- Example: 0.1 + 0.2 = 0.30000000000000004 (float error)
Purpose:Usage in code: Formal complaint logging system to ensure compliance with Norwegian financial services law requiring 15 business day response time.
Database Access Layer
Source: db.ts
Data Access Layer
The database access layer is Drizzle ORM (src/shared/db/schema.ts). The old db.ts
dual-driver abstraction has been removed (see ADR-014).
Use Drizzle query builder or the sql template tag for raw queries:
import { dbDecimal } from '@drop/shared/db';@prisma/client/runtime'
importconst {amount users= }new fromDecimal('125000.0000')
const taxRate = new Decimal('@drop/shared/db/schema';0.20')
importconst {taxAmount eq= } from 'drizzle-orm';amount.times(taxRate) // Type-safe query
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1);
// Raw SQL escape hatch (PostgreSQL syntax, $1 params not needed — Drizzle handles binding)
import { sql } from 'drizzle-orm';
const result = await db.execute(sql`SELECT id FROM users WHERE email = ${email}`);25000.0000
Constraints Summary
Primary Keys
MigrationsAll aretables manageduse 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:
drizzle-kit(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
cd# src/sharedGenerate &&Prisma Client
npx drizzle-kitprisma generate
# GenerateCreate migration
filenpx cdprisma src/sharedmigrate &&dev --name migration_name
# Apply migrations (production)
npx drizzle-kitprisma pushmigrate deploy
# Push schema to devReset database make(dev db-pushonly)
npx prisma migrate reset
# ShortcutSeed (frominitial repodata
root)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);
Entity Relationship Diagram
erDiagram
ORGANIZATION ||--o{ USER : "members"
ORGANIZATION ||--o{ CONTACT : "contacts"
ORGANIZATION ||--o{ INVOICE : "invoices"
ORGANIZATION ||--o{ EXPENSE : "expenses"
ORGANIZATION ||--o{ TRANSACTION : "transactions"
ORGANIZATION ||--o{ BANK_ACCOUNT : "bank accounts"
ORGANIZATION ||--o{ ACCOUNT_TYPE : "account types"
ORGANIZATION ||--o{ ACCOUNT : "chart of accounts"
ORGANIZATION ||--o{ CURRENCY : "currencies"
ORGANIZATION ||--o{ LOGGED_ACTION : "audit log"
INVOICE ||--o{ INVOICE_ITEM : "line items"
INVOICE }o--|| CONTACT : "billed to"
INVOICE }o--|| USER : "created by"
EXPENSE }o--o| CONTACT : "vendor"
EXPENSE }o--|| ACCOUNT : "category account"
ACCOUNT_TYPE ||--o{ ACCOUNT : "classifies"
ACCOUNT ||--o{ TRANSACTION : "debited in"
ACCOUNT ||--o{ TRANSACTION : "credited in"
ACCOUNT |o--o| BANK_ACCOUNT : "linked GL"
BANK_ACCOUNT ||--o{ BANK_TRANSACTION : "transactions"
BANK_TRANSACTION }o--o| TRANSACTION : "reconciles"
CURRENCY ||--o{ EXCHANGE_RATE : "base rates"
LOGGED_ACTION }o--o| USER : "performed by"
Domain groupings:
- Identity: Organization, User
- Financial Core: Account, AccountType, Transaction
- Invoicing: Invoice, InvoiceItem, Contact
- Expenses: Expense
- Banking: BankAccount, BankTransaction
- Compliance: LoggedAction, SchemaVersion
- Currency: Currency, ExchangeRate
Migration Strategy
Prisma Migrate Workflow
Bilko uses Prisma Migrate for all schema changes. No manual SQL migrations.
Development workflow
# 1. Edit packages/database/prisma/schema.prisma
# 2. Generate and apply migration
npx prisma migrate dev --name describe_your_change
# 3. Regenerate Prisma Client
npx prisma generate
# 4. Seed if new lookup data needed
npx prisma db seed
Production deployment workflow
# Applied automatically during Railway deploy via package.json postinstall:
# "postinstall": "prisma migrate deploy && prisma generate"
# Manual production apply:
npx prisma migrate deploy
# Verify migration status:
npx prisma migrate status
Migration naming conventions
| Type | Name format | Example |
|---|---|---|
| Add table | add_{table_name} |
add_webhook_subscriptions |
| Add column | add_{column}_to_{table} |
add_sefId_to_invoices |
| Remove column | remove_{column}_from_{table} |
remove_legacy_field_from_users |
| Add index | add_index_{table}_{columns} |
add_index_invoices_status_dueDate |
| Data fix | fix_{description} |
fix_applicationName_default |
Zero-Downtime Migration Patterns
For production migrations on live data:
| Pattern | When to use | Example |
|---|---|---|
| Additive only | New nullable columns | Add to Invoice |
| Expand-contract | Rename column | Add new column → backfill → drop old |
| Shadow table | Large table restructure | Create new table → migrate → swap |
| Index concurrently | Add index without lock | Manual SQL via prisma db execute |
Rule: Never drop columns in the same migration that removes their usage from code. Deploy code first (ignoring old column), then migrate.
Pre-Deploy Migration Checklist
Before deploying any migration to production:
-
prisma migrate statusshows no drift from dev - Migration tested on a staging DB with production data volume
- Rollback plan documented (additive migrations are safe; destructive need manual rollback SQL)
-
LoggedAction.applicationNamedefault changed from"fiken-clone-api"to"bilko-api"(pending — fix before first production deploy) - Backup taken before running destructive migrations
Known Pre-Deploy Fix Required
-- LoggedAction.applicationName has legacy default value in current schema
-- Run via: npx prisma db execute --file fix_applicationName.sql
ALTER TABLE "LoggedAction"
ALTER COLUMN "applicationName" SET DEFAULT 'bilko-api';
UPDATE "LoggedAction"
SET "applicationName" = 'bilko-api'
WHERE "applicationName" = 'fiken-clone-api';
Enhanced Index Design
Index Inventory
All indexes defined in packages/database/prisma/schema.prisma and verified against query patterns:
| Table | Index Columns | Type | Purpose |
|---|---|---|---|
Invoice |
(organizationId, status, invoiceDate) |
Composite | List invoices with status filter |
Invoice |
(organizationId, contactId) |
Composite | Invoices by customer |
Invoice |
(organizationId, dueDate, status) |
Composite | Overdue invoice cron |
InvoiceItem |
(invoiceId) |
FK | Load line items |
Expense |
(organizationId, status, expenseDate) |
Composite | List expenses with filter |
Expense |
(organizationId, categoryAccountId) |
Composite | Expense by category |
Transaction |
(organizationId, transactionDate) |
Composite | Date range reports |
Transaction |
(organizationId, debitAccountId) |
Composite | Account balance calc |
Transaction |
(organizationId, creditAccountId) |
Composite | Account balance calc |
Transaction |
(referenceType, referenceId) |
Composite | Lookup by invoice/expense |
BankTransaction |
(bankAccountId, transactionDate) |
Composite | Bank statement view |
BankTransaction |
(organizationId, reconciled) |
Composite | Unreconciled transactions |
ExchangeRate |
(baseCurrency, targetCurrency, effectiveDate) |
Unique | Daily rate lookup |
LoggedAction |
(organizationId, tableName, createdAt) |
Composite | Audit trail queries |
LoggedAction |
(organizationId, userId) |
Composite | User activity queries |
Index Design Principles
- Org-first composite indexes: Every query filters by
organizationIdfirst — it must be the leftmost column in all multi-column indexes. - Status + date combos: List endpoints combine status filter + date sort, so
(orgId, status, date)triples cover both filter and ORDER BY. - Foreign key indexes: Prisma creates FK indexes automatically; verify with
\d tableNamein psql. - Covering indexes: For report queries that fetch only a few columns, consider partial indexes in Phase 2 (e.g.,
WHERE status = 'paid').
Query Performance Targets
| Query | Target | Notes |
|---|---|---|
| List invoices (1000 rows) | < 50ms | With org + status index |
| Invoice by ID | < 5ms | PK lookup |
| Dashboard metrics | < 300ms | 7 parallel aggregations |
| P&L report (1 year) | < 500ms | Transaction table scan with date index |
| Audit trail query | < 100ms | LoggedAction composite index |
| VAT report (monthly) | < 200ms | Invoice + Expense aggregation |
Audit Log Partitioning Strategy
Why Partition LoggedAction?
The LoggedAction table is empty,append-only and retains data for 7 years (financial compliance requirement). At 10 requests/minute per organization, a 100-org instance generates ~500K audit rows/month. After 3 years: ~18M rows.
Without partitioning: sequential scans on become slow. With partitioning: queries can prune partitions by year.seedData(LoggedAction
Partitioning Approach: Range by Year
-- Convert LoggedAction to range-partitioned table (PostgreSQL 11+)
-- Execute ONCE during Phase 2 setup, before data volume grows
CREATE TABLE "LoggedAction_partitioned"
PARTITION OF "LoggedAction" (
-- same columns
) PARTITION BY RANGE (EXTRACT(YEAR FROM "createdAt"));
-- Annual partitions
CREATE TABLE "LoggedAction_2026" PARTITION OF "LoggedAction_partitioned"
FOR VALUES FROM (2026) TO (2027);
CREATE TABLE "LoggedAction_2027" PARTITION OF "LoggedAction_partitioned"
FOR VALUES FROM (2027) TO (2028);
-- etc.
Prisma Compatibility
Prisma does not natively manage PostgreSQL table partitioning. Strategy:
- Define base table in
schema.prisma(db.ts:530)nopopulates:partitioning6 exchange rate corridors (NOK → RSD, BAM, PLN, PKR, TRY, EUR)directive)DemoApplydatapartitioning(whenviaNODE_ENVprisma!==db"production"executeorwithSEED_DEMO=true):raw- SQL
1afterdemoinitialuser (usr_demo1, [email protected], role: merchant)migration3Maintainrecipientspartition creation as a yearly operations task (Serbia,orBosnia,useTurkey)pg_partmanextension)1
Archive Strategy (Ahmetov7-Year Kebab)Retention)
Year transactions1-7: Active partitions in PostgreSQL (2Railway remittances,EU 1Frankfurt)
QRYear payment)Trigger: Automated yearly job checks MIN(createdAt) in oldest partition. If > 7 years: export to R2, drop partition.
MVP Approach (Phase 1)
For MVP: no partitioning required. Single table with 45,230composite NOK,index SpareBank(organizationId, 1tableName, withcreatedAt)12,800sufficient NOK)
End of Database Schema