You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

11 KiB

Financial Domain Design

Date: 2026-09-06 Status: Approved Milestone: Roadmap step 3

Goal

Provide Django models, PostgreSQL migrations, and tested domain operations for accounts, credit cards, transactions, payees, categories, transfers, refunds, installments, and provenance. Import parsers, import preview, product pages, reporting screens, and real-data migration remain later milestones.

Approach

Use one transaction table for bank accounts and credit cards. Separate tables would duplicate categorization and provenance rules. A full double-entry ledger would add balancing accounts and posting machinery beyond the current MVP. Explicit relationships around a common transaction model provide the required semantics without that extra accounting subsystem.

Add a finance Django application. Keep models and database constraints in finance/models/, public write operations in finance/services/, and tests in tests/finance/. Existing health behavior remains unchanged.

Money and dates

Store signed Decimal amounts with two fractional digits and 18 total digits. Reject floats, non-finite numbers, excessive precision, and zero-value activity. Positive amounts increase the owner's position; negative amounts decrease it. A bank deposit is positive, a purchase is negative, and a card debt balance is negative. A card payment is a negative bank leg and a positive card leg.

An account has an immutable three-letter currency code, default BRL. Codes must be uppercase ASCII letters. No conversion or exchange-rate handling is included. Paired transfers must use the same currency. Balances in different currencies must never be added together. Monetary fields use the account currency.

Use a required posting date and optional occurrence date. Do not collapse these into timestamps: an invoice can post a purchase later than its purchase date. Creation and audit timestamps are timezone-aware UTC.

Entities

Account

UUID identifier, nonblank display name, kind (BANK or CREDIT_CARD), currency, archived flag, creation and modification timestamps. Display names are unique within the local dataset. Archive accounts instead of deleting their history. Do not store a mutable cached balance: derive it from opening entries and posted transactions. New transactions cannot target archived accounts.

A credit card is an account with kind CREDIT_CARD; no card number, credential, credit limit, or inferred billing schedule is required for this milestone.

Payee and Category

UUID identifiers, nonblank unique names, archived flag, timestamps. Categories have kind INCOME or EXPENSE and are flat for the MVP. Transactions may remain uncategorized or lack a payee. Archived values remain readable in history and cannot be assigned to new transactions. Category kind must match the activity; refunds can use an expense category to reverse spending.

CardStatement

UUID, credit-card account, period start/end, due date, optional signed statement balance, and timestamps. Period end is not earlier than period start; due date is not earlier than period end. One statement per card and period end.

A transaction can reference a statement only for its own credit-card account. Statements group posted rows; they do not create another expense. A quoted statement balance is source metadata, not the sum of imported rows: a partial import must not manufacture a reconciliation result. Bank transactions cannot reference statements. Statement creation does not require a parser.

Transaction

UUID, account, signed amount, posting date, optional occurrence date, description, kind (INCOME, EXPENSE, TRANSFER, REFUND, OPENING), optional payee/category, optional statement, optional original transaction for a refund, optional installment plan and number, and timestamps.

INCOME and REFUND are positive; EXPENSE is negative; TRANSFER may have either sign. OPENING may have either sign and is excluded from income/spending. Permit at most one opening entry per account. An opening entry has no category, payee, statement, refund link, or installment relationship.

Transfers have no income/expense category, refund link, or installment link. Refund references are allowed only on REFUND rows. A linked refund must target an EXPENSE in the same currency, cannot reference itself, and can use a different account. Refunds may be unlinked when the original purchase is absent. Multiple partial refunds are supported; no automatic cumulative cap is imposed because real adjustments can exceed an original charge. Refunds are recorded separately and never overwrite the purchase. No automatic payee/category inheritance.

Transfer

UUID and two distinct, uniquely linked Transaction rows: outgoing and incoming. The outgoing amount is negative; incoming is positive and equal in magnitude. Accounts must differ and currencies must match. Both rows have TRANSFER kind. Dates can differ to represent settlement delays. Each transaction belongs to at most one transfer. A transfer may connect a bank account and a credit card.

An unmatched TRANSFER transaction is allowed because imports can arrive one account at a time. Creating a complete transfer writes both legs atomically; matching two existing legs links them without creating more money movement. Unmatched legs remain identifiable by the absence of a Transfer relationship.

InstallmentPlan

UUID, account, description, positive total installment count, timestamps. Transactions reference the plan and their one-based installment number. Require EXPENSE kind, the same account, a number between 1 and total count, and unique (plan, installment number). Plan membership and number are either both present or both absent. Refunds refer to the individual installment expense.

Only observed installments are posted. A 3/10 source row does not create the other nine entries, and a plan's total purchase value is not inferred from a single installment. Amounts may differ due to rounding. Freeze a plan's account and total count once it has posted members.

SourceRecord and AuditEvent

SourceRecord identifies a parsed source item independently from transaction classification: UUID, document SHA-256, source format (BANK_CSV or OUROCARD_TXT), parser version, nonblank item locator, and creation timestamp. A unique (document hash, item locator) prevents attaching the same physical item twice. Each source item links to at most one transaction. A transaction may have multiple source records when later duplicate matching identifies another source for the same activity. This is provenance identity, not the future semantic duplicate-detection algorithm. Raw source text and file contents are not stored in this model. Hashes and provenance still remain local financial data.

AuditEvent stores actor kind (HUMAN, APPLICATION, AGENT), nonblank actor identifier, operation, target UUID, timestamp, outcome, and changed field names. Do not copy raw source rows, descriptions, credentials, or monetary payloads into generic audit messages. Successful writes and their audit event commit in one transaction. Domain validation failures raise a structured error code; write an independent failure audit after rollback, without sensitive input. Database outages may prevent audit persistence and must not be masked.

Mutation boundaries and validation

Public service operations create accounts/reference data, archive reference data, create statements/plans, record transactions, create/match transfers, attach provenance, and update a transaction's description/payee/category. Financial amounts, accounts, dates, kinds, refund links, and installment identity are immutable after recording in this milestone. Financial correction/reversal workflows receive a later design; no unrestricted generic update API is exposed.

Every service takes an explicit actor. Use transaction.atomic and row locking for cross-row invariants, including transfer matching and installment membership. Lock accounts/reference rows consistently before validating archived state. Protect referenced history against deletion. Do not register editable admin models or expose endpoints that bypass the service layer in this milestone.

PostgreSQL constraints enforce nonzero amounts, sign/kind combinations, valid choice values, opening uniqueness, installment-pair nullability and uniqueness, source identity, and distinct/unique transfer legs. Service validation enforces cross-table account, currency, archive, category, statement, and refund rules. Direct SQL or ORM bulk writes can bypass cross-table rules and are unsupported application mutation paths; this limit must be explicit in developer guidance.

Use a common lock order by UUID for transfer legs so competing match requests serialize. Enforce transfer leg uniqueness across both incoming and outgoing roles by the immutable signed amount: a negative leg can never be incoming, and a positive leg can never be outgoing. Test concurrent attempts against real PostgreSQL with separate connections, not mocked locks.

Acceptance examples (synthetic)

  • Bank starts at +1,000 BRL; a -120 expense leaves +880.
  • A -300 card purchase is spending. Paying it creates -300 bank and +300 card transfer legs; payment contributes zero additional spending.
  • A +50 refund references a -120 expense and reduces net spending to 70 while retaining both records and both source identities.
  • Only installment 3/10 is imported: exactly one expense exists; another 3/10 on the same plan is rejected, while a different plan's 3/10 is valid.
  • A USD-to-BRL transfer is rejected without leaving either new leg or a success audit. The validation failure is recorded without source payloads.
  • Matching already-paired legs, including simultaneous attempts, cannot create a second transfer or duplicate transactions.
  • Source metadata from a repeated physical item is rejected, while a new source item may be attached to the same transaction explicitly.
  • Invalid foreign-account statements/refunds/installments and assigning archived reference data are rejected without partial writes.

Delivery and verification

Build models and migrations first, then ordinary posting/provenance operations, then linked transfers/refunds/installments and audit failure handling. Each unit gets PostgreSQL-backed tests. Add the finance app to Docker build copies and the allowlisted build context. Verify migration application on both fresh and existing skeleton databases, migration drift, runtime grants on new tables, and all existing health/configuration tests.

No real data is needed. Use synthetic records only. Keep existing unrelated working-tree changes and the running skeleton intact. Implementation starts after owner approval of this domain design.