Skip to main content

Database Design

The Acctz database uses four domain schemas inside a single PostgreSQL 18 database. This page provides the complete table catalog, entity-relationship diagram, trigger logic, index strategy, and Flyway migration map.


Entity-Relationship Diagram

↑ Back to top


Schema: iam -- Identity & Access Management

↑ Back to top

Created by V1__create_iam_schema.sql.

iam.users

The bridge between Firebase Auth and the relational world. Every Firebase user who signs in gets a corresponding row here.

ColumnTypeNotes
idUUID (PK)UUIDv7, app-generated
firebase_uidVARCHAR(128) UNIQUEMaps to Firebase Auth UID
emailVARCHAR(255) UNIQUE NOT NULLFrom Firebase JWT
display_nameVARCHAR(255)From Firebase profile
avatar_urlTEXT
statusVARCHAR(20)active, suspended, deleted
last_login_atTIMESTAMPTZUpdated on each authentication
created_atTIMESTAMPTZ
updated_atTIMESTAMPTZ

iam.organizations

The top-level tenant unit -- equivalent to a "company" in QuickBooks or a Xero "organisation."

ColumnTypeNotes
idUUID (PK)UUIDv7
nameVARCHAR(255) NOT NULL
slugVARCHAR(100) UNIQUE NOT NULLURL-safe identifier
planVARCHAR(50)free, starter, pro, enterprise
statusVARCHAR(20)active, suspended, cancelled
billing_emailVARCHAR(255)
max_entitiesINTDefault 1
max_usersINTDefault 5
created_byUUID (FK → users)
created_at, updated_atTIMESTAMPTZ

iam.roles

System-defined and custom roles. Five system roles ship in seed data (V5): Owner, Admin, Accountant, Bookkeeper, Viewer.

ColumnTypeNotes
idUUID (PK)
codeVARCHAR(50) UNIQUE NOT NULLowner, admin, accountant, bookkeeper, viewer
nameVARCHAR(100) NOT NULL
descriptionTEXT
is_systemBOOLEANSystem roles cannot be deleted

iam.role_permissions

Permission matrix stored as (role, resource, action) tuples.

ColumnTypeNotes
idUUID (PK)
role_idUUID (FK → roles)CASCADE on delete
resourceVARCHAR(50)organization, entity, accounts, journal_entries, fiscal_periods, parties, banking, reports
actionVARCHAR(20)create, read, update, delete, approve, close, export
UNIQUE on (role_id, resource, action)

iam.org_memberships

Links users to organizations with a specific role.

ColumnTypeNotes
idUUID (PK)
org_idUUID (FK → organizations)
user_idUUID (FK → users)
role_idUUID (FK → roles)
statusVARCHAR(20)active, invited, suspended
invited_emailVARCHAR(255)For pending invitations
invited_at, joined_atTIMESTAMPTZ
created_atTIMESTAMPTZ
UNIQUE on (org_id, user_id)

iam.api_tokens

Hashed API tokens for machine-to-machine integrations (import tools, partner apps).

ColumnTypeNotes
idUUID (PK)
org_idUUID (FK → organizations)
user_idUUID (FK → users)Token creator
nameVARCHAR(255)Human label
token_hashVARCHAR(255)bcrypt/argon2 hash, never plaintext
scopesTEXT[]PostgreSQL array of allowed scopes
expires_atTIMESTAMPTZOptional expiration
last_used_atTIMESTAMPTZ
is_activeBOOLEAN
created_atTIMESTAMPTZ

Schema: ledger -- Core Accounting

↑ Back to top

Created by V2__create_ledger_schema.sql. This is the heart of the system.

ledger.account_types

Reference table for the five fundamental account classifications.

CodeNameNormal Balance
ASSETAssetdebit
LIABILITYLiabilitycredit
EQUITYEquitycredit
REVENUERevenuecredit
EXPENSEExpensedebit

ledger.account_templates

Industry-specific chart of accounts templates used to seed new entities. The default general template ships with 50+ accounts (1000-series Assets through 7000-series Other Expenses).

ledger.entities

A "set of books" -- the equivalent of a QuickBooks company file. Each entity belongs to one organization and contains its own chart of accounts, journal entries, parties, and fiscal periods.

ColumnTypeNotes
idUUID (PK)UUIDv7
org_idUUIDTenant isolation key (FK to iam.organizations conceptually, no cross-schema FK)
nameVARCHAR(255) NOT NULL
legal_nameVARCHAR(255)
einVARCHAR(20)Tax ID
address_*VARCHARLine1, Line2, City, State, Postal, Country
base_currencyVARCHAR(3)Default USD
fiscal_year_start_monthINT1-12, default January
industryVARCHAR(100)Links to account_templates for initial seeding
statusVARCHAR(20)active, inactive, archived
created_byUUID
created_at, updated_atTIMESTAMPTZ

ledger.accounts

The chart of accounts for each entity. Supports hierarchical parent/child relationships via parent_code.

ColumnTypeNotes
idUUID (PK)UUIDv7
entity_idUUID (FK → entities)
codeVARCHAR(20)Human-readable (e.g. 1110, 4100)
nameVARCHAR(255)
account_typeVARCHAR(20) (FK → account_types)
parent_codeVARCHAR(20)Hierarchical structure
normal_balanceVARCHAR(6)debit or credit
is_active, is_system, is_bank_accountBOOLEAN
tax_codeVARCHAR(20)
is_1099BOOLEAN1099 reporting flag
currency_codeVARCHAR(3)Default USD
sort_orderINTDisplay ordering
UNIQUE on (entity_id, code)

ledger.journal_entries

Journal entry headers. Draft entries can be edited; posted entries are immutable (enforced by trigger).

ColumnTypeNotes
idUUID (PK)UUIDv7
entity_idUUID (FK → entities)
entry_dateDATETransaction date
effective_dateDATEOptional, for accruals
reference_typeVARCHAR(50)invoice, payment, adjustment, etc.
reference_numberVARCHAR(100)Human-readable (for IIF/QBO export)
memoTEXT
statusVARCHAR(20)draftpostedvoided
sourceVARCHAR(50)manual, import, recurring, system
source_refVARCHAR(255)External reference ID
fiscal_period_idUUID (FK → fiscal_periods)
party_idUUID (FK → parties)
posted_at, posted_byPosting metadata
voided_at, voided_by, void_reasonVoid metadata
created_by, updated_byUUID
created_at, updated_atTIMESTAMPTZ

ledger.journal_lines

Individual debit/credit lines within a journal entry. Each line touches exactly one account.

ColumnTypeNotes
idUUID (PK)UUIDv7
journal_entry_idUUID (FK → journal_entries)CASCADE on delete
account_idUUID (FK → accounts)
descriptionTEXTLine-level memo
debitNUMERIC(19,4)CHECK >= 0
creditNUMERIC(19,4)CHECK >= 0
party_type, party_idOptional line-level party
department, location, projectVARCHARDimensional tagging
foreign_amount, foreign_currency, exchange_rateMulti-currency support
line_orderINTDisplay ordering
chk_debit_xor_credit: exactly one of debit/credit must be non-zero

ledger.fiscal_periods

Accounting periods with open/closed/locked lifecycle.

ColumnTypeNotes
idUUID (PK)
entity_idUUID (FK → entities)
nameVARCHAR(50)e.g. "Jan 2026"
period_typeVARCHAR(20)month, quarter, year
start_date, end_dateDATECHECK end_date > start_date
statusVARCHAR(20)openclosedlocked
closed_at, closed_byClosing metadata
UNIQUE on (entity_id, start_date, end_date)

ledger.parties

Vendors, customers, and employees.

ColumnTypeNotes
idUUID (PK)
entity_idUUID (FK → entities)
typeVARCHAR(20)vendor, customer, employee
nameVARCHAR(255) NOT NULL
tax_idVARCHAR(50)For 1099 reporting
is_1099BOOLEAN
default_account_idUUID (FK → accounts)Auto-categorization
payment_termsVARCHAR(50)net_30, etc.
Address, contact fieldsStandard address block

ledger.attachments

Documents (receipts, invoices) linked to journal entries.

ColumnTypeNotes
idUUID (PK)
entity_idUUID (FK → entities)
journal_entry_idUUID (FK → journal_entries)SET NULL on delete
file_name, file_type, file_sizeFile metadata
storage_pathTEXT NOT NULLS3 / local path
uploaded_at, uploaded_by

Schema: banking -- Bank Feeds & Reconciliation

↑ Back to top

Created by V3__create_banking_schema.sql.

banking.bank_accounts

Linked bank accounts. Stores masked account numbers only.

ColumnTypeNotes
idUUID (PK)
entity_idUUIDTenant isolation key
account_idUUIDLinks to ledger.accounts (the GL account)
institution_nameVARCHAR(255)
account_number_maskedVARCHAR(20)e.g. ****1234
account_typeVARCHAR(50)checking, savings, credit_card, loan, money_market
provider, provider_account_idBank feed provider fields (OFX/API integration)
statusVARCHAR(20)active, inactive, error
last_synced_atTIMESTAMPTZ

banking.bank_transactions

Imported bank activity. Deduplication uses the OFX fitid field.

ColumnTypeNotes
idUUID (PK)
bank_account_idUUID (FK → bank_accounts)
entity_idUUID
transaction_date, posted_dateDATE
amountNUMERIC(19,4)Positive for deposits, negative for withdrawals
description, payee_name, category
check_numberVARCHAR(50)For check matching
fitidVARCHAR(255)Financial Institution Transaction ID (OFX standard)
statusVARCHAR(20)pendingcategorizedmatchedexcluded
matched_journal_idUUIDLinks to the journal entry this was matched to
UNIQUE on (bank_account_id, fitid)

banking.import_jobs

Tracks file import operations with format-aware validation.

ColumnTypeNotes
idUUID (PK)
entity_idUUID
bank_account_idUUID (FK → bank_accounts)
file_nameVARCHAR(255)
file_formatVARCHAR(20)ofx, qbo, qfx, csv, iif
statusVARCHAR(20)pendingprocessingcompleted / failed
total_records, imported_records, skipped_recordsINTProgress tracking
error_messageTEXT
created_byUUID

banking.reconciliations and banking.reconciliation_items

Period-based reconciliation workflow. Each reconciliation captures a statement balance and tracks which journal entries have been cleared against it.


Schema: audit -- Compliance Log

↑ Back to top

Created by V4__create_audit_schema.sql.

audit.audit_log

Append-only. The only table in the system that uses BIGSERIAL instead of UUIDv7 (for maximum write throughput; see Design Decisions).

ColumnTypeNotes
idBIGSERIAL (PK)Sequential, not UUIDv7
org_idUUID
entity_idUUID
user_idUUID NOT NULLWho performed the action
actionVARCHAR(50)create, update, delete, login, export, etc.
resource_typeVARCHAR(50)journal_entry, account, party, etc.
resource_idUUIDWhich record was affected
changesJSONBBefore/after snapshot
ip_addressINET
user_agentTEXT
created_atTIMESTAMPTZ

Immutability enforcement:

  • Trigger on UPDATE → raises exception
  • Trigger on DELETE → raises exception
  • REVOKE UPDATE, DELETE ON audit.audit_log FROM PUBLIC

Only the postgres superuser can bypass these protections for disaster recovery.


Triggers

↑ Back to top

TriggerTableWhenPurpose
trg_prevent_posted_entry_updateledger.journal_entriesBEFORE UPDATE (when OLD.status = 'posted')Blocks modification of posted entries. Corrections require voiding and re-entry.
trg_validate_balanced_entryledger.journal_entriesBEFORE UPDATE (draft → posted)Verifies SUM(debit) = SUM(credit) across all lines. Rejects empty or unbalanced entries.
trg_prevent_audit_updateaudit.audit_logBEFORE UPDATERaises exception -- audit records are immutable
trg_prevent_audit_deleteaudit.audit_logBEFORE DELETERaises exception -- audit records are immutable

Indexes

↑ Back to top

V7__create_indexes.sql creates 35 indexes across all schemas. Key categories:

CategoryIndexesRationale
User lookupusers(firebase_uid), users(email)JWT verification on every request
RLS performanceorg_memberships(user_id), org_memberships(org_id)The accessible_entity_ids() helper runs on every RLS-guarded query
Account queriesaccounts(entity_id, code), accounts(entity_id, account_type), accounts(entity_id, is_active)Chart of accounts views with filtering
Journal listingjournal_entries(entity_id, entry_date), journal_entries(entity_id, status)Register views sorted by date, filtered by status
Line drill-downjournal_lines(journal_entry_id), journal_lines(account_id)Account register (all entries for an account)
Bank deduplicationbank_transactions(bank_account_id, fitid)OFX FITID uniqueness check on import
Audit queriesaudit_log(entity_id, created_at), audit_log(user_id, created_at)"What happened to this entity?" and "What did this user do?"

Partial indexes (with WHERE ... IS NOT NULL) are used for sparse columns like source_ref, matched_journal_id, and journal_entry_id on attachments.


Flyway Migration Map

↑ Back to top

Migrations live in db/migrations/ and run via acctz create-db (or cd db && ./gradlew flywayMigrate directly).

FileSchema(s)What It Creates
V1__create_iam_schema.sqliam6 tables: users, organizations, roles, role_permissions, org_memberships, api_tokens
V2__create_ledger_schema.sqlledger9 tables + 2 triggers: account_types, account_templates, entities, accounts, fiscal_periods, parties, journal_entries, journal_lines, attachments
V3__create_banking_schema.sqlbanking5 tables: bank_accounts, bank_transactions, import_jobs, reconciliations, reconciliation_items
V4__create_audit_schema.sqlaudit1 table + 2 triggers + REVOKE: audit_log
V5__seed_reference_data.sqliam, ledgerAccount types, 5 system roles with permissions, default chart of accounts template (50+ accounts)
V6__enable_rls_policies.sqlallRLS on 16 tables, 2 helper functions (accessible_entity_ids, accessible_org_ids)
V7__create_indexes.sqlall35 performance indexes

Bootstrap Sequence

Before Flyway runs, scripts/bootstrap.sql is mounted to Docker's docker-entrypoint-initdb.d/ and creates the four schemas, the uuid-ossp extension, and grants:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE SCHEMA IF NOT EXISTS iam AUTHORIZATION postgres;
CREATE SCHEMA IF NOT EXISTS ledger AUTHORIZATION postgres;
CREATE SCHEMA IF NOT EXISTS banking AUTHORIZATION postgres;
CREATE SCHEMA IF NOT EXISTS audit AUTHORIZATION postgres;

GRANT ALL ON SCHEMA iam TO postgres;
GRANT ALL ON SCHEMA ledger TO postgres;
GRANT ALL ON SCHEMA banking TO postgres;
GRANT ALL ON SCHEMA audit TO postgres;

This separation exists because Flyway needs the schemas to already exist before it can create tables within them.


Further Reading

↑ Back to top