5.6 KiB
Financial domain developer guide
Use finance.services for application writes. The ORM remains useful for reads;
Model.save, QuerySet.update, bulk creation, and direct SQL are not supported
application mutation APIs. Database checks enforce row-local rules, while the
services enforce cross-record currency, account, archive, and relationship rules.
There are no finance HTTP endpoints or editable admin models yet.
Recording activity
from datetime import date
from decimal import Decimal
from finance.services import Actor, create_account, record_transaction
actor = Actor("HUMAN", "local-owner")
account = create_account(name="Example bank", kind="BANK", actor=actor)
row = record_transaction(
account_id=account.pk, amount=Decimal("-12.50"), kind="EXPENSE",
posting_date=date(2026, 9, 6), description="Synthetic example", actor=actor,
)
Examples are synthetic and are not automatically loaded. Service operations
must be called outside an existing transaction.atomic block. Each owns its
outermost transaction, commits its success audit with the mutation, and writes
a sanitized failure audit after rollback. Composite operations use private
helpers within one transaction. Invalid actor identities or caller-owned
transactions are rejected before mutation and cannot produce a trusted,
independently committed audit. Database failures are allowed to propagate.
Actor identifiers are caller-supplied accountability labels, not authentication.
A future authenticated interface must derive them from trusted identity rather
than accept arbitrary client input. Never use raw source payloads as identifiers.
DomainError.code is a safe machine-readable code; consumers must not expose
underlying database exceptions. Failure events have no target for creations that
rolled back. Audits contain field names, not transaction values or source text.
Public operations
create_account,create_payee,create_category: create reference data.archive(entity, actor=...): archive an Account, Payee, or Category.create_statement: group observed card activity with optional quoted balance.create_plan: describe installment count without generating future charges.record_transaction: post one observed movement.create_transfer: atomically create outgoing/incoming legs and their link.match_transfer: link two existing unmatched legs without posting more money.attach_source: attach unique document/item provenance to a transaction.annotate_transaction: update only description, payee, and category.
Each create/match/attach operation returns its saved model instance. All related
inputs are UUIDs named account_id, category_id, etc. All public writes require
actor=Actor(kind, identifier) with HUMAN, APPLICATION, or AGENT kind.
Financial conventions
Use Decimal, decimal strings, or integers for money; floats, booleans, non-finite values, zero movement, more than two fractional digits, and out-of-range amounts are rejected before database rounding. Amounts have 18 total digits and two fractional digits. Statement metadata may be zero.
Positive amounts improve the owner's position; negative amounts reduce it. Purchases on a credit card are negative. Paying a card is a negative bank leg and a positive card leg, both TRANSFER. Refunds are positive separate records. Opening entries are unique per account and excluded from income/spending.
Balances are Sum("amount") per account. Never aggregate balances across currency
codes. Net spending is the negated sum of EXPENSE and REFUND amounts within one
currency and the desired date range. Transfers and opening entries are excluded.
No FX conversions or combined-currency summaries are implemented.
Unmatched TRANSFER entries are expected when only one account has been imported. Matching validates currency, distinct accounts, opposite equal amounts, and unique use of each leg. Account and transaction locks are acquired in UUID order; reference locks precede transaction locks to avoid opposing lock order.
Linked refunds target an expense in the same currency, possibly on another account; partial and over-refunds are allowed. Installment membership requires the same account, a one-based number within the plan total, and a unique (plan, number). Posting installment 3/10 does not create any other installment. Statement balances are quoted metadata and need not equal partially imported rows.
Accounts' currencies, posted financial fields, and plan definitions are immutable through the supported API. Only transaction annotations can change. Existing archived assignments remain on history; they cannot be newly assigned. Referenced records are protected from deletion. Financial correction/reversal workflows are not exposed in this milestone.
Source identity is (document SHA-256, item locator). Changing a parser version does not allow the same physical item to be attached twice. Multiple distinct source items can explicitly describe the same transaction. Semantic duplicate detection and file parsing belong to the next milestone.
Verification
Follow local setup, then run:
docker compose build
docker compose run --rm manage migrate
docker compose run --rm test
docker compose run --rm permissions
docker compose run --rm manage makemigrations --check --dry-run
docker compose run --rm manage migrate --check
The full PostgreSQL suite includes direct constraint violations, protected history, precision rejection, audit rollback, relationships, and concurrent transfer matching with separate database sessions. The permissions command checks grants on all finance tables and rolls back synthetic runtime account DML.