From d01e8918c0333c79b50fea2896711daeaaedfb0b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 16:34:33 -0300 Subject: [PATCH] feat: add financial domain --- docs/financial-domain.md | 110 ++++++ .../plans/2026-09-06-financial-domain.md | 41 +++ .../2026-09-06-financial-domain-design.md | 199 +++++++++++ finance/__init__.py | 1 + finance/migrations/0001_initial.py | 230 +++++++++++++ finance/migrations/__init__.py | 0 finance/models/__init__.py | 8 + finance/models/base.py | 18 + finance/models/ledger.py | 75 +++++ finance/models/provenance.py | 54 +++ finance/models/reference.py | 48 +++ finance/services/__init__.py | 7 + finance/services/base.py | 89 +++++ finance/services/posting.py | 75 +++++ finance/services/reference.py | 53 +++ finance/services/transfers.py | 45 +++ tests/finance/test_concurrency.py | 56 ++++ tests/finance/test_constraints.py | 139 ++++++++ tests/finance/test_services.py | 316 ++++++++++++++++++ 19 files changed, 1564 insertions(+) create mode 100644 docs/financial-domain.md create mode 100644 docs/superpowers/plans/2026-09-06-financial-domain.md create mode 100644 docs/superpowers/specs/2026-09-06-financial-domain-design.md create mode 100644 finance/__init__.py create mode 100644 finance/migrations/0001_initial.py create mode 100644 finance/migrations/__init__.py create mode 100644 finance/models/__init__.py create mode 100644 finance/models/base.py create mode 100644 finance/models/ledger.py create mode 100644 finance/models/provenance.py create mode 100644 finance/models/reference.py create mode 100644 finance/services/__init__.py create mode 100644 finance/services/base.py create mode 100644 finance/services/posting.py create mode 100644 finance/services/reference.py create mode 100644 finance/services/transfers.py create mode 100644 tests/finance/test_concurrency.py create mode 100644 tests/finance/test_constraints.py create mode 100644 tests/finance/test_services.py diff --git a/docs/financial-domain.md b/docs/financial-domain.md new file mode 100644 index 0000000..d7a4902 --- /dev/null +++ b/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. diff --git a/docs/superpowers/plans/2026-09-06-financial-domain.md b/docs/superpowers/plans/2026-09-06-financial-domain.md new file mode 100644 index 0000000..e8e9370 --- /dev/null +++ b/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. diff --git a/docs/superpowers/specs/2026-09-06-financial-domain-design.md b/docs/superpowers/specs/2026-09-06-financial-domain-design.md new file mode 100644 index 0000000..589636a --- /dev/null +++ b/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. diff --git a/finance/__init__.py b/finance/__init__.py new file mode 100644 index 0000000..bc41413 --- /dev/null +++ b/finance/__init__.py @@ -0,0 +1 @@ +"""Financial domain persistence and audited write operations.""" diff --git a/finance/migrations/0001_initial.py b/finance/migrations/0001_initial.py new file mode 100644 index 0000000..a99ee69 --- /dev/null +++ b/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'), + ), + ] diff --git a/finance/migrations/__init__.py b/finance/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/finance/models/__init__.py b/finance/models/__init__.py new file mode 100644 index 0000000..1677961 --- /dev/null +++ b/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", +] diff --git a/finance/models/base.py b/finance/models/base.py new file mode 100644 index 0000000..02be3de --- /dev/null +++ b/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 diff --git a/finance/models/ledger.py b/finance/models/ledger.py new file mode 100644 index 0000000..ed97c85 --- /dev/null +++ b/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")] diff --git a/finance/models/provenance.py b/finance/models/provenance.py new file mode 100644 index 0000000..b10d2b5 --- /dev/null +++ b/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"), + ] diff --git a/finance/models/reference.py b/finance/models/reference.py new file mode 100644 index 0000000..392ccf7 --- /dev/null +++ b/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"), + ] diff --git a/finance/services/__init__.py b/finance/services/__init__.py new file mode 100644 index 0000000..95e290b --- /dev/null +++ b/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"] diff --git a/finance/services/base.py b/finance/services/base.py new file mode 100644 index 0000000..6471036 --- /dev/null +++ b/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 diff --git a/finance/services/posting.py b/finance/services/posting.py new file mode 100644 index 0000000..ccd5244 --- /dev/null +++ b/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"] diff --git a/finance/services/reference.py b/finance/services/reference.py new file mode 100644 index 0000000..615e9ac --- /dev/null +++ b/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"] diff --git a/finance/services/transfers.py b/finance/services/transfers.py new file mode 100644 index 0000000..c50b575 --- /dev/null +++ b/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"] diff --git a/tests/finance/test_concurrency.py b/tests/finance/test_concurrency.py new file mode 100644 index 0000000..6c47ff6 --- /dev/null +++ b/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"] diff --git a/tests/finance/test_constraints.py b/tests/finance/test_constraints.py new file mode 100644 index 0000000..52b596b --- /dev/null +++ b/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}) diff --git a/tests/finance/test_services.py b/tests/finance/test_services.py new file mode 100644 index 0000000..1cd8415 --- /dev/null +++ b/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