Skip to main content

Database Schema

DropBilko Database Schema

Source:Status: src/shared/db/schema.tsIMPLEMENTED (Drizzle ORMPrisma schema exists) singleLocation: source/Users/makinja/ALAI/products/Bilko/packages/database/prisma/schema.prisma ofDatabase: truthPostgreSQL for14+ 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.

ColumnTypeConstraintsDescription
idUUIDPK, default uuid_generate_v4()Primary key
nameVARCHAR(255)NOT NULLBusiness name
registrationNumberVARCHAR(50)NULLCompany tax ID
vatNumberVARCHAR(50)NULLVAT registration number
baseCurrencyCHAR(3)NOT NULL, default 'EUR'ISO 4217 currency code
countryCHAR(2)NOT NULLISO 3166-1 alpha-2 country code
languageCHAR(2)NOT NULL, default 'sr'ISO 639-1 language code
fiscalYearStartDATENOT NULL, default '2026-01-01'Fiscal year start date
createdAtTIMESTAMPNOT NULL, default now()Record creation timestamp
updatedAtTIMESTAMPNOT NULL, default now()Last update timestamp

Drizzle ORMIndexes:. There is no SQLite dependency and no dual-driver abstraction.

  • LocalPrimary dev: PostgreSQL 16 in Docker (docker compose up -d), port 5433
  • CI: PostgreSQL 16 service container in GitHub Actions
  • Production: PostgreSQL 16 on AWS RDS (db.t3.small)
  • Schema definition:key: src/shared/db/schema.ts (Drizzle schema, TypeScript, PostgreSQL-native)
  • Migrations: managed by drizzle-kitid

SeeBusiness ADR-014rules:

  • baseCurrency determines default currency for theall fulltransactions
  • rationale.

  • 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.

+7compliance)
ColumnTypeConstraintsDescription
idUUIDPKPrimary key
organizationIdUUIDFK → Organization, NOT NULL, CASCADEOrganization membership
emailVARCHAR(255)UNIQUE, NOT NULLLogin email
passwordHashVARCHAR(255)NOT NULLbcrypt hash (12 corerounds)
fullNameVARCHAR(255)NOT NULLDisplay name
roleENUMNOT NULLowner, admin, accountant, viewer
twoFactorEnabledBOOLEANNOT NULL, default false2FA status
twoFactorSecretVARCHAR(255)NULLTOTP secret
lastLoginAtTIMESTAMPNULLLast login timestamp
createdAtTIMESTAMPNOT NULLAccount creation
updatedAtTIMESTAMPNOT NULLLast update

Indexes:

  • Primary key: id
  • Unique: email
  • Foreign key: organizationId → Organization(id)
  • Index: idx_users_organization on organizationId
  • Index: idx_users_email on 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.

ColumnTypeConstraintsDescription
idINTPK, AUTOINCREMENTPrimary userkey
nameVARCHAR(50)UNIQUE, NOT NULLAsset, Liability, Equity, Revenue, Expense
normalBalanceENUMNOT NULLdebit or credit
createdAtTIMESTAMPNOT NULLRecord 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.

Account
Column Type Constraints DefaultDescription
id TEXTUUID PRIMARY KEYPK -Primary key
emailorganizationId TEXTUUID UNIQUEFK → Organization, NOT NULL -Organization scope
password_hashcode TEXTVARCHAR(10) NOT NULL -
first_nameTEXTNOT NULL-
last_nameTEXTNOT NULL-
phoneTEXT-NULL
date_of_birthTEXT-NULL
kyc_statusTEXTCHECK('pending','approved','rejected')'pending'
roleTEXTCHECK('user','merchant')'user'
created_atTEXT-CURRENT_TIMESTAMP

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.

Last
ColumnTypeConstraintsDefault
idTEXTPRIMARY KEY-
user_idTEXTNOT NULL, FK → users(id)-
name TEXTVARCHAR(255) NOT NULL -Account name (e.g., "Cash", "Revenue")
countryaccountTypeId TEXTINTFK → AccountType, NOT NULLAccount category
currencyCodeCHAR(3)NOT NULL, default 'EUR'Account currency
parentAccountIdUUIDFK → Account, NULLParent account (for sub-accounts)
isActiveBOOLEANNOT NULL, default trueActive status
createdAtTIMESTAMP NOT NULL -Record creation
currencyupdatedAt TEXTTIMESTAMP NOT NULL -
bank_accountTEXTNOT NULL-
bank_nameTEXT-NULL
created_atTEXT-CURRENT_TIMESTAMPupdate

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_organization on user_id

    organizationId

  • merchants

    Registered merchant profiles for QR payments.

    ColumnTypeConstraintsDefault
    idTEXTPRIMARY KEY-
    user_idTEXTNOT NULL, FK → users(id)-
    business_nameTEXTNOT NULL-
    org_numberTEXTUNIQUE NOT NULL-
    addressTEXT-NULL
    bank_accountTEXTNOT NULL-
    fee_rateREAL-0.01
    statusTEXT-'active'
    created_atTEXT-CURRENT_TIMESTAMP

  • Index: idx_merchants_orgidx_accounts_type on org_numberaccountTypeId

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).

Last
Column Type Constraints DefaultDescription
id TEXTUUID PRIMARY KEYPK -Primary key
user_idorganizationId TEXTUUID NOT NULL, FK → users(id)Organization, NOT NULL -Organization scope
type TEXTNOT NULL, CHECK('remittance','qr_payment')-
statusTEXTCHECK('processing','completed','failed')'processing'
amountREALENUM NOT NULL -customer, vendor, both
nameVARCHAR(255)NOT NULLContact name
emailVARCHAR(255)NULLEmail address
phoneVARCHAR(50)NULLPhone number
registrationNumberVARCHAR(50)NULLCompany registration number
vatNumberVARCHAR(50)NULLVAT number
addressLine1VARCHAR(255)NULLStreet address
addressLine2VARCHAR(255)NULLApt/suite
cityVARCHAR(100)NULLCity
postalCodeVARCHAR(20)NULLPostal/ZIP code
countryCHAR(2)NULLISO 3166-1 alpha-2
currencyCodeCHAR(3)NOT NULL, default 'EUR'Preferred currency
paymentTermsINTNOT NULL, default 30Payment terms in days
notes TEXT -NULL 'NOK'Free-text notes
feeisActive REALBOOLEAN -NOT NULL, default true 0Active status
recipient_idcreatedAt TEXTTIMESTAMP FKNOT → recipients(id)NULL NULLRecord creation
merchant_idupdatedAt TEXTTIMESTAMP FKNOT → merchants(id)NULL NULL
send_amountREAL-NULL
send_currencyTEXT-NULL
receive_amountREAL-NULL
receive_currencyTEXT-NULL
exchange_rateREAL-NULL
created_atTEXT-CURRENT_TIMESTAMP
completed_atTEXT-NULLupdate

Indexes:

  • Primary key: idx_transactions_userid
  • Index: idx_contacts_organization on user_id,organizationId
  • Index: idx_transactions_merchantidx_contacts_type on merchant_id

    type

Notes:Enums:

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

Business rules:

  • RemittancesSoft havedelete recipient_id(isActive set,= merchant_idfalse) NULLif has invoices/expenses
  • QRcurrencyCode paymentsdetermines havedefault merchant_idinvoice/expense set, recipient_id NULL
  • send_* / receive_* / exchange_rate fields are populated for remittances onlycurrency

Invoicing

exchange_rates6. Invoice

CurrencyPurpose: exchangeSales ratesinvoices (fromoutgoing). NOK).Revenue recognition.

Column Type Constraints DefaultDescription
id SERIALUUID PRIMARY KEYPK AUTOINCREMENTPrimary key
from_currencyorganizationId TEXTUUID -FK → Organization, NOT NULL 'NOK'Organization scope
to_currencycustomerId TEXTUUIDFK → Contact, NOT NULLInvoice recipient
invoiceNumberVARCHAR(50) NOT NULL -Auto-generated (e.g., INV-2026-001)
rateinvoiceDate REALDATE NOT NULL -Invoice issue date
updated_atdueDateDATENOT NULLPayment due date
currencyCodeCHAR(3)NOT NULLInvoice currency
exchangeRateDECIMAL(12,6)NOT NULL, default 1.0Exchange rate at invoiceDate
subtotalDECIMAL(19,4)NOT NULLSum of line totals (before tax)
taxAmountDECIMAL(19,4)NOT NULL, default 0Total VAT/tax
discountAmountDECIMAL(19,4)NOT NULL, default 0Total discount
totalAmountDECIMAL(19,4)NOT NULLsubtotal + taxAmount - discountAmount
baseAmountDECIMAL(19,4)NOT NULLConverted to org baseCurrency
statusENUMNOT NULL, default 'draft'Invoice status
sentAtTIMESTAMPNULLWhen invoice was sent
viewedAtTIMESTAMPNULLWhen customer viewed (email tracking)
paidAtTIMESTAMPNULLWhen marked as paid
notes TEXT -NULL CURRENT_TIMESTAMPInternal notes
termsTEXTNULLPayment terms text
pdfUrlVARCHAR(500)NULLCloudflare R2 URL
createdByUUIDFK → User, NULLCreator user
createdAtTIMESTAMPNOT NULLRecord creation
updatedAtTIMESTAMPNOT NULLLast update

Seed data (db.ts:531-545):Indexes:

  • Primary
  • key:id
  • Unique:
  • (organizationId, invoiceNumber)
  • Index:
  • idx_invoices_organization
  • Index:
  • idx_invoices_customeroncustomerIdonstatus
  • Index:
  • idx_invoices_due_date
  • Composite:
  • idx_invoices_org_status_dateon(organizationId,

    Enums:

    enum 
    InvoiceStatus // Being edited sent to customer viewed //
    Corridor Rate
    NOKon organizationId RSD11.7
    NOK
  • Index: idx_invoices_status BAM
  • 1.04
    NOKon dueDate PLN0.41
    NOKstatus, invoiceDate) PKR 26.8
    NOK{ draft TRY3.45
    NOK// Sent EUR0.089
    Customer viewed email paid // Payment received overdue // Past dueDate and unpaid cancelled // Voided }

    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: DropLine NEVER holds customer money. The balance column 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.

    ColumnTypeConstraintsDefault
    idTEXTPRIMARY KEY-
    user_idTEXTNOT NULL, FK → users(id)-
    bank_nameTEXTNOT NULL-
    account_numberTEXTNOT NULL-
    ibanTEXT-NULL
    balanceREAL-0
    currencyTEXT-'NOK'
    is_primaryINTEGER-0
    connected_atTEXT-CURRENT_TIMESTAMP

    Index: idx_bank_accounts_useritems on user_id


    cards (FUTURE — feature-flagged)

    Note: Cards are a FUTURE feature, gated behind feature flags (all default to false). This table exists in the schema but is not actively used until a card issuing partner is integrated.

    Virtual and physical payment cards.invoices.

    (addedruntimemigrationincards/[id]/pin/route.ts:51-53)
    Column Type Constraints DefaultDescription
    id TEXTUUID PRIMARY KEYPK -Primary key
    user_idinvoiceId TEXTUUID NOT NULL, FK → users(id)Invoice, CASCADE, NOT NULL -Parent invoice
    typelineNumber TEXTCHECK('virtual','physical')'virtual'
    last_fourTEXTINT NOT NULL -Line order (1, 2, 3...)
    token_refdescription TEXT-NULL
    expiryTEXTVARCHAR(500) NOT NULL -Item description
    statusquantity TEXTDECIMAL(10,2) CHECK('active','frozen','cancelled')NOT NULL 'active'Quantity sold
    shipping_addressunitPrice TEXTDECIMAL(19,4) -NOT NULL NULLPrice per unit
    created_attaxRate TEXTDECIMAL(5,2) -NOT NULL, default 0 CURRENT_TIMESTAMPVAT rate (20 = 20%)
    pin_hashlineTotal TEXTDECIMAL(19,4) -NOT NULL quantity * unitPrice
    accountIdUUIDFK → Account, NULL Revenue viaaccount
    createdAt TIMESTAMPNOT NULLRecord creation

    Index:Indexes:

    • Primary key: idx_cards_userid
    • Index: idx_invoice_items_invoice on user_idinvoiceId

    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 DefaultDescription
    id TEXTUUID PRIMARY KEYPK -Primary key
    user_idorganizationId TEXTUUID NOT NULL, FK → users(id)Organization, NOT NULL -Organization scope
    token_hashvendorId TEXTUUIDFK → Contact, NULLVendor (optional)
    expenseNumberVARCHAR(50) NOT NULL -Auto-generated (e.g., EXP-2026-001)
    created_atexpenseDate TEXT-CURRENT_TIMESTAMP
    expires_atTEXTDATE NOT NULL -Expense date
    revokedcurrencyCode INTEGERCHAR(3) -NOT NULL Expense currency
    exchangeRateDECIMAL(12,6)NOT NULL, default 1.0Exchange rate at expenseDate
    amountDECIMAL(19,4)NOT NULLTotal expense amount
    baseAmountDECIMAL(19,4)NOT NULLConverted to org baseCurrency
    taxAmountDECIMAL(19,4)NOT NULL, default 0VAT amount
    categoryVARCHAR(100)NOT NULLExpense category
    paymentMethodVARCHAR(50)NULLcash, card, bank_transfer, etc.
    accountIdUUIDFK → Account, NULLExpense account
    descriptionTEXTNULLExpense description
    receiptUrlVARCHAR(500)NULLCloudflare R2 URL
    statusENUMNOT NULL, default 'pending'Approval status
    approvedByUUIDFK → User, NULLApprover user
    approvedAtTIMESTAMPNULLApproval timestamp
    paidAtTIMESTAMPNULLPayment timestamp
    createdByUUIDFK → User, NULLCreator user
    createdAtTIMESTAMPNOT NULLRecord creation
    updatedAtTIMESTAMPNOT NULLLast update

    Indexes:

    • Primary key: idx_sessions_userid
    • Unique: (organizationId, expenseNumber)
    • Index: idx_expenses_organization on user_id,organizationId
    • Index: idx_sessions_tokenidx_expenses_vendor on vendorId
    • Index: token_hashidx_expenses_category

       on category
    • Index: idx_expenses_date on expenseDate
    • Composite: idx_expenses_org_date_category on (organizationId, expenseDate, category)

    TokenEnums:

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

    Business rules:

    SHA-256
      of
    • expenseNumber theauto-generated
    • JWT
    • 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 DefaultDescription
    id TEXTUUID PRIMARY KEYPK -Primary key
    user_idorganizationId TEXTUUID NOT NULL, FK → users(id)Organization, NOT NULL -Organization scope
    typetransactionDate TEXTDATE NOT NULL -Transaction date
    titledescription TEXTVARCHAR(255) NOT NULL -Transaction description
    bodydebitAccountId TEXTUUIDFK → Account, NOT NULLAccount to debit
    creditAccountIdUUIDFK → Account, NOT NULLAccount to credit
    amountDECIMAL(19,4) NOT NULL -Transaction amount
    readcurrencyCode INTEGERCHAR(3) -NOT NULL 0Transaction currency
    created_atexchangeRateDECIMAL(12,6)NOT NULL, default 1.0Exchange rate at transactionDate
    baseAmountDECIMAL(19,4)NOT NULLConverted to org baseCurrency
    referenceTypeVARCHAR(50)NULLinvoice, expense, payment, manual
    referenceIdUUIDNULLInvoice/Expense ID
    lockedBOOLEANNOT NULL, default falseImmutable if true
    lockedAtTIMESTAMPNULLWhen locked
    reconciledBOOLEANNOT NULL, default falseMatched to bank transaction
    reconciledAtTIMESTAMPNULLWhen reconciled
    notes TEXT -NULL CURRENT_TIMESTAMPFree-text notes
    createdByUUIDFK → User, NULLCreator user
    createdAtTIMESTAMPNOT NULLRecord creation

    Index:Indexes:

    • Primary key: idx_notifications_userid
    • Index: idx_transactions_organization on organizationId
    • Index: user_ididx_transactions_date on transactionDate
    • Index: idx_transactions_debit on debitAccountId
    • Index: idx_transactions_credit on creditAccountId
    • Index: idx_transactions_reference on (referenceType, referenceId)
    • Composite: idx_transactions_org_date on (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:

    1. Invoice created (draft → sent):

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

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

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

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

    Banking & Reconciliation

    settings10. BankAccount

    Per-userPurpose: preferences.Bank account metadata.

    Column Type Constraints DefaultDescription
    user_idid TEXTUUID PRIMARY KEY, FK → users(id)PK -Primary key
    currencyorganizationId TEXTUUID -FK → Organization, NOT NULL 'NOK'Organization scope
    languageaccountId TEXTUUID -FK → Account, NOT NULL 'nb'GL account (must be Asset)
    push_enabledbankName INTEGERVARCHAR(255) -NOT NULL 1Bank name
    email_enabledaccountNumber INTEGERVARCHAR(50) -NULL 1Account number
    updated_atiban TEXTVARCHAR(50) -NULL CURRENT_TIMESTAMPIBAN
    currencyCodeCHAR(3)NOT NULL, default 'EUR'Account currency
    currentBalanceDECIMAL(19,4)NOT NULL, default 0Current balance
    isActiveBOOLEANNOT NULL, default trueActive status
    createdAtTIMESTAMPNOT NULLRecord creation
    updatedAtTIMESTAMPNOT NULLLast update

    Indexes:

    • Primary key: id
    • Index: idx_bank_accounts_organization on 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: TiedBank tostatement theimports. cardsFor feature. Only active when card feature flags are enabled.

    Card spending limits.reconciliation.

    Column Type Constraints DefaultDescription
    id TEXTUUID PRIMARY KEYPK -Primary key
    user_idbankAccountId TEXTUUID NOT NULL, FK → users(id)BankAccount, CASCADE, NOT NULL -Parent bank account
    card_idtransactionDate TEXTFK → cards(id)NULL
    limit_typeTEXTDATE NOT NULL -Transaction date
    amount REALDECIMAL(19,4) NOT NULL -Positive = credit, negative = debit
    currencydescription TEXTVARCHAR(500) -NULL 'NOK'Bank description
    created_atreference TEXTVARCHAR(255) -NULL CURRENT_TIMESTAMPReference number
    reconciledBOOLEANNOT NULL, default falseMatched to GL transaction
    matchedTransactionIdUUIDNULLGL transaction ID
    createdAtTIMESTAMPNOT NULLRecord creation

    Indexes:

    • Primary key: idx_spending_limits_userid
    • Index: idx_bank_transactions_account on user_id,bankAccountId
    • Index: idx_spending_limits_cardidx_bank_transactions_date on card_id

      transactionDate

    LimitBusiness typesrules:

    (API-enforced):
      daily,
    • Imported weekly,from monthly,CSV transaction

      bank statements
    • Matched to GL transactions via reconciliation workflow
    • reconciled = true when matched

    Multi-Currency

    rate_limits12. Currency

    PersistentPurpose: rateSupported limiting store.currencies.

    Column Type Constraints DefaultDescription
    keycode TEXTCHAR(3) PRIMARY KEYPK -ISO 4217 currency code
    countname INTEGERVARCHAR(100) NOT NULL -Currency name
    reset_atsymbol INTEGERVARCHAR(10)NULLCurrency symbol
    decimalPlacesSMALLINTNOT NULL, default 2Decimal precision
    isActiveBOOLEANNOT NULL, default trueActive status
    createdAtTIMESTAMP NOT NULL -Record creation

    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      | $    | 2
     for IP-based rate limiting. Expired entries are cleaned on each call (middleware.ts:11).


    Compliance

    13. & 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.

    Lastupdate
    Column Type Constraints DefaultDescription
    id TEXTUUID PRIMARY KEYPK -Primary key
    timestampbaseCurrency TEXTCHAR(3) -FK → Currency, NOT NULL CURRENT_TIMESTAMPFrom currency
    user_idtargetCurrency TEXTCHAR(3) FK → users(id)Currency, NOT NULL NULLTo currency
    actionrate TEXTDECIMAL(12,6) NOT NULL -Exchange rate
    resource_typeeffectiveDate TEXTDATE -NOT NULL NULLRate effective date
    resource_idsource TEXT-VARCHAR(50) NULLECB, fixer.io, manual
    detailslastUpdated TEXTTIMESTAMP -NOT NULL NULL
    ip_addressTEXT-NULL
    user_agentTEXT-NULLtimestamp

    Indexes:

    • Primary key: idx_audit_log_userid
    • Unique: (baseCurrency, targetCurrency, effectiveDate)
    • Index: idx_exchange_rates_date on user_id,effectiveDate
    • Index: idx_audit_log_timestampidx_exchange_rates_pair on timestamp,(baseCurrency, idx_audit_log_actiontargetCurrency)
    • on
    action

    Business rules:

    • Rates fetched daily from ECB or fixer.io API
    • When creating transaction, rate is locked at transaction date
    • If no rate for exact date, use nearest available (warn in logs)

    Audit Trail

    14. LoggedAction

    Purpose: 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.

    Application
    Column Type Constraints DefaultDescription
    ideventId TEXTBIGINT PRIMARYPK, KEYAUTOINCREMENT -Event ID
    user_idTEXTNOT NULL, FK → users(id)-
    alert_typeschemaName TEXT NOT NULL -Database schema (default: public)
    severitytableNameTEXTNOT NULLTable name
    userIdUUIDFK → User, NULLUser who performed action
    actionTimestampTIMESTAMPNOT NULL, default now()When action occurred
    actionENUMNOT NULLINSERT, UPDATE, DELETE
    rowDataJSONBNULLFull row data before change
    changedFieldsJSONBNULLChanged fields (UPDATE only)
    queryTextTEXTNULLSQL query (if available)
    clientIpINETNULLClient IP address
    applicationName TEXT NOT NULL, CHECK(default 'low','medium','high','critical')fiken-clone-api' -
    transaction_idTEXTFK → transactions(id)NULL
    detailsTEXT-NULL
    statusTEXTCHECK('open','investigating','resolved','escalated','filed')'open'
    reviewed_byTEXT-NULL
    reviewed_atTEXT-NULL
    created_atTEXT-CURRENT_TIMESTAMPidentifier

    Indexes:

    • Primary key: idx_aml_alerts_usereventId
    • Index: idx_logged_actions_timestamp on user_id,actionTimestamp
    • Index: idx_aml_alerts_statusidx_logged_actions_table on tableName
    • Index: statusidx_logged_actions_user on userId

    Enums:

    enum AuditAction {
      INSERT
      UPDATE
      DELETE
    }
    

    Business rules:

    • APPEND-ONLY — NEVER delete or update records
    • Triggered via Prisma middleware (automatic)
    • Used for: compliance, debugging, rollback simulation

    Schema Version

    15. SchemaVersion

    Purpose: 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.

    Migration
    Column Type Constraints DefaultDescription
    idversion TEXTVARCHAR(20) PRIMARY KEYPK -Version string (e.g., "1.0.0")
    user_idappliedAt TEXTTIMESTAMP NOT NULL, FKdefault → users(id)-
    alert_idTEXTFK → aml_alerts(id)NULL
    report_typeTEXTNOT NULL-
    statusTEXTCHECK('draft','submitted','acknowledged'now() 'draft'
    filed_atTEXT-NULL
    reference_numberTEXT-NULL
    detailsTEXT-NULL
    created_atTEXT-CURRENT_TIMESTAMP

    Purpose: Tracks STRs filed with Økokrim/EFE (Norwegian financial intelligence unit) per hvitvaskingsloven requirements.


    screening_results

    Results from sanctions/PEP (Politically Exposed Persons) screening.

    ColumnTypeConstraintsDefault
    idTEXTPRIMARY KEY-
    user_idTEXTNOT NULL, FK → users(id)-
    screening_typeTEXTNOT NULL, CHECK('pep','sanctions','adverse_media')-
    providerTEXT-NULL
    resultTEXTNOT NULL, CHECK('clear','match','potential_match','error')-
    match_detailsTEXT-NULL
    screened_atTEXT-CURRENT_TIMESTAMP

    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

    ColumnTypeConstraintsDefault
    idTEXTPRIMARY KEY-
    user_idTEXTNOT NULL, FK → users(id)-
    consent_typeTEXTNOT NULL-
    grantedINTEGERNOT NULL1
    granted_atTEXT-CURRENT_TIMESTAMP
    withdrawn_atTEXT-NULL
    ip_addressTEXT-NULL

    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).

    ColumnTypeConstraintsDefault
    idTEXTPRIMARY KEY-
    user_idTEXTNOT NULL, FK → users(id)-
    request_typeTEXTNOT NULL, CHECK('export','erasure','rectification','restriction')-
    statusTEXTCHECK('pending','processing','completed','rejected')'pending'
    requested_atTEXT-CURRENT_TIMESTAMP
    completed_atTEXT-NULL
    download_urlTEXT-NULL
    notesTEXT-NULL

    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).

    Migration
    ColumnTypeConstraintsDefault
    idTEXTPRIMARY KEY-
    user_idTEXTNOT NULL, FK → users(id)-
    categoryTEXTNOT NULL-
    subjectTEXTNOT NULL-timestamp
    description TEXT NOT NULL -
    statusTEXTCHECK('received','investigating','resolved','escalated')'received'
    resolutionTEXT-NULL
    created_atTEXT-CURRENT_TIMESTAMP
    resolved_atTEXT-NULLdescription

    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: idx_complaints_userNEVER onuse user_idfloat, idx_complaints_statusdouble, or JavaScript number 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 number has 53-bit precision (API-enforced):safe transaction,up service,to fees,2^53 privacy,- technical,1 other

      = 9,007,199,254,740,991)
    • 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

    1. List by organization + filter:

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

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

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

      • Index on (referenceType, referenceId) for transaction lookups

    Migration Commands

    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

    TypeName formatExample
    Add tableadd_{table_name}add_webhook_subscriptions
    Add columnadd_{column}_to_{table}add_sefId_to_invoices
    Remove columnremove_{column}_from_{table}remove_legacy_field_from_users
    Add indexadd_index_{table}_{columns}add_index_invoices_status_dueDate
    Data fixfix_{description}fix_applicationName_default

    Zero-Downtime Migration Patterns

    For production migrations on live data:

    PatternWhen to useExample
    Additive onlyNew nullable columnsAdd exchange_ratessefId String? to Invoice
    Expand-contractRename columnAdd new column → backfill → drop old
    Shadow tableLarge table restructureCreate new table → migrate → swap
    Index concurrentlyAdd index without lockManual 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 status shows 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.applicationName default 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:

    TableIndex ColumnsTypePurpose
    Invoice(organizationId, status, invoiceDate)CompositeList invoices with status filter
    Invoice(organizationId, contactId)CompositeInvoices by customer
    Invoice(organizationId, dueDate, status)CompositeOverdue invoice cron
    InvoiceItem(invoiceId)FKLoad line items
    Expense(organizationId, status, expenseDate)CompositeList expenses with filter
    Expense(organizationId, categoryAccountId)CompositeExpense by category
    Transaction(organizationId, transactionDate)CompositeDate range reports
    Transaction(organizationId, debitAccountId)CompositeAccount balance calc
    Transaction(organizationId, creditAccountId)CompositeAccount balance calc
    Transaction(referenceType, referenceId)CompositeLookup by invoice/expense
    BankTransaction(bankAccountId, transactionDate)CompositeBank statement view
    BankTransaction(organizationId, reconciled)CompositeUnreconciled transactions
    ExchangeRate(baseCurrency, targetCurrency, effectiveDate)UniqueDaily rate lookup
    LoggedAction(organizationId, tableName, createdAt)CompositeAudit trail queries
    LoggedAction(organizationId, userId)CompositeUser activity queries

    Index Design Principles

    1. Org-first composite indexes: Every query filters by organizationId first — it must be the leftmost column in all multi-column indexes.
    2. Status + date combos: List endpoints combine status filter + date sort, so (orgId, status, date) triples cover both filter and ORDER BY.
    3. Foreign key indexes: Prisma creates FK indexes automatically; verify with \d tableName in psql.
    4. 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

    QueryTargetNotes
    List invoices (1000 rows)< 50msWith org + status index
    Invoice by ID< 5msPK lookup
    Dashboard metrics< 300ms7 parallel aggregations
    P&L report (1 year)< 500msTransaction table scan with date index
    Audit trail query< 100msLoggedAction composite index
    VAT report (monthly)< 200msInvoice + 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 seedData(LoggedAction become slow. With partitioning: queries can prune partitions by year.

    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:

    1. Define base table in schema.prisma (db.ts:530)no populates:

      partitioning
      • 6 exchange rate corridors (NOK → RSD, BAM, PLN, PKR, TRY, EUR)directive)
      • DemoApply datapartitioning (whenvia NODE_ENVprisma !==db "production"execute orwith SEED_DEMO=true):raw
          SQL
        • 1after demoinitial user (usr_demo1, [email protected], role: merchant)migration
        • 3Maintain recipientspartition creation as a yearly operations task (Serbia,or Bosnia,use Turkey)pg_partman extension)
        • 1
    merchant

    Archive Strategy (Ahmetov7-Year Kebab)Retention)

  • 3
    Year transactions1-7:    Active partitions in PostgreSQL (2Railway remittances,EU 1Frankfurt)
    QRYear payment)
  • 7+:
  • 2Archive bankpartition accountsto cold storage (DNBCloudflare primaryR2 Glacier tier) Delete from PostgreSQL Retain R2 archives for GDPR compliance period

    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)

  • for < 1M rows. Implement partitioning in Phase 2 before reaching 5M rows.


    End of Database Schema