Browse Source

feat: add financial domain

master
Codex 2 months ago
parent
commit
d01e8918c0
  1. 110
      docs/financial-domain.md
  2. 41
      docs/superpowers/plans/2026-09-06-financial-domain.md
  3. 199
      docs/superpowers/specs/2026-09-06-financial-domain-design.md
  4. 1
      finance/__init__.py
  5. 230
      finance/migrations/0001_initial.py
  6. 0
      finance/migrations/__init__.py
  7. 8
      finance/models/__init__.py
  8. 18
      finance/models/base.py
  9. 75
      finance/models/ledger.py
  10. 54
      finance/models/provenance.py
  11. 48
      finance/models/reference.py
  12. 7
      finance/services/__init__.py
  13. 89
      finance/services/base.py
  14. 75
      finance/services/posting.py
  15. 53
      finance/services/reference.py
  16. 45
      finance/services/transfers.py
  17. 56
      tests/finance/test_concurrency.py
  18. 139
      tests/finance/test_constraints.py
  19. 316
      tests/finance/test_services.py

110
docs/financial-domain.md

@ -0,0 +1,110 @@
# 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
```python
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](development.md), then run:
```powershell
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.

41
docs/superpowers/plans/2026-09-06-financial-domain.md

@ -0,0 +1,41 @@
# Financial Domain Implementation Plan
**Spec:** [Approved domain design](../specs/2026-09-06-financial-domain-design.md)
**Goal:** Deliver the financial models, constraints, service boundary and PostgreSQL verification.
**Architecture:** finance/models for persistence; finance/services for audited atomic writes; tests/finance for synthetic acceptance and concurrency checks.
## Interfaces
Models: Account(name,kind,currency,archived), Payee(name,archived), Category(name,kind,archived), CardStatement(account,period_start,period_end,due_date,statement_balance), InstallmentPlan(account,description,total_installments), Transaction(account,amount,posting_date,occurrence_date,description,kind,payee,category,statement,refund_of,installment_plan,installment_number), Transfer(outgoing,incoming), SourceRecord(transaction,document_hash,source_format,parser_version,item_locator), AuditEvent(actor_kind,actor_identifier,operation,target_id,outcome,changed_fields,error_code). All identifiers UUID; timestamps created_at/updated_at except immutable provenance/audit created_at.
Public services take actor=Actor(kind,identifier). Create operations take model scalar fields, related UUID arguments named account_id etc. create_transfer takes outgoing_account_id, incoming_account_id, positive amount, outgoing_date, incoming_date. match_transfer takes outgoing_id/incoming_id. annotate_transaction takes transaction_id and description/payee_id/category_id only. archive takes entity. attach_source takes transaction_id and source metadata. DomainError.code is stable and sanitized.
- [x] Task 1: Implement models and PostgreSQL constraints; create migrations. Owned files finance/models and finance/migrations. Check constraint rejection using direct ORM writes, including sign, uniqueness, and protected deletion.
- [x] Task 2: Implement services for ordinary posting, reference data, statements, plans and audit. Owned files finance/services. Validate Decimal precision before persistence, serialize references and record success atomically; failures recorded after rollback.
- [x] Task 3: Implement transfer/refund/installment/provenance operations and synthetic acceptance tests, including concurrent matching in separate PostgreSQL connections. Tests written before service implementation and run red, then green.
- [x] Task 4: Integrate finance in settings and Docker context, verify fresh/existing migrations and runtime privileges; review code and document developer contracts. Run full pytest, check, makemigrations --check, migrate --check and HTTP health.
Ruling: public write operations own their outermost database transaction. Reject invocation inside an existing atomic block so failure audit events can persist independently after rollback. Composite operations call private helpers in one public transaction, never public services recursively.
Ruling: archived reference data cannot be newly assigned, but existing historical assignments can remain when only a description is updated.
Ruling: retain the existing working directory and branch because all previous work is uncommitted here; workers own disjoint files, no overlapping implementation.
## Completion evidence
- 83 PostgreSQL tests passed, including database constraints, relationship
validation, audit rollback, source identity and two-session concurrent matching.
- Initial service run exposed audit field suffix mismatch (79 passed/1 failed);
normalized audit field names and reran successfully.
- Review found Decimal-context rounding in transfer negation. Both new
regressions failed before the fix; copy_negate() corrected creation and
matching. Scoped re-review reported no remaining findings.
- Finance migration applied to the existing skeleton database and a fresh
gizmoduck-domain-verify database. Runtime grants and synthetic account DML
passed on both. The verification database container is stopped; its synthetic
volume is retained. No real financial data was used.
- Django checks passed with no migration drift or pending migrations.
- Candidate Git files checked for local secrets; migration/source files remain
trackable. Documentation links and whitespace verified.
- Financial APIs and transaction-boundary limits documented in
docs/financial-domain.md. No financial HTTP UI or import parser added.
- Changes remain local and uncommitted alongside the prior foundation/skeleton.

199
docs/superpowers/specs/2026-09-06-financial-domain-design.md

@ -0,0 +1,199 @@
# 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.

1
finance/__init__.py

@ -0,0 +1 @@
"""Financial domain persistence and audited write operations."""

230
finance/migrations/0001_initial.py

@ -0,0 +1,230 @@
# Generated by Django 5.2.17 on 2026-09-06 19:02
import django.core.validators
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Account',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255, unique=True)),
('kind', models.CharField(choices=[('BANK', 'Bank'), ('CREDIT_CARD', 'Credit card')], max_length=16)),
('currency', models.CharField(default='BRL', max_length=3, validators=[django.core.validators.RegexValidator('\\A[A-Z]{3}\\Z', 'Use three uppercase ASCII letters.')])),
('archived', models.BooleanField(default=False)),
],
options={
'constraints': [models.CheckConstraint(condition=models.Q(('kind__in', ['BANK', 'CREDIT_CARD'])), name='account_valid_kind'), models.CheckConstraint(condition=models.Q(('currency__regex', '^[A-Z]{3}$')), name='account_valid_currency'), models.CheckConstraint(condition=models.Q(('name__regex', '\\S')), name='account_nonblank_name')],
},
),
migrations.CreateModel(
name='AuditEvent',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('actor_kind', models.CharField(choices=[('HUMAN', 'Human'), ('APPLICATION', 'Application'), ('AGENT', 'Agent')], max_length=16)),
('actor_identifier', models.CharField(max_length=255)),
('operation', models.CharField(max_length=128)),
('target_id', models.UUIDField(blank=True, null=True)),
('outcome', models.CharField(choices=[('SUCCESS', 'Success'), ('FAILURE', 'Failure')], max_length=16)),
('changed_fields', models.JSONField(blank=True, default=list)),
('error_code', models.CharField(blank=True, max_length=128)),
],
options={
'constraints': [models.CheckConstraint(condition=models.Q(('actor_kind__in', ['HUMAN', 'APPLICATION', 'AGENT'])), name='audit_valid_actor'), models.CheckConstraint(condition=models.Q(('outcome__in', ['SUCCESS', 'FAILURE'])), name='audit_valid_outcome'), models.CheckConstraint(condition=models.Q(('actor_identifier__regex', '\\S')), name='audit_nonblank_actor'), models.CheckConstraint(condition=models.Q(('operation__regex', '\\S')), name='audit_nonblank_operation')],
},
),
migrations.CreateModel(
name='CardStatement',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('period_start', models.DateField()),
('period_end', models.DateField()),
('due_date', models.DateField()),
('statement_balance', models.DecimalField(blank=True, decimal_places=2, max_digits=18, null=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='statements', to='finance.account')),
],
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255, unique=True)),
('kind', models.CharField(choices=[('INCOME', 'Income'), ('EXPENSE', 'Expense')], max_length=16)),
('archived', models.BooleanField(default=False)),
],
options={
'constraints': [models.CheckConstraint(condition=models.Q(('kind__in', ['INCOME', 'EXPENSE'])), name='category_valid_kind'), models.CheckConstraint(condition=models.Q(('name__regex', '\\S')), name='category_nonblank_name')],
},
),
migrations.CreateModel(
name='InstallmentPlan',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('description', models.TextField(blank=True)),
('total_installments', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='installment_plans', to='finance.account')),
],
),
migrations.CreateModel(
name='Payee',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255, unique=True)),
('archived', models.BooleanField(default=False)),
],
options={
'constraints': [models.CheckConstraint(condition=models.Q(('name__regex', '\\S')), name='payee_nonblank_name')],
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('amount', models.DecimalField(decimal_places=2, max_digits=18)),
('posting_date', models.DateField()),
('occurrence_date', models.DateField(blank=True, null=True)),
('description', models.TextField(blank=True)),
('kind', models.CharField(choices=[('INCOME', 'Income'), ('EXPENSE', 'Expense'), ('TRANSFER', 'Transfer'), ('REFUND', 'Refund'), ('OPENING', 'Opening')], max_length=16)),
('installment_number', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)])),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='finance.account')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='finance.category')),
('installment_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='finance.installmentplan')),
('payee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='finance.payee')),
('refund_of', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='finance.transaction')),
('statement', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='finance.cardstatement')),
],
),
migrations.CreateModel(
name='SourceRecord',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('document_hash', models.CharField(max_length=64, validators=[django.core.validators.RegexValidator('\\A[0-9a-f]{64}\\Z', 'Use a lowercase SHA-256 hexadecimal digest.')])),
('source_format', models.CharField(choices=[('BANK_CSV', 'Bank CSV'), ('OUROCARD_TXT', 'Ourocard TXT')], max_length=16)),
('parser_version', models.CharField(max_length=128)),
('item_locator', models.CharField(max_length=255)),
('transaction', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='source_records', to='finance.transaction')),
],
),
migrations.CreateModel(
name='Transfer',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('incoming', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='incoming_transfer', to='finance.transaction')),
('outgoing', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='outgoing_transfer', to='finance.transaction')),
],
),
migrations.AddConstraint(
model_name='cardstatement',
constraint=models.CheckConstraint(condition=models.Q(('period_end__gte', models.F('period_start'))), name='statement_period_order'),
),
migrations.AddConstraint(
model_name='cardstatement',
constraint=models.CheckConstraint(condition=models.Q(('due_date__gte', models.F('period_end'))), name='statement_due_order'),
),
migrations.AddConstraint(
model_name='cardstatement',
constraint=models.UniqueConstraint(fields=('account', 'period_end'), name='statement_account_period_unique'),
),
migrations.AddConstraint(
model_name='installmentplan',
constraint=models.CheckConstraint(condition=models.Q(('total_installments__gt', 0)), name='plan_positive_total'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(('kind__in', ['INCOME', 'EXPENSE', 'TRANSFER', 'REFUND', 'OPENING'])), name='transaction_valid_kind'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(('amount', 0), _negated=True), name='transaction_nonzero_amount'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('amount__gt', 0), ('kind__in', ['INCOME', 'REFUND'])), models.Q(('amount__lt', 0), ('kind', 'EXPENSE')), ('kind__in', ['TRANSFER', 'OPENING']), _connector='OR'), name='transaction_kind_sign'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.UniqueConstraint(condition=models.Q(('kind', 'OPENING')), fields=('account',), name='transaction_one_opening'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('kind', 'OPENING'), _negated=True), models.Q(('category__isnull', True), ('installment_number__isnull', True), ('installment_plan__isnull', True), ('payee__isnull', True), ('refund_of__isnull', True), ('statement__isnull', True)), _connector='OR'), name='transaction_opening_unassociated'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('kind', 'TRANSFER'), _negated=True), models.Q(('category__isnull', True), ('installment_number__isnull', True), ('installment_plan__isnull', True), ('refund_of__isnull', True)), _connector='OR'), name='transaction_transfer_unclassified'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(('refund_of__isnull', True), ('kind', 'REFUND'), _connector='OR'), name='transaction_refund_link_kind'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(('refund_of__isnull', True), models.Q(('refund_of', models.F('id')), _negated=True), _connector='OR'), name='transaction_refund_not_self'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('installment_number__isnull', True), ('installment_plan__isnull', True)), models.Q(('installment_number__isnull', False), ('installment_plan__isnull', False)), _connector='OR'), name='transaction_installment_pair'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(('installment_number__isnull', True), ('installment_number__gt', 0), _connector='OR'), name='transaction_installment_positive'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.CheckConstraint(condition=models.Q(('installment_plan__isnull', True), ('kind', 'EXPENSE'), _connector='OR'), name='transaction_installment_expense'),
),
migrations.AddConstraint(
model_name='transaction',
constraint=models.UniqueConstraint(condition=models.Q(('installment_plan__isnull', False)), fields=('installment_plan', 'installment_number'), name='transaction_installment_unique'),
),
migrations.AddConstraint(
model_name='sourcerecord',
constraint=models.CheckConstraint(condition=models.Q(('source_format__in', ['BANK_CSV', 'OUROCARD_TXT'])), name='source_valid_format'),
),
migrations.AddConstraint(
model_name='sourcerecord',
constraint=models.CheckConstraint(condition=models.Q(('document_hash__regex', '^[0-9a-f]{64}$')), name='source_valid_hash'),
),
migrations.AddConstraint(
model_name='sourcerecord',
constraint=models.CheckConstraint(condition=models.Q(('parser_version__regex', '\\S')), name='source_nonblank_parser'),
),
migrations.AddConstraint(
model_name='sourcerecord',
constraint=models.CheckConstraint(condition=models.Q(('item_locator__regex', '\\S')), name='source_nonblank_locator'),
),
migrations.AddConstraint(
model_name='sourcerecord',
constraint=models.UniqueConstraint(fields=('document_hash', 'item_locator'), name='source_document_item_unique'),
),
migrations.AddConstraint(
model_name='transfer',
constraint=models.CheckConstraint(condition=models.Q(('outgoing', models.F('incoming')), _negated=True), name='transfer_distinct_legs'),
),
]

0
finance/migrations/__init__.py

8
finance/models/__init__.py

@ -0,0 +1,8 @@
from .ledger import CardStatement, InstallmentPlan, Transaction, Transfer
from .provenance import AuditEvent, SourceRecord
from .reference import Account, Category, Payee
__all__ = [
"Account", "AuditEvent", "CardStatement", "Category", "InstallmentPlan",
"Payee", "SourceRecord", "Transaction", "Transfer",
]

18
finance/models/base.py

@ -0,0 +1,18 @@
import uuid
from django.db import models
class IdentifiedModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
class TimestampedModel(IdentifiedModel):
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True

75
finance/models/ledger.py

@ -0,0 +1,75 @@
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import F, Q
from .base import TimestampedModel
class CardStatement(TimestampedModel):
account = models.ForeignKey("finance.Account", on_delete=models.PROTECT, related_name="statements")
period_start = models.DateField()
period_end = models.DateField()
due_date = models.DateField()
statement_balance = models.DecimalField(max_digits=18, decimal_places=2, null=True, blank=True)
class Meta:
constraints = [
models.CheckConstraint(condition=Q(period_end__gte=F("period_start")), name="statement_period_order"),
models.CheckConstraint(condition=Q(due_date__gte=F("period_end")), name="statement_due_order"),
models.UniqueConstraint(fields=["account", "period_end"], name="statement_account_period_unique"),
]
class InstallmentPlan(TimestampedModel):
account = models.ForeignKey("finance.Account", on_delete=models.PROTECT, related_name="installment_plans")
description = models.TextField(blank=True)
total_installments = models.PositiveIntegerField(validators=[MinValueValidator(1)])
class Meta:
constraints = [models.CheckConstraint(condition=Q(total_installments__gt=0), name="plan_positive_total")]
class Transaction(TimestampedModel):
class Kind(models.TextChoices):
INCOME = "INCOME", "Income"
EXPENSE = "EXPENSE", "Expense"
TRANSFER = "TRANSFER", "Transfer"
REFUND = "REFUND", "Refund"
OPENING = "OPENING", "Opening"
account = models.ForeignKey("finance.Account", on_delete=models.PROTECT, related_name="transactions")
amount = models.DecimalField(max_digits=18, decimal_places=2)
posting_date = models.DateField()
occurrence_date = models.DateField(null=True, blank=True)
description = models.TextField(blank=True)
kind = models.CharField(max_length=16, choices=Kind.choices)
payee = models.ForeignKey("finance.Payee", on_delete=models.PROTECT, related_name="transactions", null=True, blank=True)
category = models.ForeignKey("finance.Category", on_delete=models.PROTECT, related_name="transactions", null=True, blank=True)
statement = models.ForeignKey(CardStatement, on_delete=models.PROTECT, related_name="transactions", null=True, blank=True)
refund_of = models.ForeignKey("self", on_delete=models.PROTECT, related_name="refunds", null=True, blank=True)
installment_plan = models.ForeignKey(InstallmentPlan, on_delete=models.PROTECT, related_name="transactions", null=True, blank=True)
installment_number = models.PositiveIntegerField(null=True, blank=True, validators=[MinValueValidator(1)])
class Meta:
constraints = [
models.CheckConstraint(condition=Q(kind__in=["INCOME", "EXPENSE", "TRANSFER", "REFUND", "OPENING"]), name="transaction_valid_kind"),
models.CheckConstraint(condition=~Q(amount=0), name="transaction_nonzero_amount"),
models.CheckConstraint(condition=(Q(kind__in=["INCOME", "REFUND"], amount__gt=0) | Q(kind="EXPENSE", amount__lt=0) | Q(kind__in=["TRANSFER", "OPENING"])), name="transaction_kind_sign"),
models.UniqueConstraint(fields=["account"], condition=Q(kind="OPENING"), name="transaction_one_opening"),
models.CheckConstraint(condition=(~Q(kind="OPENING") | Q(category__isnull=True, payee__isnull=True, statement__isnull=True, refund_of__isnull=True, installment_plan__isnull=True, installment_number__isnull=True)), name="transaction_opening_unassociated"),
models.CheckConstraint(condition=(~Q(kind="TRANSFER") | Q(category__isnull=True, refund_of__isnull=True, installment_plan__isnull=True, installment_number__isnull=True)), name="transaction_transfer_unclassified"),
models.CheckConstraint(condition=Q(refund_of__isnull=True) | Q(kind="REFUND"), name="transaction_refund_link_kind"),
models.CheckConstraint(condition=Q(refund_of__isnull=True) | ~Q(refund_of=F("id")), name="transaction_refund_not_self"),
models.CheckConstraint(condition=(Q(installment_plan__isnull=True, installment_number__isnull=True) | Q(installment_plan__isnull=False, installment_number__isnull=False)), name="transaction_installment_pair"),
models.CheckConstraint(condition=Q(installment_number__isnull=True) | Q(installment_number__gt=0), name="transaction_installment_positive"),
models.CheckConstraint(condition=Q(installment_plan__isnull=True) | Q(kind="EXPENSE"), name="transaction_installment_expense"),
models.UniqueConstraint(fields=["installment_plan", "installment_number"], condition=Q(installment_plan__isnull=False), name="transaction_installment_unique"),
]
class Transfer(TimestampedModel):
outgoing = models.OneToOneField(Transaction, on_delete=models.PROTECT, related_name="outgoing_transfer")
incoming = models.OneToOneField(Transaction, on_delete=models.PROTECT, related_name="incoming_transfer")
class Meta:
constraints = [models.CheckConstraint(condition=~Q(outgoing=F("incoming")), name="transfer_distinct_legs")]

54
finance/models/provenance.py

@ -0,0 +1,54 @@
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Q
from .base import IdentifiedModel
class SourceRecord(IdentifiedModel):
class Format(models.TextChoices):
BANK_CSV = "BANK_CSV", "Bank CSV"
OUROCARD_TXT = "OUROCARD_TXT", "Ourocard TXT"
transaction = models.ForeignKey("finance.Transaction", on_delete=models.PROTECT, related_name="source_records")
document_hash = models.CharField(max_length=64, validators=[
RegexValidator(r"\A[0-9a-f]{64}\Z", "Use a lowercase SHA-256 hexadecimal digest.")])
source_format = models.CharField(max_length=16, choices=Format.choices)
parser_version = models.CharField(max_length=128)
item_locator = models.CharField(max_length=255)
class Meta:
constraints = [
models.CheckConstraint(condition=Q(source_format__in=["BANK_CSV", "OUROCARD_TXT"]), name="source_valid_format"),
models.CheckConstraint(condition=Q(document_hash__regex=r"^[0-9a-f]{64}$"), name="source_valid_hash"),
models.CheckConstraint(condition=Q(parser_version__regex=r"\S"), name="source_nonblank_parser"),
models.CheckConstraint(condition=Q(item_locator__regex=r"\S"), name="source_nonblank_locator"),
models.UniqueConstraint(fields=["document_hash", "item_locator"], name="source_document_item_unique"),
]
class AuditEvent(IdentifiedModel):
class ActorKind(models.TextChoices):
HUMAN = "HUMAN", "Human"
APPLICATION = "APPLICATION", "Application"
AGENT = "AGENT", "Agent"
class Outcome(models.TextChoices):
SUCCESS = "SUCCESS", "Success"
FAILURE = "FAILURE", "Failure"
actor_kind = models.CharField(max_length=16, choices=ActorKind.choices)
actor_identifier = models.CharField(max_length=255)
operation = models.CharField(max_length=128)
target_id = models.UUIDField(null=True, blank=True)
outcome = models.CharField(max_length=16, choices=Outcome.choices)
changed_fields = models.JSONField(default=list, blank=True)
error_code = models.CharField(max_length=128, blank=True)
class Meta:
constraints = [
models.CheckConstraint(condition=Q(actor_kind__in=["HUMAN", "APPLICATION", "AGENT"]), name="audit_valid_actor"),
models.CheckConstraint(condition=Q(outcome__in=["SUCCESS", "FAILURE"]), name="audit_valid_outcome"),
models.CheckConstraint(condition=Q(actor_identifier__regex=r"\S"), name="audit_nonblank_actor"),
models.CheckConstraint(condition=Q(operation__regex=r"\S"), name="audit_nonblank_operation"),
]

48
finance/models/reference.py

@ -0,0 +1,48 @@
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Q
from .base import TimestampedModel
class Account(TimestampedModel):
class Kind(models.TextChoices):
BANK = "BANK", "Bank"
CREDIT_CARD = "CREDIT_CARD", "Credit card"
name = models.CharField(max_length=255, unique=True)
kind = models.CharField(max_length=16, choices=Kind.choices)
currency = models.CharField(max_length=3, default="BRL", validators=[
RegexValidator(r"\A[A-Z]{3}\Z", "Use three uppercase ASCII letters.")])
archived = models.BooleanField(default=False)
class Meta:
constraints = [
models.CheckConstraint(condition=Q(kind__in=["BANK", "CREDIT_CARD"]), name="account_valid_kind"),
models.CheckConstraint(condition=Q(currency__regex=r"^[A-Z]{3}$"), name="account_valid_currency"),
models.CheckConstraint(condition=Q(name__regex=r"\S"), name="account_nonblank_name"),
]
class Payee(TimestampedModel):
name = models.CharField(max_length=255, unique=True)
archived = models.BooleanField(default=False)
class Meta:
constraints = [models.CheckConstraint(condition=Q(name__regex=r"\S"), name="payee_nonblank_name")]
class Category(TimestampedModel):
class Kind(models.TextChoices):
INCOME = "INCOME", "Income"
EXPENSE = "EXPENSE", "Expense"
name = models.CharField(max_length=255, unique=True)
kind = models.CharField(max_length=16, choices=Kind.choices)
archived = models.BooleanField(default=False)
class Meta:
constraints = [
models.CheckConstraint(condition=Q(kind__in=["INCOME", "EXPENSE"]), name="category_valid_kind"),
models.CheckConstraint(condition=Q(name__regex=r"\S"), name="category_nonblank_name"),
]

7
finance/services/__init__.py

@ -0,0 +1,7 @@
"""Supported financial writes. Direct ORM mutation bypasses cross-row invariants."""
from .base import Actor, DomainError
from .reference import create_account, create_payee, create_category, archive, create_statement, create_plan
from .posting import record_transaction, annotate_transaction, attach_source
from .transfers import create_transfer, match_transfer
__all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_transfer", "match_transfer"]

89
finance/services/base.py

@ -0,0 +1,89 @@
"""Public writes own a transaction; internal helpers never open public operations."""
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from functools import wraps
from uuid import UUID
from django.core.exceptions import ValidationError
from django.db import IntegrityError, connection, transaction
from finance.models import AuditEvent
@dataclass(frozen=True)
class Actor:
kind: str
identifier: str
class DomainError(ValueError):
def __init__(self, code):
self.code = code
super().__init__(code)
def money(value, *, allow_zero=False):
if not isinstance(value, (Decimal, str, int)) or isinstance(value, bool):
raise DomainError("invalid_amount")
try:
result = Decimal(value)
except (InvalidOperation, ValueError):
raise DomainError("invalid_amount") from None
if not result.is_finite() or result.as_tuple().exponent < -2 or result.adjusted() >= 16:
raise DomainError("invalid_amount")
if not allow_zero and result == 0:
raise DomainError("zero_amount")
return result
def identity(value):
try:
return UUID(str(value))
except (ValueError, TypeError, AttributeError):
raise DomainError("invalid_identifier") from None
def lock(model, identifier, *, active=False):
try:
obj = model.objects.select_for_update().get(pk=identity(identifier))
except model.DoesNotExist:
raise DomainError("not_found") from None
if active and obj.archived:
raise DomainError("archived_reference")
return obj
def save(obj):
obj.full_clean()
obj.save()
return obj
def audit(actor, operation, outcome, target_id=None, fields=(), error_code=""):
AuditEvent.objects.create(actor_kind=actor.kind, actor_identifier=actor.identifier,
operation=operation, target_id=target_id, outcome=outcome,
changed_fields=list(fields), error_code=error_code)
def write(operation):
def decorate(function):
@wraps(function)
def wrapped(*args, actor, **kwargs):
if not isinstance(actor, Actor) or actor.kind not in {"HUMAN", "APPLICATION", "AGENT"} or not isinstance(actor.identifier, str) or not actor.identifier.strip() or len(actor.identifier) > 200:
raise DomainError("invalid_actor")
if connection.in_atomic_block:
raise DomainError("transaction_boundary")
try:
with transaction.atomic(durable=True):
result, fields = function(*args, **kwargs)
audit(actor, operation, "SUCCESS", result.pk, fields)
return result
except (DomainError, ValidationError, IntegrityError) as exc:
code = exc.code if isinstance(exc, DomainError) else ("conflict" if isinstance(exc, IntegrityError) else "validation_error")
# The failed mutation transaction has exited before this audit.
# Audit persistence errors propagate: do not conceal a database outage.
with transaction.atomic(durable=True):
audit(actor, operation, "FAILURE", error_code=code)
raise DomainError(code) from None
return wrapped
return decorate

75
finance/services/posting.py

@ -0,0 +1,75 @@
from finance.models import Account, Payee, Category, CardStatement, InstallmentPlan, Transaction, SourceRecord
from .base import DomainError, identity, lock, money, save, write
def validate_category(kind, category):
if category is None:
return
expected = {"INCOME": "INCOME", "EXPENSE": "EXPENSE", "REFUND": "EXPENSE"}.get(kind)
if expected != category.kind:
raise DomainError("category_kind_mismatch")
def record(*, account_id, amount, posting_date, kind, description="", occurrence_date=None,
payee_id=None, category_id=None, statement_id=None, refund_of_id=None,
installment_plan_id=None, installment_number=None):
amount = money(amount)
account = lock(Account, account_id, active=True)
payee = lock(Payee, payee_id, active=True) if payee_id is not None else None
category = lock(Category, category_id, active=True) if category_id is not None else None
validate_category(kind, category)
statement = lock(CardStatement, statement_id) if statement_id is not None else None
if statement and (account.kind != "CREDIT_CARD" or statement.account_id != account.pk):
raise DomainError("statement_account_mismatch")
plan = lock(InstallmentPlan, installment_plan_id) if installment_plan_id is not None else None
if plan:
if kind != "EXPENSE" or plan.account_id != account.pk:
raise DomainError("installment_account_or_kind_mismatch")
if not isinstance(installment_number, int) or isinstance(installment_number, bool) or not 1 <= installment_number <= plan.total_installments:
raise DomainError("invalid_installment_number")
elif installment_number is not None:
raise DomainError("missing_installment_plan")
original = lock(Transaction, refund_of_id) if refund_of_id is not None else None
if original:
if kind != "REFUND" or original.kind != "EXPENSE" or original.account.currency != account.currency:
raise DomainError("invalid_refund_reference")
obj = Transaction(account=account, amount=amount, posting_date=posting_date, kind=kind,
description=description, occurrence_date=occurrence_date, payee=payee,
category=category, statement=statement, refund_of=original,
installment_plan=plan, installment_number=installment_number)
return save(obj)
@write("record_transaction")
def record_transaction(**kwargs):
return record(**kwargs), sorted(key.removesuffix("_id") for key in kwargs)
@write("annotate_transaction")
def annotate_transaction(*, transaction_id, **changes):
if not changes or set(changes) - {"description", "payee_id", "category_id"}:
raise DomainError("immutable_transaction")
# Reference rows precede transaction rows in every write path. This avoids
# a cycle with refund posting (category -> original transaction).
references = {}
for field, model in (("payee_id", Payee), ("category_id", Category)):
if field in changes:
value = changes[field]
references[field] = lock(model, value, active=True) if value is not None else None
obj = lock(Transaction, transaction_id)
if "description" in changes:
obj.description = changes["description"]
if "payee_id" in changes:
obj.payee = references["payee_id"]
if "category_id" in changes:
obj.category = references["category_id"]
validate_category(obj.kind, obj.category)
return save(obj), sorted(key.removesuffix("_id") for key in changes)
@write("attach_source")
def attach_source(*, transaction_id, document_hash, source_format, parser_version, item_locator):
obj = lock(Transaction, transaction_id)
source = save(SourceRecord(transaction=obj, document_hash=document_hash, source_format=source_format,
parser_version=parser_version, item_locator=item_locator))
return source, ["transaction", "document_hash", "source_format", "parser_version", "item_locator"]

53
finance/services/reference.py

@ -0,0 +1,53 @@
from finance.models import Account, Payee, Category, CardStatement, InstallmentPlan
from .base import DomainError, lock, money, save, write
def normalized_name(value):
if not isinstance(value, str) or not value.strip():
raise DomainError("invalid_name")
return value.strip()
@write("create_account")
def create_account(*, name, kind, currency="BRL"):
obj = save(Account(name=normalized_name(name), kind=kind, currency=currency))
return obj, ["name", "kind", "currency"]
@write("create_payee")
def create_payee(*, name):
return save(Payee(name=normalized_name(name))), ["name"]
@write("create_category")
def create_category(*, name, kind):
return save(Category(name=normalized_name(name), kind=kind)), ["name", "kind"]
@write("archive")
def archive(entity):
if type(entity) not in (Account, Payee, Category):
raise DomainError("invalid_reference")
obj = lock(type(entity), entity.pk)
obj.archived = True
return save(obj), ["archived"]
@write("create_statement")
def create_statement(*, account_id, period_start, period_end, due_date, statement_balance=None):
account = lock(Account, account_id, active=True)
if account.kind != "CREDIT_CARD":
raise DomainError("statement_requires_card")
balance = None if statement_balance is None else money(statement_balance, allow_zero=True)
obj = save(CardStatement(account=account, period_start=period_start, period_end=period_end,
due_date=due_date, statement_balance=balance))
return obj, ["account", "period_start", "period_end", "due_date", "statement_balance"]
@write("create_plan")
def create_plan(*, account_id, description, total_installments):
account = lock(Account, account_id, active=True)
if not isinstance(total_installments, int) or isinstance(total_installments, bool) or total_installments < 1:
raise DomainError("invalid_installment_count")
obj = save(InstallmentPlan(account=account, description=description, total_installments=total_installments))
return obj, ["account", "description", "total_installments"]

45
finance/services/transfers.py

@ -0,0 +1,45 @@
from finance.models import Account, Transaction, Transfer
from .base import DomainError, identity, lock, money, save, write
from .posting import record
def match(outgoing_id, incoming_id):
ids = [identity(outgoing_id), identity(incoming_id)]
if ids[0] == ids[1]:
raise DomainError("invalid_transfer_legs")
rows = {obj.pk: obj for obj in Transaction.objects.select_for_update().filter(pk__in=ids).order_by("pk")}
if len(rows) != 2:
raise DomainError("not_found")
outgoing, incoming = rows[ids[0]], rows[ids[1]]
if outgoing.kind != "TRANSFER" or incoming.kind != "TRANSFER" or outgoing.amount >= 0 or incoming.amount <= 0 or outgoing.amount != incoming.amount.copy_negate():
raise DomainError("invalid_transfer_legs")
if outgoing.account_id == incoming.account_id or outgoing.account.currency != incoming.account.currency:
raise DomainError("transfer_account_or_currency_mismatch")
if Transfer.objects.filter(outgoing=outgoing).exists() or Transfer.objects.filter(incoming=incoming).exists():
raise DomainError("already_paired")
return save(Transfer(outgoing=outgoing, incoming=incoming))
@write("match_transfer")
def match_transfer(*, outgoing_id, incoming_id):
return match(outgoing_id, incoming_id), ["outgoing", "incoming"]
@write("create_transfer")
def create_transfer(*, outgoing_account_id, incoming_account_id, amount, outgoing_date, incoming_date):
amount = money(amount)
if amount <= 0:
raise DomainError("transfer_amount_must_be_positive")
ids = [identity(outgoing_account_id), identity(incoming_account_id)]
if ids[0] == ids[1]:
raise DomainError("transfer_account_or_currency_mismatch")
accounts = {obj.pk: obj for obj in Account.objects.select_for_update().filter(pk__in=ids).order_by("pk")}
if len(accounts) != 2:
raise DomainError("not_found")
if any(obj.archived for obj in accounts.values()):
raise DomainError("archived_reference")
if accounts[ids[0]].currency != accounts[ids[1]].currency:
raise DomainError("transfer_account_or_currency_mismatch")
outgoing = record(account_id=ids[0], amount=amount.copy_negate(), posting_date=outgoing_date, kind="TRANSFER")
incoming = record(account_id=ids[1], amount=amount, posting_date=incoming_date, kind="TRANSFER")
return match(outgoing.pk, incoming.pk), ["outgoing", "incoming"]

56
tests/finance/test_concurrency.py

@ -0,0 +1,56 @@
"""Real PostgreSQL competing writers, with independent thread connections."""
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from decimal import Decimal
from threading import Barrier
import pytest
from django.db import close_old_connections, connection, connections
from finance.models import AuditEvent, Transaction, Transfer
from finance.services import Actor, DomainError, create_account, match_transfer, record_transaction
pytestmark = pytest.mark.django_db(transaction=True)
def test_competing_matches_cannot_reuse_one_outgoing_leg():
assert connection.vendor == "postgresql"
actor = Actor(kind="AGENT", identifier="synthetic-concurrency-test")
bank = create_account(name="Synthetic Outgoing", kind="BANK", actor=actor)
other = create_account(name="Synthetic Incoming", kind="BANK", actor=actor)
common = dict(kind="TRANSFER", posting_date=date(2026, 1, 15), actor=actor)
outgoing = record_transaction(account_id=bank.pk, amount=Decimal("-25"), **common)
incoming = [record_transaction(account_id=other.pk, amount=Decimal("25"), **common) for _ in range(2)]
barrier = Barrier(2)
audit_ids = set(AuditEvent.objects.values_list("pk", flat=True))
def attempt(incoming_id):
close_old_connections()
try:
# Opening connections before the barrier ensures two real sessions.
with connection.cursor() as cursor:
cursor.execute("SELECT pg_backend_pid()")
backend_pid = cursor.fetchone()[0]
barrier.wait(timeout=15)
try:
result = match_transfer(outgoing_id=outgoing.pk, incoming_id=incoming_id, actor=actor)
return backend_pid, "success", result.pk
except DomainError as error:
return backend_pid, "failure", error.code
finally:
connections.close_all()
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(attempt, [row.pk for row in incoming]))
assert len({result[0] for result in results}) == 2
assert sorted(result[1] for result in results) == ["failure", "success"]
assert all(result[2] for result in results)
assert Transfer.objects.count() == 1
paired = Transfer.objects.get()
assert paired.outgoing_id == outgoing.pk
assert paired.incoming_id in {row.pk for row in incoming}
assert Transaction.objects.count() == 3
outcomes = list(AuditEvent.objects.exclude(pk__in=audit_ids).values_list("outcome", flat=True))
assert sorted(outcomes) == ["FAILURE", "SUCCESS"]

139
tests/finance/test_constraints.py

@ -0,0 +1,139 @@
"""Synthetic direct-ORM checks for the PostgreSQL persistence boundary."""
from datetime import date
from decimal import Decimal
import pytest
from django.db import IntegrityError
from django.db.models.deletion import ProtectedError
from finance.models import (
Account, AuditEvent, CardStatement, Category, InstallmentPlan, Payee,
SourceRecord, Transaction, Transfer,
)
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture
def account():
return Account.objects.create(name="Synthetic bank", kind="BANK")
def post(account, **changes):
values = dict(account=account, amount=Decimal("-10.00"),
posting_date=date(2026, 9, 1), kind="EXPENSE")
values.update(changes)
return Transaction.objects.create(**values)
@pytest.mark.parametrize("kind,amount", [
("EXPENSE", "0"), ("EXPENSE", "1"), ("INCOME", "-1"),
("REFUND", "-1"), ("TRANSFER", "0"), ("OPENING", "0"),
("INVALID", "-1"),
])
def test_database_rejects_invalid_transaction_signs(account, kind, amount):
with pytest.raises(IntegrityError):
post(account, kind=kind, amount=Decimal(amount))
@pytest.mark.parametrize("currency", ["brl", "B1L", "BR", "ÁBC"])
def test_database_rejects_invalid_currency(currency):
with pytest.raises(IntegrityError):
Account.objects.create(name="Synthetic account", kind="BANK", currency=currency)
def test_database_rejects_invalid_reference_choices():
with pytest.raises(IntegrityError):
Account.objects.create(name="Synthetic account", kind="INVALID")
with pytest.raises(IntegrityError):
Category.objects.create(name="Synthetic category", kind="INVALID")
def test_opening_is_unique_and_cannot_have_associations(account):
post(account, kind="OPENING")
with pytest.raises(IntegrityError):
post(account, kind="OPENING")
payee = Payee.objects.create(name="Synthetic shop")
other = Account.objects.create(name="Other synthetic bank", kind="BANK")
with pytest.raises(IntegrityError):
post(other, kind="OPENING", payee=payee)
def test_refund_reference_only_allowed_on_refund(account):
expense = post(account)
with pytest.raises(IntegrityError):
post(account, refund_of=expense)
with pytest.raises(IntegrityError):
Transaction.objects.filter(pk=expense.pk).update(
kind="REFUND", amount=Decimal("10"), refund_of=expense)
def test_transfer_cannot_have_category(account):
category = Category.objects.create(name="Synthetic expense", kind="EXPENSE")
with pytest.raises(IntegrityError):
post(account, kind="TRANSFER", category=category)
def test_installment_pair_number_kind_and_uniqueness(account):
plan = InstallmentPlan.objects.create(account=account, total_installments=10)
for changes in [dict(installment_plan=plan), dict(installment_number=3),
dict(installment_plan=plan, installment_number=0),
dict(installment_plan=plan, installment_number=3,
kind="REFUND", amount=Decimal("10"))]:
with pytest.raises(IntegrityError):
post(account, **changes)
post(account, installment_plan=plan, installment_number=3)
with pytest.raises(IntegrityError):
post(account, installment_plan=plan, installment_number=3)
with pytest.raises(IntegrityError):
InstallmentPlan.objects.create(account=account, total_installments=0)
def test_statement_date_order_and_identity(account):
values = dict(account=account, period_start=date(2026, 8, 1),
period_end=date(2026, 8, 31), due_date=date(2026, 9, 10))
with pytest.raises(IntegrityError):
CardStatement.objects.create(**{**values, "period_end": date(2026, 7, 31)})
with pytest.raises(IntegrityError):
CardStatement.objects.create(**{**values, "due_date": date(2026, 8, 30)})
CardStatement.objects.create(**values)
with pytest.raises(IntegrityError):
CardStatement.objects.create(**values)
def test_source_identity_format_and_protected_history(account):
activity = post(account)
values = dict(transaction=activity, document_hash="a" * 64,
source_format="BANK_CSV", parser_version="synthetic-v1",
item_locator="row:1")
for changes in [dict(document_hash="bad"), dict(source_format="OFX")]:
with pytest.raises(IntegrityError):
SourceRecord.objects.create(**{**values, **changes})
SourceRecord.objects.create(**values)
with pytest.raises(IntegrityError):
SourceRecord.objects.create(**values)
with pytest.raises(ProtectedError):
activity.delete()
with pytest.raises(ProtectedError):
account.delete()
def test_transfer_distinct_and_unique_roles(account):
outgoing = post(account, kind="TRANSFER")
incoming = post(account, kind="TRANSFER", amount=Decimal("10"))
with pytest.raises(IntegrityError):
Transfer.objects.create(outgoing=outgoing, incoming=outgoing)
Transfer.objects.create(outgoing=outgoing, incoming=incoming)
with pytest.raises(IntegrityError):
Transfer.objects.create(outgoing=outgoing, incoming=post(account, amount=Decimal("10"), kind="TRANSFER"))
with pytest.raises(IntegrityError):
Transfer.objects.create(outgoing=post(account, kind="TRANSFER"), incoming=incoming)
def test_audit_actor_and_outcome_choices():
values = dict(actor_kind="HUMAN", actor_identifier="synthetic-owner",
operation="create_account", outcome="SUCCESS")
for changes in [dict(actor_kind="INVALID"), dict(outcome="INVALID")]:
with pytest.raises(IntegrityError):
AuditEvent.objects.create(**{**values, **changes})

316
tests/finance/test_services.py

@ -0,0 +1,316 @@
"""Synthetic acceptance cases for the public, audited write boundary."""
from contextlib import contextmanager
from datetime import date
from decimal import Decimal
import pytest
from django.db import transaction
from django.db.models import Sum
from finance.models import AuditEvent, SourceRecord, Transaction, Transfer
from finance.services import (
Actor, DomainError, annotate_transaction, archive, attach_source,
create_account, create_category, create_payee, create_plan, create_statement,
create_transfer, match_transfer, record_transaction,
)
pytestmark = pytest.mark.django_db(transaction=True)
ACTOR = Actor(kind="AGENT", identifier="synthetic-test-agent")
DAY = date(2026, 1, 15)
def account(name="Synthetic Bank", kind="BANK", currency="BRL"):
return create_account(name=name, kind=kind, currency=currency, actor=ACTOR)
def post(account, amount="-120.00", kind="EXPENSE", **fields):
return record_transaction(
account_id=account.pk, amount=Decimal(amount), kind=kind,
posting_date=DAY, actor=ACTOR, **fields,
)
@contextmanager
def rejected_without_posting():
"""A rejected write must leave movement intact and retain a failure audit."""
before = Transaction.objects.count()
previous_audits = set(AuditEvent.objects.values_list("pk", flat=True))
with pytest.raises(DomainError) as caught:
yield
assert caught.value.code
assert Transaction.objects.count() == before
events = list(AuditEvent.objects.exclude(pk__in=previous_audits))
assert len(events) == 1
assert events[0].outcome == "FAILURE"
assert events[0].error_code == caught.value.code
assert events[0].actor_kind == "AGENT"
assert events[0].actor_identifier == ACTOR.identifier
def test_opening_and_expense_derive_bank_balance():
bank = account()
post(bank, "1000.00", "OPENING")
expense = post(bank, occurrence_date=date(2026, 1, 12))
assert Transaction.objects.filter(account=bank).aggregate(total=Sum("amount"))["total"] == Decimal("880.00")
assert expense.posting_date == DAY
assert expense.occurrence_date == date(2026, 1, 12)
with rejected_without_posting():
post(bank, "1.00", "OPENING")
@pytest.mark.parametrize("amount", [0.1, Decimal("0"), Decimal("NaN"), Decimal("Infinity"), Decimal("-Infinity"), Decimal("-0.001"), Decimal("10000000000000000"), Decimal("1e999999999")])
def test_invalid_money_is_rejected_without_rounding(amount):
bank = account()
with rejected_without_posting():
record_transaction(account_id=bank.pk, amount=amount, kind="EXPENSE", posting_date=DAY, actor=ACTOR)
@pytest.mark.parametrize("kind,amount", [("INCOME", "-1"), ("EXPENSE", "1"), ("REFUND", "-1"), ("UNKNOWN", "1")])
def test_transaction_sign_matches_kind(kind, amount):
bank = account()
with rejected_without_posting():
post(bank, amount, kind)
@pytest.mark.parametrize("currency", ["brl", "BR", "BRLL", "B1L", "ÉUR"])
def test_account_currency_requires_three_uppercase_ascii_letters(currency):
with rejected_without_posting():
account(currency=currency)
def test_category_kind_and_archived_assignments_are_validated():
bank = account()
income = create_category(name="Synthetic Salary", kind="INCOME", actor=ACTOR)
expense = create_category(name="Synthetic Food", kind="EXPENSE", actor=ACTOR)
payee = create_payee(name="Synthetic Merchant", actor=ACTOR)
with rejected_without_posting():
post(bank, category_id=income.pk)
with rejected_without_posting():
post(bank, "10", "INCOME", category_id=expense.pk)
historical = post(bank, category_id=expense.pk, payee_id=payee.pk)
archive(expense, actor=ACTOR)
archive(payee, actor=ACTOR)
for fields in ({"category_id": expense.pk}, {"payee_id": payee.pk}):
with rejected_without_posting():
post(bank, **fields)
updated = annotate_transaction(transaction_id=historical.pk, description="Synthetic revised label", actor=ACTOR)
assert updated.category_id == expense.pk
assert updated.payee_id == payee.pk
assert updated.description == "Synthetic revised label"
archive(bank, actor=ACTOR)
with rejected_without_posting():
post(bank)
historical.refresh_from_db()
assert historical.amount == Decimal("-120.00")
def test_annotations_change_only_permitted_fields_and_audit_names():
bank = account()
row = post(bank)
category = create_category(name="Synthetic Food", kind="EXPENSE", actor=ACTOR)
payee = create_payee(name="Synthetic Merchant", actor=ACTOR)
updated = annotate_transaction(transaction_id=row.pk, description="Synthetic sensitive label", payee_id=payee.pk, category_id=category.pk, actor=ACTOR)
assert updated.description == "Synthetic sensitive label"
assert updated.category_id == category.pk
assert updated.payee_id == payee.pk
event = AuditEvent.objects.filter(target_id=row.pk).latest("created_at")
assert event.outcome == "SUCCESS"
assert set(event.changed_fields) == {"description", "payee", "category"}
assert "Synthetic sensitive label" not in str(event.__dict__)
cleared = annotate_transaction(transaction_id=row.pk, payee_id=None, category_id=None, actor=ACTOR)
assert cleared.payee_id is None and cleared.category_id is None
@pytest.mark.parametrize("field,value", [("amount", Decimal("-99")), ("account_id", None), ("posting_date", date(2026, 2, 1)), ("kind", "INCOME"), ("refund_of_id", None), ("installment_number", 1)])
def test_annotation_api_rejects_financial_changes(field, value):
row = post(account())
with pytest.raises((TypeError, DomainError)):
annotate_transaction(transaction_id=row.pk, actor=ACTOR, **{field: value})
row.refresh_from_db()
assert row.amount == Decimal("-120.00")
assert row.posting_date == DAY
assert row.kind == "EXPENSE"
def test_card_payment_adds_no_spending_and_preserves_settlement_dates():
bank = account()
card = account("Synthetic Card", "CREDIT_CARD")
post(bank, "1000", "OPENING")
post(card, "-300")
payment = create_transfer(outgoing_account_id=bank.pk, incoming_account_id=card.pk, amount=Decimal("300"), outgoing_date=DAY, incoming_date=date(2026, 1, 17), actor=ACTOR)
assert payment.outgoing.amount == Decimal("-300")
assert payment.incoming.amount == Decimal("300")
assert payment.outgoing.posting_date == DAY
assert payment.incoming.posting_date == date(2026, 1, 17)
assert Transaction.objects.filter(kind="EXPENSE").aggregate(total=Sum("amount"))["total"] == Decimal("-300")
assert Transaction.objects.filter(account=card).aggregate(total=Sum("amount"))["total"] == Decimal("0")
assert Transaction.objects.count() == 4
@pytest.mark.parametrize("scenario", ["same_account", "different_currency", "negative_amount"])
def test_invalid_complete_transfer_is_atomic(scenario):
bank = account()
other = bank if scenario == "same_account" else account("Synthetic Other", currency="USD" if scenario == "different_currency" else "BRL")
with rejected_without_posting():
create_transfer(outgoing_account_id=bank.pk, incoming_account_id=other.pk, amount=Decimal("-10" if scenario == "negative_amount" else "10"), outgoing_date=DAY, incoming_date=DAY, actor=ACTOR)
assert Transfer.objects.count() == 0
def test_match_existing_transfer_legs_creates_no_extra_transactions():
outgoing = post(account(), "-25", "TRANSFER")
incoming = post(account("Synthetic Other"), "25", "TRANSFER")
match = match_transfer(outgoing_id=outgoing.pk, incoming_id=incoming.pk, actor=ACTOR)
assert match.outgoing_id == outgoing.pk
assert match.incoming_id == incoming.pk
assert Transaction.objects.count() == 2
with rejected_without_posting():
match_transfer(outgoing_id=outgoing.pk, incoming_id=incoming.pk, actor=ACTOR)
assert Transfer.objects.count() == 1
@pytest.mark.parametrize("scenario", ["same_account", "currency", "sign", "magnitude", "kind"])
def test_matching_validates_both_existing_legs(scenario):
bank = account()
other = bank if scenario == "same_account" else account("Synthetic Other", currency="USD" if scenario == "currency" else "BRL")
outgoing = post(bank, "-25", "EXPENSE" if scenario == "kind" else "TRANSFER")
incoming = post(other, "-25" if scenario == "sign" else "26" if scenario == "magnitude" else "25", "TRANSFER")
with rejected_without_posting():
match_transfer(outgoing_id=outgoing.pk, incoming_id=incoming.pk, actor=ACTOR)
assert Transfer.objects.count() == 0
def test_refunds_retain_purchase_and_allow_cross_account_partial_adjustments():
bank = account()
category = create_category(name="Synthetic Food", kind="EXPENSE", actor=ACTOR)
purchase = post(bank, category_id=category.pk)
other = account("Synthetic Other")
refund = post(other, "50", "REFUND", refund_of_id=purchase.pk, category_id=category.pk)
assert refund.refund_of_id == purchase.pk
assert Transaction.objects.filter(kind__in=["EXPENSE", "REFUND"]).aggregate(total=Sum("amount"))["total"] == Decimal("-70")
post(other, "100", "REFUND", refund_of_id=purchase.pk)
unlinked = post(other, "5", "REFUND")
assert unlinked.refund_of_id is None
purchase.refresh_from_db()
assert purchase.amount == Decimal("-120")
@pytest.mark.parametrize("scenario", ["currency", "target_kind", "wrong_row_kind"])
def test_invalid_refund_links_are_rejected(scenario):
bank = account()
original = post(bank, "20" if scenario == "target_kind" else "-20", "INCOME" if scenario == "target_kind" else "EXPENSE")
other = account("Synthetic Other", currency="USD" if scenario == "currency" else "BRL")
with rejected_without_posting():
post(other, "5", "INCOME" if scenario == "wrong_row_kind" else "REFUND", refund_of_id=original.pk)
def test_only_observed_installments_are_posted_and_numbers_are_unique_per_plan():
card = account("Synthetic Card", "CREDIT_CARD")
first = create_plan(account_id=card.pk, description="Synthetic purchase", total_installments=10, actor=ACTOR)
second = create_plan(account_id=card.pk, description="Synthetic second purchase", total_installments=10, actor=ACTOR)
row = post(card, "-30.01", installment_plan_id=first.pk, installment_number=3)
assert Transaction.objects.count() == 1
with rejected_without_posting():
post(card, "-30.01", installment_plan_id=first.pk, installment_number=3)
post(card, "-30.00", installment_plan_id=first.pk, installment_number=4)
post(card, "-30.01", installment_plan_id=second.pk, installment_number=3)
refund = post(card, "5", "REFUND", refund_of_id=row.pk)
assert refund.refund_of_id == row.pk
assert Transaction.objects.filter(kind="EXPENSE").count() == 3
@pytest.mark.parametrize("scenario", ["account", "zero", "overflow", "missing_number", "missing_plan", "kind"])
def test_invalid_installment_membership_is_rejected(scenario):
bank = account()
plan = create_plan(account_id=bank.pk, description="Synthetic purchase", total_installments=10, actor=ACTOR)
destination = account("Synthetic Other") if scenario == "account" else bank
fields = {"installment_plan_id": plan.pk, "installment_number": 3}
if scenario == "missing_number":
fields.pop("installment_number")
elif scenario == "missing_plan":
fields.pop("installment_plan_id")
elif scenario in {"zero", "overflow"}:
fields["installment_number"] = 0 if scenario == "zero" else 11
with rejected_without_posting():
post(destination, "10" if scenario == "kind" else "-10", "INCOME" if scenario == "kind" else "EXPENSE", **fields)
def test_statement_metadata_does_not_manufacture_transactions_or_reconciliation():
card = account("Synthetic Card", "CREDIT_CARD")
statement = create_statement(account_id=card.pk, period_start=date(2026, 1, 1), period_end=date(2026, 1, 31), due_date=date(2026, 2, 10), statement_balance=Decimal("-900"), actor=ACTOR)
assert Transaction.objects.count() == 0
row = post(card, "-30", statement_id=statement.pk)
assert row.statement_id == statement.pk
statement.refresh_from_db()
assert statement.statement_balance == Decimal("-900")
for destination in (account(), account("Synthetic Other Card", "CREDIT_CARD")):
with rejected_without_posting():
post(destination, statement_id=statement.pk)
@pytest.mark.parametrize("scenario", ["bank", "period", "due"])
def test_statement_creation_validates_account_and_dates(scenario):
target = account(kind="BANK" if scenario == "bank" else "CREDIT_CARD")
with rejected_without_posting():
create_statement(account_id=target.pk, period_start=date(2026, 2, 1) if scenario == "period" else date(2026, 1, 1), period_end=date(2026, 1, 31), due_date=date(2026, 1, 30) if scenario == "due" else date(2026, 2, 10), actor=ACTOR)
def test_source_identity_is_physical_item_and_supports_multiple_sources_per_transaction():
row = post(account())
other = post(row.account, "-40")
metadata = dict(document_hash="a" * 64, source_format="BANK_CSV", parser_version="synthetic-v1", item_locator="row:2", actor=ACTOR)
original = attach_source(transaction_id=row.pk, **metadata)
assert original.transaction_id == row.pk
with rejected_without_posting():
attach_source(transaction_id=other.pk, **{**metadata, "parser_version": "synthetic-v2"})
attach_source(transaction_id=row.pk, **{**metadata, "item_locator": "row:3"})
attach_source(transaction_id=row.pk, **{**metadata, "document_hash": "b" * 64})
assert SourceRecord.objects.filter(transaction=row).count() == 3
assert SourceRecord.objects.filter(transaction=other).count() == 0
assert Transaction.objects.count() == 2
@pytest.mark.parametrize("fields", [{"document_hash": "invalid"}, {"source_format": "OFX"}, {"parser_version": " "}, {"item_locator": " "}])
def test_invalid_source_metadata_is_rejected(fields):
row = post(account())
values = dict(document_hash="a" * 64, source_format="BANK_CSV", parser_version="synthetic-v1", item_locator="row:2")
with rejected_without_posting():
attach_source(transaction_id=row.pk, actor=ACTOR, **{**values, **fields})
assert SourceRecord.objects.count() == 0
def test_public_writes_refuse_caller_owned_atomic_blocks():
bank = account()
before = Transaction.objects.count()
with transaction.atomic():
with pytest.raises((DomainError, RuntimeError)):
post(bank)
assert Transaction.objects.count() == before
def test_complete_transfer_preserves_amount_under_low_decimal_precision():
from decimal import localcontext
bank = account()
other = account("Synthetic Other")
with localcontext() as context:
context.prec = 4
payment = create_transfer(outgoing_account_id=bank.pk, incoming_account_id=other.pk,
amount=Decimal("123.45"), outgoing_date=DAY,
incoming_date=DAY, actor=ACTOR)
payment.outgoing.refresh_from_db()
payment.incoming.refresh_from_db()
assert payment.outgoing.amount == Decimal("-123.45")
assert payment.incoming.amount == Decimal("123.45")
def test_matching_rejects_amounts_that_only_match_after_context_rounding():
from decimal import localcontext
outgoing = post(account(), "-123.40", "TRANSFER")
incoming = post(account("Synthetic Other"), "123.45", "TRANSFER")
with localcontext() as context:
context.prec = 4
with rejected_without_posting():
match_transfer(outgoing_id=outgoing.pk, incoming_id=incoming.pk, actor=ACTOR)
assert Transfer.objects.count() == 0
Loading…
Cancel
Save