Skip to main content

Database Schema

Bilko Database Schema

Status: IMPLEMENTED (PrismaLast schemaverified: exists)2026-05-20 Location:Canonical backend: /Users/makinja/ALAI/products/Bilko/packages/database/prisma/schema.prismaapps/api Kotlin/Ktor service Database: PostgreSQL 14+on GCP Cloud SQL for deployed environments ORM:Schema source of truth: PrismaFlyway 5.xSQL Lastmigrations updated:+ 2026-02-20Exposed table mappings


Purpose

This document describes the complete database schema forcurrently Bilko.used by the Bilko Kotlin/Ktor API. It replaces the older ORM-era database notes and must be kept aligned with:

  • Flyway migrations: apps/api/src/main/resources/db/migration/
  • Exposed table mappings: apps/api/src/main/kotlin/no/alai/bilko/models/Tables.kt
  • Route/service behaviour: apps/api/src/main/kotlin/no/alai/bilko/routes/ and apps/api/src/main/kotlin/no/alai/bilko/services/
  • Environment mapping: infrastructure/gcp/ENV-MATRIX.md

Do not treat generated diagrams, frontend type definitions, or archived deployment notes as database authority.


1. Architecture Overview

Bilko is a multi-tenant accounting SaaS. The active backend stores tenant data in PostgreSQL and scopes business tables by organization_id where appropriate.

Runtime stack:

  • API: Kotlin/Ktor
  • SQL migration engine: Flyway
  • Kotlin SQL mapping: JetBrains Exposed
  • Database engine: PostgreSQL
  • Deployed DB platform: GCP Cloud SQL
  • Primary migration command path: Cloud Build / backend Gradle Flyway tasks

The schema is IMPLEMENTEDforward-only. inApplied Prismamigrations must not be edited after deployment. If a deployed environment has Flyway metadata drift, repair is handled as a controlled operations procedure with target identity checks, schema checks, transcript, and readypostflight for migration. This doc explains the relationships, constraints, and design decisions.validation.


Entity

Relationship Overview

overview
OrganizationerDiagram
  (1)organizations ──┬──||--o{ (N)users User: ├──owns
  (N)organizations Account||--o{ ├──accounts (N): Contactowns
  ├──organizations (N)||--o{ Invoicecontacts ├──: (N)owns
  Expenseorganizations ├──||--o{ (N)invoices Transaction: └──owns
  (N)organizations BankAccount||--o{ Contactexpenses (1): ────┬──owns
  (N)organizations Invoice||--o{ └──transactions (N): Expenseowns
  Invoiceorganizations (1)||--o{ ────bank_accounts (N): InvoiceItemowns
  Accountorganizations (1)||--o{ ───┬──recurring_invoices (N): InvoiceItemowns
  ├──organizations (N)||--o{ Expenseadapter_config ├──: (N)configures
  BankAccountorganizations ├──||--o{ (N)stripe_webhook_events Transaction: (debit)receives

  ├──users (N)||--o{ Transactionrefresh_tokens (credit): └──has
  (N)users Account||--o{ (parent-childinvoices hierarchy): BankAccountcreates
  (1)users ──||--o{ (N)expenses BankTransaction: Currencycreates
  (1)users ───┬──||--o{ (N)transactions ExchangeRate: (base)creates
  └──users (N)||--o{ ExchangeRatelogged_actions (target): Useractor

  (1)account_types ───┬──||--o{ (N)accounts Invoice: (creator)classifies
  ├──accounts (N)||--o{ Expenseaccounts (creator): ├──parent
  (N)accounts Expense||--o{ (approver)invoice_items ├──: (N)revenue_account
  Transactionaccounts (creator)||--o{ └──expenses (N): LoggedActionexpense_account
  accounts ||--o{ bank_accounts : ledger_account
  accounts ||--o{ transactions : debit_account
  accounts ||--o{ transactions : credit_account

  contacts ||--o{ invoices : customer
  contacts ||--o{ expenses : vendor
  contacts ||--o{ recurring_invoices : template_customer

  invoices ||--o{ invoice_items : contains
  bank_accounts ||--o{ bank_transactions : imports
  transactions ||--o{ bank_transactions : reconciles
  currencies ||--o{ exchange_rates : base_currency
  currencies ||--o{ exchange_rates : target_currency

Core Tables

1. Organization

Purpose: Multi-tenant root. Every business is one 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

Indexes:

  • Primary key: id

Business rules:

  • baseCurrency determines default currency for all transactions
  • country determines tax rules (Serbia 20%, BiH 17%, Croatia 25%)
  • fiscalYearStart used for annual reports

2. User

Source-of-Truth

Purpose: Users within an organization. Role-based access control.

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
organizationIdUUIDFK → Organization, NOT NULL, CASCADEOrganization membership
emailVARCHAR(255)UNIQUE, NOT NULLLogin email
passwordHashVARCHAR(255)NOT NULLbcrypt hash (12 rounds)
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

Chart of AccountsRules

3. AccountType

Purpose: Defines account categories for double-entry bookkeeping.

ColumnTypeConstraintsDescription
idINTPK, AUTOINCREMENTPrimary key
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.

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
organizationIdUUIDFK → Organization, NOT NULLOrganization scope
codeVARCHAR(10)NOT NULLAccount code (e.g., "1000", "4000")
nameVARCHAR(255)NOT NULLAccount name (e.g., "Cash", "Revenue")
accountTypeIdINTFK → AccountType, NOT NULLAccount category
currencyCodeCHAR(3)NOT NULL, default 'EUR'Account currency
parentAccountIdUUIDFK → Account, NULLParent account (for sub-accounts)
isActiveBOOLEANNOT NULL, default trueActive status
createdAtTIMESTAMPNOT NULLRecord creation
updatedAtTIMESTAMPNOT NULLLast update

Indexes:

  • Primary key: id
  • Unique: (organizationId, code)
  • Index: idx_accounts_organization on organizationId
  • Index: idx_accounts_type on accountTypeId

Business rules:

  • Code MUST be unique within organization
  • Cannot delete account with transactions
  • Parent-child hierarchy for sub-accounts (e.g., 1000 → 1001, 1002)

Contacts (Customers & Vendors)

5. Contact

Purpose: Customers (invoice recipients) and vendors (expense payees).

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
organizationIdUUIDFK → Organization, NOT NULLOrganization scope
typeENUMNOT NULLcustomer, 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
notesTEXTNULLFree-text notes
isActiveBOOLEANNOT NULL, default trueActive status
createdAtTIMESTAMPNOT NULLRecord creation
updatedAtTIMESTAMPNOT NULLLast update

Indexes:

  • Primary key: id
  • Index: idx_contacts_organization on organizationId
  • Index: idx_contacts_type on type

Enums:

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

Business rules:

  • Soft delete (isActive = false) if has invoices/expenses
  • currencyCode determines default invoice/expense currency

Invoicing

6. Invoice

Purpose: Sales invoices (outgoing). Revenue recognition.

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
organizationIdUUIDFK → Organization, NOT NULLOrganization scope
customerIdUUIDFK → Contact, NOT NULLInvoice recipient
invoiceNumberVARCHAR(50)NOT NULLAuto-generated (e.g., INV-2026-001)
invoiceDateDATENOT NULLInvoice issue date
dueDateDATENOT 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
notesTEXTNULLInternal notes
termsTEXTNULLPayment terms text
pdfUrlVARCHAR(500)NULLCloudflare R2 URL
createdByUUIDFK → User, NULLCreator user
createdAtTIMESTAMPNOT NULLRecord creation
updatedAtTIMESTAMPNOT NULLLast update

Indexes:

  • Primary key: id
  • Unique: (organizationId, invoiceNumber)
  • Index: idx_invoices_organization on organizationId
  • Index: idx_invoices_customer on customerId
  • Index: idx_invoices_status on status
  • Index: idx_invoices_due_date on dueDate
  • Composite: idx_invoices_org_status_date on (organizationId, status, invoiceDate)

Enums:

enum InvoiceStatus {
  draft      // Being edited
  sent       // Sent to customer
  viewed     // Customer viewed email
  paid       // Payment received
  overdue    // Past dueDate and unpaid
  cancelled  // Voided
}

Business rules:

  • invoiceNumber auto-generated on first save
  • exchangeRate locked at invoiceDate (NEVER recalculate)
  • baseAmount = totalAmount * exchangeRate
  • Cannot edit invoice unless status = draft
  • When status changes to 'paid', create Transaction (debit BankAccount, credit AccountsReceivable)

7. InvoiceItem

Purpose: Line items on invoices.

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
invoiceIdUUIDFK → Invoice, CASCADE, NOT NULLParent invoice
lineNumberINTNOT NULLLine order (1, 2, 3...)
descriptionVARCHAR(500)NOT NULLItem description
quantityDECIMAL(10,2)NOT NULLQuantity sold
unitPriceDECIMAL(19,4)NOT NULLPrice per unit
taxRateDECIMAL(5,2)NOT NULL, default 0VAT rate (20 = 20%)
lineTotalDECIMAL(19,4)NOT NULLquantity * unitPrice
accountIdUUIDFK → Account, NULLRevenue account
createdAtTIMESTAMPNOT NULLRecord creation

Indexes:

  • Primary key: id
  • Index: idx_invoice_items_invoice on invoiceId

Business rules:

  • lineTotal = quantity * unitPrice (calculated before save)
  • Tax amount = lineTotal * (taxRate / 100)
  • accountId determines which revenue account is credited

Expenses

8. Expense

Purpose: Purchase tracking (incoming). Expense recognition.

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
organizationIdUUIDFK → Organization, NOT NULLOrganization scope
vendorIdUUIDFK → Contact, NULLVendor (optional)
expenseNumberVARCHAR(50)NOT NULLAuto-generated (e.g., EXP-2026-001)
expenseDateDATENOT NULLExpense date
currencyCodeCHAR(3)NOT NULLExpense 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: id
  • Unique: (organizationId, expenseNumber)
  • Index: idx_expenses_organization on organizationId
  • Index: idx_expenses_vendor on vendorId
  • Index: idx_expenses_category on category
  • Index: idx_expenses_date on expenseDate
  • Composite: idx_expenses_org_date_category on (organizationId, expenseDate, category)

Enums:

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

Business rules:

  • expenseNumber auto-generated
  • exchangeRate locked at expenseDate
  • baseAmount = amount * exchangeRate
  • When status → approved, create Transaction (debit ExpenseAccount, credit AccountsPayable)
  • When status → paid, create Transaction (debit AccountsPayable, credit BankAccount)

Transactions (Double-Entry Ledger)

9. Transaction

Purpose: General ledger transactions. Every financial event creates a transaction.

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
organizationIdUUIDFK → Organization, NOT NULLOrganization scope
transactionDateDATENOT NULLTransaction date
descriptionVARCHAR(255)NOT NULLTransaction description
debitAccountIdUUIDFK → Account, NOT NULLAccount to debit
creditAccountIdUUIDFK → Account, NOT NULLAccount to credit
amountDECIMAL(19,4)NOT NULLTransaction amount
currencyCodeCHAR(3)NOT NULLTransaction currency
exchangeRateDECIMAL(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
notesTEXTNULLFree-text notes
createdByUUIDFK → User, NULLCreator user
createdAtTIMESTAMPNOT NULLRecord creation

Indexes:

  • Primary key: id
  • Index: idx_transactions_organization on organizationId
  • Index: idx_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. InvoiceAdd createdschema (draftchanges via sent):new Flyway migrations only.

    • Debit:Use Accountsthe Receivablenext (Asset)available version under apps/api/src/main/resources/db/migration/.
    • Credit:Never Revenuerewrite (Revenue)an already-applied migration in demo, staging, or production.
  2. InvoiceUpdate paid:Exposed mappings in the same change.

    • Debit:Tables.kt Bankshould Accountmatch (Asset)
    • the
    • Credit:migrated Accountsdatabase Receivablesurface (Asset)used by routes/services.
  3. ExpenseUpdate approved:API and docs together.

    • Debit:If Expensea Accountschema (Expense)
    • change
    • Credit:alters Accountsrequest/response Payableshapes, (Liability)update docs/backend/openapi.yaml and relevant backend docs.
  4. ExpenseValidate paid:with Flyway before deploy promotion.

    • Debit:A Accountsvalid Payabledeploy (Liability)target must pass Flyway validation before the API is considered healthy.
  5. Credit: Bank

    Keep Accountenvironment (Asset)identity explicit.

    • Staging, demo, and production databases are separate targets. Migration or repair work must state which database is being touched.

Banking3. &Migration ReconciliationInventory

10.

As BankAccount

of this verification pass, the repository contains 36 Flyway migration files. The deployed stage repair for MC #101509 validated the current Flyway version as 35 after applying pending migrations.

Purpose:Important Bankmigration account metadata.groups:

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
organizationIdUUIDFK → Organization, NOT NULLOrganization scope
accountIdUUIDFK → Account, NOT NULLGL account (must be Asset)
bankNameVARCHAR(255)NOT NULLBank name
accountNumberVARCHAR(50)NULLAccount number
ibanVARCHAR(50)NULLIBAN
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)

11. BankTransaction

Purpose: Bank statement imports. For reconciliation.

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
bankAccountIdUUIDFK → BankAccount, CASCADE, NOT NULLParent bank account
transactionDateDATENOT NULLTransaction date
amountDECIMAL(19,4)NOT NULLPositive = credit, negative = debit
descriptionVARCHAR(500)NULLBank description
referenceVARCHAR(255)NULLReference number
reconciledBOOLEANNOT NULL, default falseMatched to GL transaction
matchedTransactionIdUUIDNULLGL transaction ID
createdAtTIMESTAMPNOT NULLRecord creation

Indexes:

  • Primary key: id
  • Index: idx_bank_transactions_account on bankAccountId
  • Index: idx_bank_transactions_date on transactionDate

Business rules:

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

Multi-Currency

12. Currency

Purpose: Supported currencies.

ColumnTypeConstraintsDescription
codeCHAR(3)PKISO 4217 currency code
nameVARCHAR(100)NOT NULLCurrency name
symbolVARCHAR(10)NULLCurrency symbol
decimalPlacesSMALLINTNOT NULL, default 2Decimal precision
isActiveBOOLEANNOT NULL, default trueActive status
createdAtTIMESTAMPNOT NULLRecord creation

Seed data:

EUR | Euro           | €    | 2
RSD | Serbian Dinar  | din. | 2
BAM | Bosnian Mark   | KM   | 2
HRK | Croatian Kuna  | kn   | 2
USD | US Dollar      | $    | 2

13. ExchangeRate

Purpose: Historical exchange rates.

ColumnTypeConstraintsDescription
idUUIDPKPrimary key
baseCurrencyCHAR(3)FK → Currency, NOT NULLFrom currency
targetCurrencyCHAR(3)FK → Currency, NOT NULLTo currency
rateDECIMAL(12,6)NOT NULLExchange rate
effectiveDateDATENOT NULLRate effective date
sourceVARCHAR(50)NULLECB, fixer.io, manual
lastUpdatedTIMESTAMPNOT NULLLast update timestamp

Indexes:

  • Primary key: id
  • Unique: (baseCurrency, targetCurrency, effectiveDate)
  • Index: idx_exchange_rates_date on effectiveDate
  • Index: idx_exchange_rates_pair on (baseCurrency, targetCurrency)

Business rules:

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

Audit Trail

14. LoggedAction

Purpose: Immutable audit log. Captures all INSERT/UPDATE/DELETE.

ColumnTypeConstraintsDescription
eventIdBIGINTPK, AUTOINCREMENTEvent ID
schemaNameTEXTNOT NULLDatabase schema (default: public)
tableNameTEXTNOT 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
applicationNameTEXTNOT NULL, default 'fiken-clone-api'Application identifier

Indexes:

  • Primary key: eventId
  • Index: idx_logged_actions_timestamp on actionTimestamp
  • Index: idx_logged_actions_table on tableName
  • Index: idx_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: Migration tracking.

ColumnTypeConstraintsDescription
versionVARCHAR(20)PKVersion string (e.g., "1.0.0")
appliedAtTIMESTAMPNOT NULL, default now()Migration timestamp
descriptionTEXTNULLMigration description

Business rules:

  • Updated by Prisma migrations
  • Used to verify schema version matches application version

Data Types & Precision

NUMERIC(19,4) for ALL Money

CRITICAL: NEVER use float, double, or JavaScript number for currency.

  • Precision: 19 digits total, 4 decimal places
  • Range: -999,999,999,999,999.9999 to +999,999,999,999,999.9999
  • Prisma type: Decimal
  • PostgreSQL type: NUMERIC(19,4)

Why:

  • JavaScript number has 53-bit precision (safe up to 2^53 - 1 = 9,007,199,254,740,991)
  • Financial calculations require exact decimal precision
  • Example: 0.1 + 0.2 = 0.30000000000000004 (float error)

Usage in code:

import { Decimal } from '@prisma/client/runtime'

const amount = new Decimal('125000.0000')
const taxRate = new Decimal('0.20')
const taxAmount = amount.times(taxRate) // 25000.0000

Constraints Summary

Primary Keys

All tables use UUID primary keys (except AccountType uses INT auto-increment).

Foreign Keys

All foreign keys have onDelete: Cascade (deleting organization deletes all data).

Unique Constraints

  • User.email
  • Account.(organizationId, code)
  • Invoice.(organizationId, invoiceNumber)
  • Expense.(organizationId, expenseNumber)
  • ExchangeRate.(baseCurrency, targetCurrency, effectiveDate)

Check Constraints

(Enforced in API layer, not database):

  • amount > 0 for all financial amounts
  • dueDate >= invoiceDate for invoices
  • debitAccountId ≠ creditAccountId for transactions

Indexes Strategy

Query Patterns Optimized

  1. List by organization + filter:

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

# Generate Prisma Client
npx prisma generate

# Create migration
npx prisma migrate dev --name migration_name

# Apply migrations (production)
npx prisma migrate deploy

# Reset database (dev only)
npx prisma migrate reset

# Seed initial data
npx prisma db seed

Seed Data

AccountType

INSERT INTO account_types (id, name, normal_balance) VALUES
(1, 'Asset', 'debit'),
(2, 'Liability', 'credit'),
(3, 'Equity', 'credit'),
(4, 'Revenue', 'credit'),
(5, 'Expense', 'debit');

Currency

INSERT INTO currencies (code, name, symbol, decimal_places) VALUES
('EUR', 'Euro', '€', 2),
('RSD', 'Serbian Dinar', 'din.', 2),
('BAM', 'Bosnian Mark', 'KM', 2),
('HRK', 'Croatian Kuna', 'kn', 2),
('USD', 'US Dollar', '$', 2);

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 sefId 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:

schema,compatibilitytiers,StripeRLSpermissiveAdapterconfiguration,hardening,security-defineradmingrants,bcrypt-prefixnormalization,passwordhash
TableIndex ColumnsType Purpose
InvoiceV1-V6 (organizationId,Initial status,accounting invoiceDate) Composite Listcolumns, invoicesencrypted withidentifiers, statussupplementary filtertables, organization logo, role storage normalization
InvoiceV7-V15 (organizationId,Plan contactId) Composite Invoiceswebhook bylogging, customercompliance calendar, recurring invoices, audit enum cleanup, demo/CI seed data, trial fields
InvoiceV16-V24 (organizationId,Country dueDate,constraints, status) Composite Overduebaseline, invoiceinvoice/expense cronstatus enum support, demo org fixes, CI viewer, BA jurisdiction split, BA entity charts
InvoiceItemV25-V29 (invoiceId) FK Loadplatform-admin linemarker, itemsSerbia chart, logged action width, UAT demo users
ExpenseV30-V35 (organizationId,RLS/session status,auth expenseDate) Composite Listauth expenseshelpers, withdemo filter
Expense (organizationId,UAT categoryAccountId) Composite Expense 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 queriesreset

Index

Operational Designnote Principles

from
    MC #101509:

    • Org-firstStaging compositechecksum indexes:drift Everywas querydetected filtersfor V22, V25, V26, and V28.
    • Schema checks proved checksum-only drift before repair.
    • Controlled Flyway repair + migrate brought staging to version 35 and flyway validate passed.
    • The authoritative stage Cloud Build trigger then succeeded.

    4. Tenant and Security Model

    Most business data is scoped by organizationIdorganization_id firstand accessed through authenticated Ktor routes. The schema includes:

    • Organization-level tenant boundary.
    • User roles per organization.
    • Platform-admin marker for controlled platform operations.
    • Refresh-token session storage.
    • Logged action/audit table.
    • Row-level-security related migration work in the V17 and V30+ series.

    Application code must set the correct organization/user context before querying tenant-scoped tables. Any new table containing tenant data should include organization_id unless it mustis beintentionally theglobal leftmostreference columndata.

    in
    all

    5. multi-columnTables

    indexes.

    The

  1. Statusfollowing +table dateinventory combos:is Listderived endpoints combine status filter + date sort, sofrom (orgId, status, date)Tables.kt triples cover both filter and ORDERactive BY.
  2. migrations.

  3. Foreign

    Column keynotation

    indexes
    :

    The Prismadetailed createstype/constraint FKauthority indexesremains automatically;Flyway verifySQL withplus \dTables.kt. tableNameThis indocument psql.

  4. uses
  5. Coveringthese indexes:shorthand Fortypes reportfor queriesquick that fetch only a few columns, consider partial indexes in Phase 2 (e.g., WHERE status = 'paid').

Query Performance Targets

review:

QueryNotation TargetNotesMeaning
Listuuid pkUUID primary key
uuid fkUUID foreign key
text / varchar(n)String column; exact length is migration-defined
numericDecimal money/rate value
date / timestampDate/time column
json/jsonbStructured JSON column
boolBoolean
soft-deleteNullable deleted_at lifecycle column

Common lifecycle columns are created_at, updated_at, version, and deleted_at where present.

5.1 organizations

Tenant root table.

Key fields:

  • id
  • name
  • registration_number
  • vat_number, vat_country, vat_registered, vat_rate
  • firm_type
  • base_currency
  • country, language
  • fiscal_year_start
  • logo_url
  • security_settings
  • subscription/trial fields: plan_tier, quota_invoices_month, quota_contacts, quota_users, stripe_customer_id, stripe_subscription_id, trial_started_at, trial_ends_at
  • lifecycle fields: created_at, updated_at, version, deleted_at

Column summary:

Column groupType/constraint summary
Identityid uuid pk, name text
Registration/taxregistration and VAT columns are nullable text values; VAT registration is boolean-backed
Locale/marketcountry, language, base currency, fiscal year start
Branding/settingslogo_url, security_settings json/jsonb
Subscription/trialplan, quota, Stripe IDs, trial timestamps
Lifecycletimestamps, version, deleted_at soft-delete

Notes:

  • country is constrained by migration history and used by market-specific tax/e-invoice logic.
  • BA jurisdiction support was added in the V22/V23/V24 migration set.

5.2 users

Authenticated users belonging to organizations.

Key fields:

  • id
  • organization_id
  • email
  • password_hash
  • full_name
  • role
  • two_factor_enabled, two_factor_secret, two_factor_backup_codes
  • notification_preferences
  • last_login_at
  • invite fields: invite_token, invite_expires_at
  • status
  • is_platform_admin
  • lifecycle fields: created_at, updated_at, version, deleted_at

Column summary:

Column groupType/constraint summary
Identityid uuid pk, organization_id uuid fk
Loginemail text unique, password_hash text
RBACrole text, status text, is_platform_admin bool
2FAboolean flag, secret, backup-code storage
Invites/session metadatainvite token/expiry, last login timestamp
Lifecycletimestamps, version, deleted_at soft-delete

Notes:

  • Password and 2FA behaviour is implemented in the Kotlin auth services.
  • is_platform_admin was added by V26.

5.3 refresh_tokens

Session refresh-token storage.

Key fields:

  • id
  • user_id
  • jti
  • expires_at
  • created_at
  • version

Notes:

  • Used by auth-lifecycle and logout/revocation flows.
  • Session listing/revocation routes are documented in OpenAPI.

5.4 account_types

Reference data for account classifications.

Key fields:

  • id
  • name
  • normal_balance
  • created_at
  • version

5.5 accounts

Chart of accounts entries.

Key fields:

  • id
  • organization_id
  • code
  • name
  • account_type_id
  • currency_code
  • parent_account_id
  • is_active
  • lifecycle fields: created_at, updated_at, version, deleted_at

Notes:

  • Market-specific chart additions exist for BA and RS.
  • Account hierarchy is represented with parent_account_id.

5.6 contacts

Customers and vendors.

Key fields:

  • id
  • organization_id
  • type
  • name
  • email, phone
  • registration and tax identifiers: registration_number, vat_number, jmbg, jmbg_hash, oib, oib_hash
  • address fields: address_line1, address_line2, city, postal_code, country
  • currency_code
  • payment_terms
  • notes
  • is_active
  • lifecycle fields: created_at, updated_at, version, deleted_at

Notes:

  • Sensitive personal/business identifiers are handled through the Kotlin service layer and migration-provided columns.

5.7 invoices

Sales invoices (1000and rows)e-invoice tracking.

Key fields:

  • id
  • organization_id
  • customer_id
  • invoice_number
  • dates: invoice_date, due_date, sent_at, viewed_at, paid_at
  • money fields: currency_code, exchange_rate, subtotal, tax_amount, discount_amount, total_amount, base_amount
  • status
  • notes, terms, pdf_url
  • e-invoice fields: is_reverse_charge, sef_id, sef_document_id, sef_status, sef_submitted_at, sef_accepted_at
  • created_by
  • lifecycle fields: created_at, updated_at, version, deleted_at

Column summary:

Column groupType/constraint summary
Identity/scope <id 50msuuid pk, organization_id uuid fk, customer_id uuid fk
Numbering/dates Withinvoice orgnumber, +invoice/due dates, send/view/pay timestamps
Amountssubtotal, tax, discount, total, base amount as numeric money values
Statusstatus text/enum-backed by migration history
E-invoicereverse-charge flag and SEF IDs/status/timestamps
Ownership/lifecyclecreator user, timestamps, version, deleted_at soft-delete

Notes:

  • Invoice status enum support was added by V18.
  • Serbia SEF integration fields are present on the invoice table.

5.8 invoice_items

Line items for invoices.

Key fields:

  • id
  • invoice_id
  • line_number
  • description
  • quantity
  • unit_price
  • tax_rate
  • vat_exempt
  • line_total
  • account_id
  • created_at
  • version
  • deleted_at

5.9 recurring_invoices

Recurring invoice templates/schedules.

Key fields:

  • id
  • organization_id
  • contact_id
  • frequency
  • next_issue_date
  • day_of_month
  • currency_code
  • notes
  • is_active
  • template_data
  • created_at, updated_at

5.10 expenses

Purchase/expense records.

Key fields:

  • id
  • organization_id
  • vendor_id
  • expense_number
  • expense_date
  • money fields: currency_code, exchange_rate, amount, base_amount, tax_amount
  • category
  • payment_method
  • account_id
  • description
  • receipt_url
  • status
  • approval/payment fields: approved_by, approved_at, paid_at
  • created_by
  • lifecycle fields: created_at, updated_at, version, deleted_at

Column summary:

Column groupType/constraint summary
Identity/scopeid uuid pk, organization_id uuid fk, vendor_id uuid fk
Numbering/dateexpense number and expense date
Amountsamount, base amount, tax amount, currency, exchange rate
Classificationcategory, payment method, expense ledger account
Approval/paymentstatus, approver, approved timestamp, paid timestamp
Evidence/lifecyclereceipt URL, creator, timestamps, version, deleted_at soft-delete

Notes:

  • Expense status enum support was added by V19.

5.11 transactions

General ledger transactions.

Key fields:

  • id
  • organization_id
  • transaction_date
  • description
  • debit_account_id
  • credit_account_id
  • money fields: amount, currency_code, exchange_rate, base_amount
  • source reference: reference_type, reference_id
  • lock/reconciliation fields: locked, locked_at, reconciled, reconciled_at
  • notes
  • created_by
  • created_at
  • version
  • deleted_at

Column summary:

Column groupType/constraint summary
Identity/scopeid uuid pk, organization_id uuid fk
Double-entry legsdebit account FK, credit account FK
Amountsamount, base amount, currency, exchange rate
Source referencereference type/id links back to invoices, expenses, or manual entries
Controlslock and reconciliation flags/timestamps
Lifecyclecreator, created_at, version, deleted_at soft-delete

5.12 bank_accounts

Bank accounts linked to ledger accounts.

Key fields:

  • id
  • organization_id
  • account_id
  • bank_name
  • account_number
  • iban
  • currency_code
  • current_balance
  • is_active
  • lifecycle fields: created_at, updated_at, version, deleted_at

5.13 bank_transactions

Imported or entered bank movements.

Key fields:

  • id
  • bank_account_id
  • transaction_date
  • amount
  • description
  • reference
  • reconciled
  • matched_transaction_id
  • created_at
  • version
  • deleted_at

5.14 currencies

Currency reference data.

Key fields:

  • code
  • name
  • symbol
  • decimal_places
  • is_active
  • created_at
  • version

5.15 exchange_rates

Foreign-exchange rates.

Key fields:

  • id
  • base_currency
  • target_currency
  • rate
  • effective_date
  • source
  • last_updated
  • version
  • deleted_at

5.16 logged_actions

Audit table populated by database/application audit paths.

Column summary:

Column groupType/constraint summary
Identityevent_id primary identifier
Targetschema/table names
Actor/timeuser ID, timestamp, client IP, application name
Change payloadaction, row data, changed fields, query text

Key fields:

  • event_id
  • schema_name
  • table_name
  • user_id
  • action_timestamp
  • action
  • row_data
  • changed_fields
  • query
  • client_ip
  • application_name

Notes:

  • V28 widened the action column.
  • V30+ migrations add auth/RLS-related grants and helper functions.

5.17 chat_conversations

AI assistant conversation storage.

Key fields:

  • id
  • user_id
  • organization_id
  • messages
  • updated_at
  • version
  • deleted_at

5.18 beta_interests

Public/beta interest capture.

Key fields:

  • id
  • email
  • company_size
  • use_case
  • source
  • created_at
  • version

5.19 leads

Lead capture records from public/landing flows.

Key fields:

  • id
  • name
  • email
  • company
  • phone
  • country
  • message
  • lead_source
  • ip
  • user_agent
  • status
  • created_at

5.20 stripe_webhook_events

Payment provider webhook idempotency/audit log.

Key fields:

  • id
  • event_type
  • organization_id
  • payload
  • processed_at
  • error

5.21 sef_webhook_events

SEF status webhook idempotency/audit log added by V36. Because SEF does not expose a documented immutable event ID, the API computes a SHA-256 idempotency key from the parsed SEF payload and raw body before calling SefService.handleWebhook().

Key fields:

  • id — SHA-256 idempotency key; primary key
  • sef_invoice_id
  • status
  • status_date
  • payload
  • processing_status — processing, processed, or failed
  • processed_at
  • error
  • created_at

Notes:

  • Duplicate webhook deliveries with the same idempotency key return 200 with duplicate=true and are not reprocessed once processing is in progress or complete.
  • Failed events are marked failed; a later duplicate delivery can retry processing.
  • Signature verification still happens first via X-Sef-Signature / SEF_WEBHOOK_SECRET.

5.22 adapter_config

Per-market integration adapter toggles.

Key fields:

  • id
  • market
  • adapter_type
  • adapter_name
  • enabled
  • reason
  • updated_at
  • updated_by

Notes:

  • Added by V25.
  • Used to control market adapters such as e-invoice integrations.

5.23 schema_version

Legacy/internal schema marker table mapped by Exposed.

Key fields:

  • version
  • applied_at
  • description

Notes:

  • Flyway remains the migration authority. This table is not a replacement for Flyway history.

6. Cross-Cutting Conventions

UUID identifiers

Most business tables use UUID primary keys. Public API paths expose UUID strings for resource identifiers.

Soft deletion

Several tenant/business tables include deleted_at. Application queries should exclude soft-deleted rows unless a route is explicitly designed for archive/audit use.

Optimistic version field

Many tables include a version field. Preserve it when adding update paths and migrations.

Money

Money columns are stored as decimal/numeric values with explicit currency fields. base_amount fields support organization base-currency reporting.

Country and market support

Market-specific support currently includes HR/RS/BA concepts across country constraints, tax rates, chart-of-accounts migrations, SEF fields, and adapter configuration.


7. Operational Procedures

Add a table or column

  1. Create a new Flyway migration with the next version.
  2. Add or update the corresponding Exposed mapping in Tables.kt.
  3. Update services/routes/tests that use the new field.
  4. Update OpenAPI and backend docs if API shape changes.
  5. Run Flyway validation/migration in the intended environment.
  6. Capture evidence for MC/PR review.

Change an existing applied migration

Do not edit it. Instead:

  1. Create a new forward migration.
  2. Explain the compatibility path in the PR/MC evidence.
  3. Validate on a non-production target before promotion.

Repair Flyway metadata drift

Only perform repair after all of these are captured:

  1. Target identity: project, instance, database, environment.
  2. Flyway validate output showing exact drift.
  3. Schema checks proving the live schema matches expected intent.
  4. Written runbook and abort conditions.
  5. Repair transcript.
  6. Post-repair validate/migrate/info output.
  7. Deployment or smoke evidence if the drift blocked CI/CD.

MC #101509 is the reference example for this flow.


8. Validation Checklist

Before marking database documentation current:

  • Tables.kt table inventory reviewed.
  • Flyway migration directory reviewed.
  • No stale deployment assumptions remain in this document.
  • No legacy ORM workflow is presented as active.
  • OpenAPI/API docs updated when endpoint shapes changed.
  • Environment-specific migration claims cite evidence.

9. Index and Performance Strategy

The exact index inventory is migration-defined and should be inspected with psql against the target database when diagnosing query plans. The application design depends on these index principles:

filtering
Query familyRequired access pattern
Tenant listscomposite lookup by organization_id plus status/date/name as applicable
Invoice by IDlists <organization 5ms+ customer/status/date ordering
Expense lists PKorganization + vendor/status/date ordering
Ledger reportsorganization + transaction date range; debit/credit account joins
Bank reconciliationbank account + reconciliation status + transaction date
Authuser email lookup and refresh-token jti/user lookup
Dashboard metricsAudit <table/action/time 300ms 7and paralleluser/time aggregations
P&L report (1 year)< 500msTransaction table scan with date index
Audit trail query< 100msLoggedAction composite index
VAT report (monthly)< 200msInvoice + Expense aggregationfiltering

Performance

Audittargets Logfor Partitioningproduct-facing Strategy

paths:

Why
    Partition
  • Tenant-scoped LoggedAction?

list

Theendpoints LoggedActionshould avoid full-table isscans append-onlyacross organizations.

  • Month/quarter report queries should be bounded by organization and retainsdate datarange.
  • for
  • Background 7reconciliation/export yearsjobs (financialmay complianceuse requirement).broader Atscans, 10but requests/minuteshould perbe organization,batchable and observable.
  • Any new high-cardinality field used in filters should include an index decision in the migration PR.
  • When adding an index:

    1. Add it in a 100-orgnew instanceFlyway generatesmigration.
    2. ~500K
    3. Explain auditthe rows/month.route/report Afterit 3supports.
    4. years:
    5. Verify ~18M rows.

      Without partitioning: sequential scans onwith LoggedActionEXPLAIN becomeor slow.a Withrepresentative partitioning:query 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, beforewhen data volume growsmakes CREATEthe TABLErisk "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 (no partitioning directive)
      2. Apply partitioning via prisma db execute with raw SQL after initial migration
      3. Maintain partition creation as a yearly operations task (or use pg_partman extension)material.

      Archive
      Strategy

      10. (7-YearAudit Retention)

      Log
      Scaling and Retention
      

      Yearlogged_actions 1-7:can Activegrow partitionsfaster than ordinary tenant tables. Current documentation stance:

      • The active schema keeps audit rows in PostgreSQL (Railwayand EUrecords Frankfurt)actor, Yeartarget 7+:table, Archiveaction, partitionrow data, changed fields, query text, client IP, and application name.
      • V28 widened the action field to coldsupport current action labels.
      • No partitioning migration is currently documented as applied in Tables.kt/Flyway source of truth.

      Future scaling decision:

      • If audit volume threatens report/API latency or storage (Cloudflarebudgets, R2introduce Glacieran tier)explicit DeleteFlyway migration for partitioning or archival.
      • The migration must include retention policy, query impact, backfill plan, and restore/audit requirements.
      • Until that migration exists, do not describe partitioning as active behaviour.

      11. Known Follow-Ups

      • Keep docs/backend/openapi.yaml aligned with implemented Ktor routes.
      • Keep docs/backend/API-REFERENCE.md aligned with OpenAPI.
      • Keep deployment docs aligned with GCP Cloud Run and Cloud SQL reality.
      • Consider generating a schema snapshot from PostgreSQLa Retainmigrated R2Cloud archivesSQL-compatible database for GDPRfuture compliancereviews.
      • 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 composite index (organizationId, tableName, createdAt) sufficient for < 1M rows. Implement partitioning in Phase 2 before reaching 5M rows.


      End of Database Schema