Plan 2 — Domain & Ports Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Populate src/finance_bot/domain/ (pure types) and src/finance_bot/ports/ (Protocol interfaces) and ship in-memory tests/_fakes/ for every port — so Plan 5’s use-cases can be written and unit-tested before any adapter exists.
Architecture: Pure-Python, I/O-free. The domain layer holds value objects, models, enums, and the error hierarchy. The ports layer holds typing.Protocol interfaces. tests/_fakes/ provides minimal in-memory implementations of every port plus tests that prove each fake honors its interface contract. No Postgres, no aiogram, no Docker. All tests run in well under one second.
Tech Stack: Python 3.12, dataclasses (frozen + slots), typing.Protocol, enum.StrEnum, decimal.Decimal (boundary only), hypothesis for Money property tests.
Branch: feature/domain-ports (worktree at /Users/zipsybok/dev/telegram-finance-bot-domain-ports).
ADRs delivered in this plan: ADR-0005 (Money representation: BIGINT minor units + ISO-4217 alpha currency).
Notes & Conventions
Section titled “Notes & Conventions”- Working directory:
/Users/zipsybok/dev/telegram-finance-bot-domain-portsfor every command. The main checkout at/Users/zipsybok/dev/telegram-finance-botis onmasterand is NOT touched in this plan. - Run python tools as
.venv/bin/<tool>oruv run <tool>.README.mdexists onmasterso plainuv runworks without--no-sync(Plan 1 closed the workaround era). - Commit style: Conventional Commits (
feat:,test:,docs:,refactor:,chore:). English. Body wraps at 72 chars. One topic per commit. Each task ends with exactly one commit unless flagged otherwise. - TDD strict: for every code-bearing task — write failing test → confirm it fails → write minimum impl → confirm it passes → lint+typecheck → commit. For docs-only tasks (ADR-0005), no test cycle.
- Strict mypy is already on for
finance_bot.{domain,ports,application}.*(Plan 1 Task 3). Every domain/ports file MUST pass mypy strict. If you hit a mypy issue you can’t resolve quickly, ESCALATE — don’t sprinkle# type: ignore. - Fakes live under
tests/_fakes/. Leading underscore so pytest scans the directory but doesn’t collect implementation modules (clock.py,ledger.py,repositories.py) — only files matchingtest_*.pyare collected. Implementation modules are imported by the test files in the same directory. - NO adapters in this plan. Anything Postgres-shaped, aiogram-shaped, or
asyncpg-flavored is Plan 3 or later.
File Structure (state after Plan 2)
Section titled “File Structure (state after Plan 2)”src/finance_bot/├── domain/│ ├── __init__.py (existing, empty)│ ├── enums.py NEW — TransactionKind, AccountType│ ├── errors.py NEW — DomainError + 9 subclasses with stable codes│ ├── money.py NEW — Money value object│ └── models.py NEW — User, Account, Category, Budget, TransferMetadata, PostedTransfer, Transaction├── ports/│ ├── __init__.py (existing, empty)│ ├── clock.py NEW — Clock Protocol│ ├── ledger.py NEW — LedgerPort Protocol│ └── repositories.py NEW — UserRepo, AccountRepo, CategoryRepo, BudgetRepo, TransactionReadRepo├── application/ (unchanged — still empty)├── adapters/ (unchanged — still empty besides Plan 1 telegram + observability)├── bootstrap.py (unchanged)└── __main__.py (unchanged)
tests/├── conftest.py (existing)├── unit/│ ├── __init__.py (existing)│ ├── domain/ NEW│ │ ├── __init__.py│ │ ├── test_enums.py│ │ ├── test_errors.py│ │ ├── test_money.py│ │ ├── test_money_arithmetic.py (hypothesis-based)│ │ └── test_models.py│ └── ports/ NEW│ ├── __init__.py│ └── test_protocols.py (structural tests — every port has @runtime_checkable)├── _fakes/ NEW — implementation + tests for fakes│ ├── __init__.py│ ├── clock.py FakeClock│ ├── ledger.py FakeLedger (in-memory, enforces invariants)│ ├── repositories.py FakeUserRepo, FakeAccountRepo, FakeCategoryRepo, FakeBudgetRepo, FakeTransactionReadRepo│ ├── test_clock.py contract tests│ ├── test_ledger.py contract tests + property-based invariant test│ └── test_repositories.py contract tests├── unit/test_access_middleware.py (existing)├── unit/test_bootstrap.py (existing)├── unit/test_config.py (existing)├── unit/test_logging_redaction.py (existing)└── unit/test_sanity.py (existing)
docs/└── adr/ └── 0005-money-representation.md NEWAfter Plan 2 completes, expect roughly 45–55 unit tests total: 14 carried over from Plan 1 + ~30–40 new ones.
Task 1: Domain enums (TransactionKind, AccountType)
Section titled “Task 1: Domain enums (TransactionKind, AccountType)”Files:
-
Create:
src/finance_bot/domain/enums.py -
Create:
tests/unit/domain/__init__.py -
Create:
tests/unit/domain/test_enums.py -
Step 1.1: Create
tests/unit/domain/__init__.py(empty file — makestests/unit/domaina package).
mkdir -p tests/unit/domaintouch tests/unit/domain/__init__.py- Step 1.2: Write failing test
Create tests/unit/domain/test_enums.py:
"""Tests for TransactionKind and AccountType."""from __future__ import annotations
import pytest
from finance_bot.domain.enums import AccountType, TransactionKind
def test_transaction_kind_members() -> None: assert TransactionKind.EXPENSE == "expense" assert TransactionKind.INCOME == "income" assert TransactionKind.TRANSFER == "transfer" assert TransactionKind.COMPENSATION == "compensation"
def test_transaction_kind_is_str() -> None: assert isinstance(TransactionKind.EXPENSE, str) assert TransactionKind.EXPENSE == "expense"
def test_account_type_members() -> None: assert AccountType.CASH == "cash" assert AccountType.CARD == "card" assert AccountType.DEPOSIT == "deposit" assert AccountType.EXTERNAL_EXPENSE == "external_expense" assert AccountType.EXTERNAL_INCOME == "external_income"
def test_account_type_real_vs_virtual() -> None: real = {AccountType.CASH, AccountType.CARD, AccountType.DEPOSIT} virtual = {AccountType.EXTERNAL_EXPENSE, AccountType.EXTERNAL_INCOME} assert real & virtual == set() assert real | virtual == set(AccountType)
def test_transaction_kind_round_trip_via_str() -> None: assert TransactionKind("expense") == TransactionKind.EXPENSE with pytest.raises(ValueError): TransactionKind("nope")- Step 1.3: Run test, expect ImportError
.venv/bin/pytest tests/unit/domain/test_enums.py -vExpected: ImportError on from finance_bot.domain.enums import ....
- Step 1.4: Implement enums
Create src/finance_bot/domain/enums.py:
"""Domain enums.
Values are lowercase strings to match the schema literals indocs/design/2026-04-27-mvp1-architecture.md §5.2 and the`kind` / `type` columns of the future ledger and account tables."""from __future__ import annotations
from enum import StrEnum
class TransactionKind(StrEnum): """Domain-level kind for a Transaction."""
EXPENSE = "expense" INCOME = "income" TRANSFER = "transfer" COMPENSATION = "compensation"
class AccountType(StrEnum): """User-visible account type plus virtual sinks/sources used by the ledger."""
CASH = "cash" CARD = "card" DEPOSIT = "deposit" EXTERNAL_EXPENSE = "external_expense" EXTERNAL_INCOME = "external_income"- Step 1.5: Run test, expect 5 passed
.venv/bin/pytest tests/unit/domain/test_enums.py -vExpected: 5 passed.
- Step 1.6: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypyExpected: clean. mypy now covers 28 source files.
- Step 1.7: Commit
git add src/finance_bot/domain/enums.py tests/unit/domain/__init__.py tests/unit/domain/test_enums.pygit commit -m "$(cat <<'EOF'feat(domain): add TransactionKind and AccountType StrEnums
Both are StrEnum so values compare directly to their lowercasestring literals (matching the schema in design.md §5.2 and thefuture ledger/account columns).
- TransactionKind: expense | income | transfer | compensation.- AccountType: cash | card | deposit | external_expense | external_income. The two external_* members are virtual sinks/sources used to express expenses and income as ledger transfers.
5 unit tests cover member values, str-isinstance, round-trip viaconstructor, and the real-vs-virtual partition.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 2: Domain errors hierarchy
Section titled “Task 2: Domain errors hierarchy”Files:
-
Create:
src/finance_bot/domain/errors.py -
Create:
tests/unit/domain/test_errors.py -
Step 2.1: Write failing test
Create tests/unit/domain/test_errors.py:
"""Tests for the domain error hierarchy."""from __future__ import annotations
import pytest
from finance_bot.domain.errors import ( AccountNotFoundError, AlreadyVoidedError, AmountNotPositiveError, CategoryNotFoundError, CurrencyMismatchError, DomainError, MinBalanceViolationError, ParseError, SameAccountError, TransferNotFoundError,)
def test_domain_error_is_exception() -> None: assert issubclass(DomainError, Exception)
@pytest.mark.parametrize( "exc_cls, expected_code", [ (ParseError, "ERR_PARSE_001"), (CategoryNotFoundError, "ERR_VAL_001"), (AccountNotFoundError, "ERR_VAL_002"), (SameAccountError, "ERR_LDG_001"), (AmountNotPositiveError, "ERR_LDG_002"), (CurrencyMismatchError, "ERR_LDG_003"), (MinBalanceViolationError, "ERR_LDG_004"), (TransferNotFoundError, "ERR_LDG_005"), (AlreadyVoidedError, "ERR_LDG_006"), ],)def test_each_error_has_stable_code( exc_cls: type[DomainError], expected_code: str) -> None: assert exc_cls.code == expected_code
def test_each_error_inherits_domain_error() -> None: for exc_cls in ( ParseError, CategoryNotFoundError, AccountNotFoundError, SameAccountError, AmountNotPositiveError, CurrencyMismatchError, MinBalanceViolationError, TransferNotFoundError, AlreadyVoidedError, ): assert issubclass(exc_cls, DomainError)
def test_error_can_be_raised_with_message() -> None: err = SameAccountError("debit and credit must differ")
assert str(err) == "debit and credit must differ" assert err.code == "ERR_LDG_001"
def test_codes_are_unique() -> None: codes = { ParseError.code, CategoryNotFoundError.code, AccountNotFoundError.code, SameAccountError.code, AmountNotPositiveError.code, CurrencyMismatchError.code, MinBalanceViolationError.code, TransferNotFoundError.code, AlreadyVoidedError.code, } assert len(codes) == 9- Step 2.2: Run test, expect ImportError
.venv/bin/pytest tests/unit/domain/test_errors.py -vExpected: ImportError.
- Step 2.3: Implement errors
Create src/finance_bot/domain/errors.py:
"""Domain error hierarchy.
Every domain-level exception inherits from DomainError and carries astable string code on its class. Codes are mapped 1:1 to acceptancecriteria in the SRS (see design.md §9.1) and are surfaced both inlog lines and (where appropriate) in user-visible error messages.
Adapter-level transport errors (DBError, TelegramAPIError, etc.) liveelsewhere; they do NOT inherit from DomainError because handling themis a different concern (retry, circuit-break, alert)."""from __future__ import annotations
from typing import ClassVar
class DomainError(Exception): """Base for every expected domain-level failure."""
code: ClassVar[str] = "ERR_GENERIC"
class ParseError(DomainError): """Raised when a free-text user message cannot be parsed."""
code: ClassVar[str] = "ERR_PARSE_001"
class CategoryNotFoundError(DomainError): """Raised when a category alias cannot be resolved and there is no fallback."""
code: ClassVar[str] = "ERR_VAL_001"
class AccountNotFoundError(DomainError): """Raised when an account id or alias cannot be resolved for the user."""
code: ClassVar[str] = "ERR_VAL_002"
class SameAccountError(DomainError): """Raised when a transfer specifies the same debit and credit account."""
code: ClassVar[str] = "ERR_LDG_001"
class AmountNotPositiveError(DomainError): """Raised when a transfer's amount is zero or negative."""
code: ClassVar[str] = "ERR_LDG_002"
class CurrencyMismatchError(DomainError): """Raised when an operation is attempted across two different currencies."""
code: ClassVar[str] = "ERR_LDG_003"
class MinBalanceViolationError(DomainError): """Raised when a transfer would push an account below its min_balance."""
code: ClassVar[str] = "ERR_LDG_004"
class TransferNotFoundError(DomainError): """Raised when a void target does not exist for the user."""
code: ClassVar[str] = "ERR_LDG_005"
class AlreadyVoidedError(DomainError): """Raised when a void target has already been voided."""
code: ClassVar[str] = "ERR_LDG_006"- Step 2.4: Run test, expect 14 passed (5 from parametrize + 9 others)
.venv/bin/pytest tests/unit/domain/test_errors.py -vExpected: all pass.
- Step 2.5: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypyExpected: clean.
- Step 2.6: Commit
git add src/finance_bot/domain/errors.py tests/unit/domain/test_errors.pygit commit -m "$(cat <<'EOF'feat(domain): add DomainError hierarchy with stable codes
DomainError base + 9 subclasses, each with a ClassVar[str] code thatmaps 1:1 to an acceptance criterion / FR id in the SRS:
ParseError ERR_PARSE_001 CategoryNotFoundError ERR_VAL_001 AccountNotFoundError ERR_VAL_002 SameAccountError ERR_LDG_001 AmountNotPositiveError ERR_LDG_002 CurrencyMismatchError ERR_LDG_003 MinBalanceViolationError ERR_LDG_004 TransferNotFoundError ERR_LDG_005 AlreadyVoidedError ERR_LDG_006
Adapter-transport errors are intentionally NOT in this hierarchy.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 3: Money — structure + currency validation
Section titled “Task 3: Money — structure + currency validation”Files:
-
Create:
src/finance_bot/domain/money.py -
Create:
tests/unit/domain/test_money.py -
Step 3.1: Write failing test
Create tests/unit/domain/test_money.py:
"""Tests for Money value object: structure and validation."""from __future__ import annotations
import pytest
from finance_bot.domain.money import Money
def test_money_construction() -> None: m = Money(amount_minor=25000, currency="UAH") assert m.amount_minor == 25000 assert m.currency == "UAH"
def test_money_is_frozen() -> None: m = Money(amount_minor=1, currency="UAH") with pytest.raises(Exception): # FrozenInstanceError under dataclasses m.amount_minor = 2 # type: ignore[misc]
def test_money_is_hashable() -> None: a = Money(amount_minor=100, currency="UAH") b = Money(amount_minor=100, currency="UAH") assert hash(a) == hash(b) assert {a, b} == {a}
def test_money_equality_requires_same_currency() -> None: assert Money(100, "UAH") == Money(100, "UAH") assert Money(100, "UAH") != Money(100, "USD") assert Money(100, "UAH") != Money(200, "UAH")
def test_money_zero() -> None: z = Money.zero("UAH") assert z.amount_minor == 0 assert z.currency == "UAH"
def test_money_rejects_invalid_currency() -> None: with pytest.raises(ValueError, match="ISO-4217"): Money(amount_minor=1, currency="uah") # lowercase
with pytest.raises(ValueError, match="ISO-4217"): Money(amount_minor=1, currency="UA") # too short
with pytest.raises(ValueError, match="ISO-4217"): Money(amount_minor=1, currency="UAHX") # too long
with pytest.raises(ValueError, match="ISO-4217"): Money(amount_minor=1, currency="UA1") # non-alpha
def test_money_allows_negative_amount() -> None: """Negative amounts are valid for balance representations.
LedgerPort enforces amount > 0 on the WRITE side; Money the type must permit negatives so balances can be expressed when overdraft is allowed. """ m = Money(amount_minor=-500, currency="UAH") assert m.amount_minor == -500- Step 3.2: Run test, expect ImportError
.venv/bin/pytest tests/unit/domain/test_money.py -vExpected: ImportError.
- Step 3.3: Implement Money — structure + validation
Create src/finance_bot/domain/money.py:
"""Money value object.
Stores amount as an integer in *minor* units (kopecks for UAH, centsfor USD, …) and currency as an ISO-4217 alpha code (3 uppercaseletters). Negative amounts ARE allowed at the type level so thatbalances can be expressed under overdraft; the *write side* of theledger (LedgerPort.post_transfer) enforces amount > 0 separately.
Arithmetic, comparison, and Decimal helpers are added in Tasks 4-6."""from __future__ import annotations
import refrom dataclasses import dataclassfrom typing import ClassVar
_ISO4217_ALPHA = re.compile(r"^[A-Z]{3}$")
@dataclass(frozen=True, slots=True)class Money: """Amount in minor units paired with an ISO-4217 alpha currency code."""
amount_minor: int currency: str
SCALE: ClassVar[int] = 100 """Minor units per major. Locked at 2 for Tier-2 fiat (kopecks/cents).
Raising this is a breaking change — see ADR-0005. """
def __post_init__(self) -> None: if not _ISO4217_ALPHA.match(self.currency): raise ValueError( f"currency must be ISO-4217 alpha (3 uppercase letters): {self.currency!r}" )
@classmethod def zero(cls, currency: str) -> Money: """A zero-amount Money in the given currency.""" return cls(amount_minor=0, currency=currency)- Step 3.4: Run test, expect 7 passed
.venv/bin/pytest tests/unit/domain/test_money.py -vExpected: 7 passed.
- Step 3.5: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypyExpected: clean.
- Step 3.6: Commit
git add src/finance_bot/domain/money.py tests/unit/domain/test_money.pygit commit -m "$(cat <<'EOF'feat(domain): add Money value object (structure + currency validation)
Frozen dataclass with slots:- amount_minor: int (negative permitted for balance representation)- currency: str (validated against ISO-4217 alpha regex on construction)- SCALE: ClassVar[int] = 100 (locked at 2 decimal places, ADR-0005)- Money.zero(currency) factory.
Arithmetic, comparison, sign helpers, and Decimal conversion land inTasks 4-6. 7 unit tests cover construction, frozen-ness, hashability,equality semantics, zero factory, and currency-format rejection.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 4: Money — arithmetic with property-based tests
Section titled “Task 4: Money — arithmetic with property-based tests”Files:
-
Modify:
src/finance_bot/domain/money.py -
Create:
tests/unit/domain/test_money_arithmetic.py -
Step 4.1: Write failing tests (mix of examples and
hypothesis)
Create tests/unit/domain/test_money_arithmetic.py:
"""Tests for Money arithmetic. Mix of explicit examples and property-basedchecks via hypothesis."""from __future__ import annotations
import pytestfrom hypothesis import givenfrom hypothesis import strategies as st
from finance_bot.domain.errors import CurrencyMismatchErrorfrom finance_bot.domain.money import Money
# Strategies_AMOUNTS = st.integers(min_value=-(10**18), max_value=10**18)_CURRENCIES = st.sampled_from(["UAH", "USD", "EUR"])
def _money(currency: str = "UAH") -> st.SearchStrategy[Money]: return st.builds(Money, amount_minor=_AMOUNTS, currency=st.just(currency))
# Explicit examples (sanity)
def test_addition_same_currency() -> None: assert Money(100, "UAH") + Money(50, "UAH") == Money(150, "UAH")
def test_subtraction_same_currency() -> None: assert Money(100, "UAH") - Money(70, "UAH") == Money(30, "UAH")
def test_subtraction_can_go_negative() -> None: assert Money(50, "UAH") - Money(100, "UAH") == Money(-50, "UAH")
def test_negation() -> None: assert -Money(100, "UAH") == Money(-100, "UAH") assert -Money(0, "UAH") == Money(0, "UAH")
def test_addition_currency_mismatch_raises() -> None: with pytest.raises(CurrencyMismatchError): Money(100, "UAH") + Money(100, "USD")
def test_subtraction_currency_mismatch_raises() -> None: with pytest.raises(CurrencyMismatchError): Money(100, "UAH") - Money(100, "USD")
# Property-based
@given(a=_money(), b=_money())def test_addition_is_commutative(a: Money, b: Money) -> None: assert a + b == b + a
@given(a=_money(), b=_money(), c=_money())def test_addition_is_associative(a: Money, b: Money, c: Money) -> None: # Bound the intermediate to avoid overflow on extreme cases — Python ints # don't overflow, but we want to ensure no spurious sign flips. assert (a + b) + c == a + (b + c)
@given(a=_money())def test_zero_is_identity_for_addition(a: Money) -> None: assert a + Money.zero(a.currency) == a assert Money.zero(a.currency) + a == a
@given(a=_money())def test_negation_inverses_addition(a: Money) -> None: assert a + (-a) == Money.zero(a.currency)
@given(a=_money(), b=_money())def test_subtraction_is_addition_of_negation(a: Money, b: Money) -> None: assert a - b == a + (-b)
@given( amount=_AMOUNTS, c1=_CURRENCIES, c2=_CURRENCIES,)def test_cross_currency_addition_always_raises_when_currencies_differ( amount: int, c1: str, c2: str) -> None: if c1 == c2: return # not the case under test with pytest.raises(CurrencyMismatchError): Money(amount, c1) + Money(amount, c2)- Step 4.2: Run tests, expect failures
.venv/bin/pytest tests/unit/domain/test_money_arithmetic.py -vExpected: AttributeError / TypeError because Money doesn’t yet support +, -, __neg__.
- Step 4.3: Implement arithmetic
Edit src/finance_bot/domain/money.py — add three dunder methods and one helper inside the Money class. Append AFTER the zero classmethod, BEFORE the closing of the class:
def __add__(self, other: Money) -> Money: self._check_same_currency(other) return Money(amount_minor=self.amount_minor + other.amount_minor, currency=self.currency)
def __sub__(self, other: Money) -> Money: self._check_same_currency(other) return Money(amount_minor=self.amount_minor - other.amount_minor, currency=self.currency)
def __neg__(self) -> Money: return Money(amount_minor=-self.amount_minor, currency=self.currency)
def _check_same_currency(self, other: Money) -> None: if self.currency != other.currency: from finance_bot.domain.errors import CurrencyMismatchError
raise CurrencyMismatchError( f"cannot operate on different currencies: {self.currency} vs {other.currency}" )The local import inside _check_same_currency is intentional — Money is referenced from errors.py test fixtures and this avoids any module-load-order risk. Keep the inline import.
- Step 4.4: Run all Money tests, expect green
.venv/bin/pytest tests/unit/domain/test_money.py tests/unit/domain/test_money_arithmetic.py -vExpected: 7 + 12 passed (test_money keeps 7; this file adds 6 explicit + 6 property-based = 12). Hypothesis prints “Trying example…” lines on first run as it generates examples.
- Step 4.5: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypyExpected: clean.
- Step 4.6: Commit
git add src/finance_bot/domain/money.py tests/unit/domain/test_money_arithmetic.pygit commit -m "$(cat <<'EOF'feat(domain): add Money arithmetic (+, -, unary -)
Operations enforce currency match and raise CurrencyMismatchError onmismatch. Inline import inside _check_same_currency keeps themodule-load order tolerant.
Tests:- 6 explicit examples (same-currency add/sub, sub-going-negative, negation, currency-mismatch on + and -).- 6 property-based via hypothesis: commutativity, associativity, zero identity, negation inverse, sub == add of neg, cross-currency always raises.
Property tests are the start of the property-based testing patternthat Plan 3 will lean on for the ledger-balance invariant.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 5: Money — comparison and sign helpers
Section titled “Task 5: Money — comparison and sign helpers”Files:
-
Modify:
src/finance_bot/domain/money.py -
Modify:
tests/unit/domain/test_money.py -
Step 5.1: Append failing tests to
tests/unit/domain/test_money.py
Add these tests at the END of tests/unit/domain/test_money.py:
def test_money_less_than() -> None: assert Money(50, "UAH") < Money(100, "UAH") assert not Money(100, "UAH") < Money(100, "UAH") assert not Money(100, "UAH") < Money(50, "UAH")
def test_money_less_than_or_equal() -> None: assert Money(50, "UAH") <= Money(100, "UAH") assert Money(100, "UAH") <= Money(100, "UAH") assert not Money(101, "UAH") <= Money(100, "UAH")
def test_money_greater_than_via_reflection() -> None: # functools.total_ordering would auto-derive these; we test # that python's reflected operators work via the __lt__/__le__ pair. assert Money(100, "UAH") > Money(50, "UAH") assert Money(100, "UAH") >= Money(100, "UAH")
def test_money_comparison_currency_mismatch_raises() -> None: from finance_bot.domain.errors import CurrencyMismatchError
with pytest.raises(CurrencyMismatchError): _ = Money(100, "UAH") < Money(100, "USD")
def test_sign_helpers() -> None: assert Money(1, "UAH").is_positive() assert not Money(0, "UAH").is_positive() assert not Money(-1, "UAH").is_positive()
assert Money(0, "UAH").is_zero() assert not Money(1, "UAH").is_zero()
assert Money(-1, "UAH").is_negative() assert not Money(0, "UAH").is_negative() assert not Money(1, "UAH").is_negative()- Step 5.2: Run test, expect failures
.venv/bin/pytest tests/unit/domain/test_money.py -vExpected: TypeError on <, AttributeError on .is_positive() etc.
- Step 5.3: Implement comparison + sign
Edit src/finance_bot/domain/money.py — at the top, change the dataclass decorator to add order=False (we want explicit dunders, not auto-derived). Then add methods AFTER __neg__:
def __lt__(self, other: Money) -> bool: self._check_same_currency(other) return self.amount_minor < other.amount_minor
def __le__(self, other: Money) -> bool: self._check_same_currency(other) return self.amount_minor <= other.amount_minor
def __gt__(self, other: Money) -> bool: self._check_same_currency(other) return self.amount_minor > other.amount_minor
def __ge__(self, other: Money) -> bool: self._check_same_currency(other) return self.amount_minor >= other.amount_minor
def is_positive(self) -> bool: return self.amount_minor > 0
def is_zero(self) -> bool: return self.amount_minor == 0
def is_negative(self) -> bool: return self.amount_minor < 0Note: dataclass order=False is the default; you don’t need to add it explicitly. We define __lt__/__le__/__gt__/__ge__ ourselves so the currency check fires for all four.
- Step 5.4: Run tests, expect green
.venv/bin/pytest tests/unit/domain/test_money.py tests/unit/domain/test_money_arithmetic.py -vExpected: all pass (7 + 5 new = 12 from test_money; 12 from arithmetic).
- Step 5.5: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypyExpected: clean.
- Step 5.6: Commit
git add src/finance_bot/domain/money.py tests/unit/domain/test_money.pygit commit -m "$(cat <<'EOF'feat(domain): add Money ordering and sign helpers
- __lt__, __le__, __gt__, __ge__ enforce currency match (so a check on '< Money(0, 'USD')' raises rather than silently returning False).- is_positive() / is_zero() / is_negative() — common predicates used by application code (overdraft check, transfer validation).
5 new unit tests bring the test_money.py file to 12.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 6: Money — Decimal conversion (boundary helpers)
Section titled “Task 6: Money — Decimal conversion (boundary helpers)”Files:
-
Modify:
src/finance_bot/domain/money.py -
Modify:
tests/unit/domain/test_money.py -
Step 6.1: Append failing tests
Add at the END of tests/unit/domain/test_money.py:
def test_money_from_decimal() -> None: from decimal import Decimal
assert Money.from_decimal(Decimal("250.00"), "UAH") == Money(25000, "UAH") assert Money.from_decimal(Decimal("0.01"), "UAH") == Money(1, "UAH") assert Money.from_decimal(Decimal("0"), "UAH") == Money(0, "UAH") assert Money.from_decimal(Decimal("-50.00"), "UAH") == Money(-5000, "UAH")
def test_money_from_decimal_rounds_half_even() -> None: """Banker's rounding (ROUND_HALF_EVEN) — the default for Decimal in fintech contexts. 0.005 with scale 2 rounds to 0 (towards even).""" from decimal import Decimal
# 0.005 -> 0.00 (rounds to even) assert Money.from_decimal(Decimal("0.005"), "UAH") == Money(0, "UAH") # 0.015 -> 0.02 (rounds to even) assert Money.from_decimal(Decimal("0.015"), "UAH") == Money(2, "UAH")
def test_money_to_decimal() -> None: from decimal import Decimal
assert Money(25000, "UAH").to_decimal() == Decimal("250.00") assert Money(1, "UAH").to_decimal() == Decimal("0.01") assert Money(-5000, "UAH").to_decimal() == Decimal("-50.00")
def test_money_decimal_round_trip() -> None: from decimal import Decimal
for amount in (0, 1, 99, 12345, -12345, 10**12): m = Money(amount, "UAH") assert Money.from_decimal(m.to_decimal(), "UAH") == m- Step 6.2: Run test, expect AttributeError
.venv/bin/pytest tests/unit/domain/test_money.py -vExpected: failures on the new tests because Money.from_decimal and Money.to_decimal don’t exist yet.
- Step 6.3: Implement Decimal helpers
Edit src/finance_bot/domain/money.py. Add import at the top:
from decimal import Decimal, ROUND_HALF_EVENThen add methods AFTER is_negative (last method in class body):
@classmethod def from_decimal(cls, amount: Decimal, currency: str) -> Money: """Convert a major-unit Decimal (e.g. 250.00 UAH) to Money.
Uses banker's rounding (ROUND_HALF_EVEN), the default for fintech accounting in Python's Decimal module. """ scaled = (amount * cls.SCALE).quantize(Decimal("1"), rounding=ROUND_HALF_EVEN) return cls(amount_minor=int(scaled), currency=currency)
def to_decimal(self) -> Decimal: """Convert this Money to a major-unit Decimal at the locked SCALE.""" return (Decimal(self.amount_minor) / Decimal(self.SCALE)).quantize(Decimal("0.01"))- Step 6.4: Run tests, expect green
.venv/bin/pytest tests/unit/domain/ -vExpected: all pass. test_money.py now has 16 tests; test_money_arithmetic.py has 12; test_enums.py 5; test_errors.py 14.
- Step 6.5: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypyExpected: clean.
- Step 6.6: Commit
git add src/finance_bot/domain/money.py tests/unit/domain/test_money.pygit commit -m "$(cat <<'EOF'feat(domain): add Money <-> Decimal boundary helpers
Money.from_decimal(major, currency) and Money.to_decimal():
- Uses Decimal at the conversion boundary only; internal storage remains int amount_minor, per ADR-0005.- Banker's rounding (ROUND_HALF_EVEN) on from_decimal — the standard for fintech accounting.- Quantizes both directions to SCALE digits.
4 new unit tests cover construction, rounding edge cases, the inversedirection, and full round-trip identity.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 7: User model
Section titled “Task 7: User model”Files:
-
Create:
src/finance_bot/domain/models.py -
Create:
tests/unit/domain/test_models.py -
Step 7.1: Write failing test
Create tests/unit/domain/test_models.py:
"""Tests for domain models (User this task; others added incrementally)."""from __future__ import annotations
from datetime import UTC, datetimefrom uuid import UUID
import pytest
from finance_bot.domain.models import User
def test_user_construction() -> None: u = User( id=UUID("11111111-1111-1111-1111-111111111111"), telegram_id=1190040571, display_name="zipsybok", default_currency="UAH", created_at=datetime(2026, 5, 2, 12, 0, tzinfo=UTC), ) assert u.telegram_id == 1190040571 assert u.default_currency == "UAH"
def test_user_is_frozen() -> None: u = User( id=UUID("11111111-1111-1111-1111-111111111111"), telegram_id=1, display_name="x", default_currency="UAH", created_at=datetime(2026, 5, 2, tzinfo=UTC), ) with pytest.raises(Exception): u.display_name = "y" # type: ignore[misc]
def test_user_equality() -> None: fields = { "id": UUID("11111111-1111-1111-1111-111111111111"), "telegram_id": 1, "display_name": "x", "default_currency": "UAH", "created_at": datetime(2026, 5, 2, tzinfo=UTC), } assert User(**fields) == User(**fields)- Step 7.2: Run test, expect ImportError
.venv/bin/pytest tests/unit/domain/test_models.py -v- Step 7.3: Implement User
Create src/finance_bot/domain/models.py:
"""Domain models — frozen dataclasses with slots.
Each model is a passive value type: no behavior, no I/O, no DBmapping. Adapters (Plan 3-4) translate to/from these types.Subsequent plan-2 tasks add Account, Category, Budget, Transaction,TransferMetadata, and PostedTransfer to this module."""from __future__ import annotations
from dataclasses import dataclassfrom datetime import datetimefrom uuid import UUID
@dataclass(frozen=True, slots=True)class User: """Telegram-id-whitelisted user."""
id: UUID telegram_id: int display_name: str default_currency: str created_at: datetime- Step 7.4: Run test, expect 3 passed
.venv/bin/pytest tests/unit/domain/test_models.py -v- Step 7.5: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypy- Step 7.6: Commit
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.pygit commit -m "$(cat <<'EOF'feat(domain): add User model (frozen dataclass)
First entry in domain/models.py. Other models (Account, Category,Budget, Transaction, TransferMetadata, PostedTransfer) follow inTasks 8-12 as separate commits so each can be reviewed in isolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 8: Account model
Section titled “Task 8: Account model”Files:
-
Modify:
src/finance_bot/domain/models.py -
Modify:
tests/unit/domain/test_models.py -
Step 8.1: Append failing tests
Add to tests/unit/domain/test_models.py:
from finance_bot.domain.enums import AccountTypefrom finance_bot.domain.models import Account
def _account(**overrides: object) -> Account: base: dict[str, object] = { "id": UUID("22222222-2222-2222-2222-222222222222"), "user_id": UUID("11111111-1111-1111-1111-111111111111"), "name": "Card", "currency": "UAH", "type": AccountType.CARD, "is_default": False, "is_archived": False, "min_balance_minor": None, "created_at": datetime(2026, 5, 2, tzinfo=UTC), } base.update(overrides) return Account(**base) # type: ignore[arg-type]
def test_account_construction_defaults() -> None: a = _account() assert a.is_default is False assert a.is_archived is False assert a.min_balance_minor is None assert a.type == AccountType.CARD
def test_account_with_min_balance() -> None: a = _account(min_balance_minor=0) assert a.min_balance_minor == 0
def test_account_external_types() -> None: a = _account(type=AccountType.EXTERNAL_EXPENSE) assert a.type is AccountType.EXTERNAL_EXPENSE
def test_account_is_frozen() -> None: a = _account() with pytest.raises(Exception): a.name = "Other" # type: ignore[misc]- Step 8.2: Run test, expect ImportError on Account
.venv/bin/pytest tests/unit/domain/test_models.py -v- Step 8.3: Implement Account
Edit src/finance_bot/domain/models.py. Add imports at top:
from finance_bot.domain.enums import AccountTypeAppend AFTER the User class:
@dataclass(frozen=True, slots=True)class Account: """A logical wallet — real (cash/card/deposit) or virtual (external_*)."""
id: UUID user_id: UUID name: str currency: str type: AccountType is_default: bool is_archived: bool min_balance_minor: int | None created_at: datetime- Step 8.4: Run tests, expect green
.venv/bin/pytest tests/unit/domain/test_models.py -vExpected: 3 (User) + 4 (Account) = 7 passed.
- Step 8.5: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypy- Step 8.6: Commit
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.pygit commit -m "$(cat <<'EOF'feat(domain): add Account model
Holds the user-facing identity of a wallet. Type is one of the fiveAccountType members (Task 1); external_expense and external_incomeare virtual accounts used by the ledger to express expenses/incomeas transfers.
min_balance_minor is None when overdraft is allowed (default), oran int when the account must not go below that amount — enforced onthe LedgerPort write side.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 9: Category model
Section titled “Task 9: Category model”Files:
-
Modify:
src/finance_bot/domain/models.py -
Modify:
tests/unit/domain/test_models.py -
Step 9.1: Append failing tests
Add to tests/unit/domain/test_models.py:
from finance_bot.domain.models import Category
def test_category_user_owned() -> None: c = Category( id=UUID("33333333-3333-3333-3333-333333333333"), user_id=UUID("11111111-1111-1111-1111-111111111111"), codename="cafe", display_name="кафе", aliases=("ресторан", "rest"), is_base_expense=True, ) assert c.user_id is not None assert "ресторан" in c.aliases
def test_category_system_seed() -> None: c = Category( id=UUID("33333333-3333-3333-3333-333333333333"), user_id=None, codename="other", display_name="прочее", aliases=(), is_base_expense=True, ) assert c.user_id is None assert c.aliases == ()
def test_category_aliases_immutable_tuple() -> None: """aliases is a tuple (not list) so the dataclass stays hashable and to make the inadvertent .append() impossible.""" c = Category( id=UUID("33333333-3333-3333-3333-333333333333"), user_id=None, codename="x", display_name="x", aliases=("a", "b"), is_base_expense=False, ) assert isinstance(c.aliases, tuple)-
Step 9.2: Run test, expect ImportError
-
Step 9.3: Implement Category
Append to src/finance_bot/domain/models.py:
@dataclass(frozen=True, slots=True)class Category: """Transaction classification.
user_id is None for system-seed categories shared across users; user_id is set for user-defined categories. """
id: UUID user_id: UUID | None codename: str display_name: str aliases: tuple[str, ...] is_base_expense: bool-
Step 9.4: Run tests, expect green (3 + 4 + 3 = 10).
-
Step 9.5: Lint + typecheck.
-
Step 9.6: Commit
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.pygit commit -m "$(cat <<'EOF'feat(domain): add Category model
user_id is None for system-seed categories (shared across users)and a UUID for user-defined ones. aliases is a tuple to keep thedataclass hashable and to forbid accidental mutation.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 10: Budget model
Section titled “Task 10: Budget model”Files:
-
Modify:
src/finance_bot/domain/models.py -
Modify:
tests/unit/domain/test_models.py -
Step 10.1: Append failing tests
Add to tests/unit/domain/test_models.py:
from datetime import date
from finance_bot.domain.models import Budgetfrom finance_bot.domain.money import Money
def test_budget_construction() -> None: b = Budget( id=UUID("44444444-4444-4444-4444-444444444444"), user_id=UUID("11111111-1111-1111-1111-111111111111"), category_id=UUID("33333333-3333-3333-3333-333333333333"), period="month", limit=Money(amount_minor=500_00, currency="UAH"), effective_from=date(2026, 5, 1), ) assert b.period == "month" assert b.limit == Money(50000, "UAH")
def test_budget_period_can_be_week() -> None: b = Budget( id=UUID("44444444-4444-4444-4444-444444444444"), user_id=UUID("11111111-1111-1111-1111-111111111111"), category_id=UUID("33333333-3333-3333-3333-333333333333"), period="week", limit=Money(100_00, "UAH"), effective_from=date(2026, 5, 1), ) assert b.period == "week"-
Step 10.2: Run test, expect ImportError on Budget
-
Step 10.3: Implement Budget
Edit src/finance_bot/domain/models.py. Add from datetime import date, datetime at the top (date is new). Then add the import:
from finance_bot.domain.money import MoneyAppend AFTER Category:
@dataclass(frozen=True, slots=True)class Budget: """Per-category spending limit for a period."""
id: UUID user_id: UUID category_id: UUID period: str # "month" | "week" — kept as Literal-shaped string for now limit: Money effective_from: date(Period is left as str rather than a Literal to keep the dataclass simple; validation lives in the application layer / repo when persisting. If a stricter type is wanted later it can become a BudgetPeriod StrEnum without touching consumers.)
-
Step 10.4: Run tests, expect green (10 + 2 = 12).
-
Step 10.5: Lint + typecheck.
-
Step 10.6: Commit
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.pygit commit -m "$(cat <<'EOF'feat(domain): add Budget model
Per-category limit tied to a calendar period (month/week). limitis a Money so currency consistency is enforced by the value object,not by ad-hoc int math.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 11: TransferMetadata + PostedTransfer
Section titled “Task 11: TransferMetadata + PostedTransfer”Files:
-
Modify:
src/finance_bot/domain/models.py -
Modify:
tests/unit/domain/test_models.py -
Step 11.1: Append failing tests
Add to tests/unit/domain/test_models.py:
from finance_bot.domain.enums import TransactionKindfrom finance_bot.domain.models import PostedTransfer, TransferMetadata
def _transfer_metadata(**overrides: object) -> TransferMetadata: base: dict[str, object] = { "category_id": UUID("33333333-3333-3333-3333-333333333333"), "kind": TransactionKind.EXPENSE, "raw_text": "250 кафе", "occurred_at": datetime(2026, 5, 2, 12, 0, tzinfo=UTC), } base.update(overrides) return TransferMetadata(**base) # type: ignore[arg-type]
def test_transfer_metadata_construction() -> None: md = _transfer_metadata() assert md.kind is TransactionKind.EXPENSE assert md.raw_text == "250 кафе"
def test_transfer_metadata_for_transfer_kind_has_no_category() -> None: md = _transfer_metadata(kind=TransactionKind.TRANSFER, category_id=None) assert md.category_id is None assert md.kind is TransactionKind.TRANSFER
def test_posted_transfer_construction() -> None: pt = PostedTransfer( id=UUID("55555555-5555-5555-5555-555555555555"), user_id=UUID("11111111-1111-1111-1111-111111111111"), debit_account_id=UUID("22222222-2222-2222-2222-222222222222"), credit_account_id=UUID("66666666-6666-6666-6666-666666666666"), amount=Money(25000, "UAH"), metadata=_transfer_metadata(), posted_at=datetime(2026, 5, 2, 12, 0, 1, tzinfo=UTC), voided_by_id=None, voided_at=None, compensates_id=None, ) assert pt.amount == Money(25000, "UAH") assert pt.voided_by_id is None assert pt.metadata.kind is TransactionKind.EXPENSE
def test_posted_transfer_voided() -> None: pt = PostedTransfer( id=UUID("55555555-5555-5555-5555-555555555555"), user_id=UUID("11111111-1111-1111-1111-111111111111"), debit_account_id=UUID("22222222-2222-2222-2222-222222222222"), credit_account_id=UUID("66666666-6666-6666-6666-666666666666"), amount=Money(25000, "UAH"), metadata=_transfer_metadata(), posted_at=datetime(2026, 5, 2, 12, 0, 1, tzinfo=UTC), voided_by_id=UUID("77777777-7777-7777-7777-777777777777"), voided_at=datetime(2026, 5, 2, 12, 5, tzinfo=UTC), compensates_id=None, ) assert pt.voided_by_id is not None assert pt.voided_at is not None-
Step 11.2: Run test, expect ImportError
-
Step 11.3: Implement
Append to src/finance_bot/domain/models.py:
from finance_bot.domain.enums import TransactionKind(top of file, with other imports)
After Budget:
@dataclass(frozen=True, slots=True)class TransferMetadata: """Opaque-to-the-ledger metadata about a transfer.
The ledger adapter stores these fields but does not interpret them. The application layer reads them for display and reporting. """
category_id: UUID | None kind: TransactionKind raw_text: str occurred_at: datetime
@dataclass(frozen=True, slots=True)class PostedTransfer: """The ledger's view of a successfully recorded transfer.
Returned by LedgerPort.post_transfer and LedgerPort.void. """
id: UUID user_id: UUID debit_account_id: UUID credit_account_id: UUID amount: Money metadata: TransferMetadata posted_at: datetime voided_by_id: UUID | None voided_at: datetime | None compensates_id: UUID | None-
Step 11.4: Run tests, expect green (12 + 4 = 16).
-
Step 11.5: Lint + typecheck.
-
Step 11.6: Commit
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.pygit commit -m "$(cat <<'EOF'feat(domain): add TransferMetadata and PostedTransfer
TransferMetadata is the opaque-to-the-ledger payload (category,kind, raw_text, occurred_at) that LedgerPort accepts onpost_transfer and stores without interpreting.
PostedTransfer is the ledger's response: includes void linkage(voided_by_id, voided_at) and compensation linkage(compensates_id), all nullable per design.md §5.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 12: Transaction (read-side projection)
Section titled “Task 12: Transaction (read-side projection)”Files:
-
Modify:
src/finance_bot/domain/models.py -
Modify:
tests/unit/domain/test_models.py -
Step 12.1: Append failing tests
Add to tests/unit/domain/test_models.py:
from finance_bot.domain.models import Transaction
def test_transaction_construction() -> None: t = Transaction( id=UUID("55555555-5555-5555-5555-555555555555"), user_id=UUID("11111111-1111-1111-1111-111111111111"), debit_account_id=UUID("22222222-2222-2222-2222-222222222222"), credit_account_id=UUID("66666666-6666-6666-6666-666666666666"), amount=Money(25000, "UAH"), kind=TransactionKind.EXPENSE, category_id=UUID("33333333-3333-3333-3333-333333333333"), raw_text="250 кафе", occurred_at=datetime(2026, 5, 2, 12, 0, tzinfo=UTC), posted_at=datetime(2026, 5, 2, 12, 0, 1, tzinfo=UTC), voided_by_id=None, voided_at=None, compensates_id=None, ) assert t.kind is TransactionKind.EXPENSE assert t.amount == Money(25000, "UAH")
def test_transaction_compensation_kind() -> None: t = Transaction( id=UUID("88888888-8888-8888-8888-888888888888"), user_id=UUID("11111111-1111-1111-1111-111111111111"), debit_account_id=UUID("66666666-6666-6666-6666-666666666666"), credit_account_id=UUID("22222222-2222-2222-2222-222222222222"), amount=Money(25000, "UAH"), kind=TransactionKind.COMPENSATION, category_id=None, raw_text="", occurred_at=datetime(2026, 5, 2, 12, 5, tzinfo=UTC), posted_at=datetime(2026, 5, 2, 12, 5, tzinfo=UTC), voided_by_id=None, voided_at=None, compensates_id=UUID("55555555-5555-5555-5555-555555555555"), ) assert t.compensates_id is not None assert t.kind is TransactionKind.COMPENSATION-
Step 12.2: Run test, expect ImportError
-
Step 12.3: Implement Transaction
Append to src/finance_bot/domain/models.py:
@dataclass(frozen=True, slots=True)class Transaction: """Read-side projection of a posted transfer with metadata flattened.
Returned by TransactionReadRepo. Has the same fields as PostedTransfer but with TransferMetadata expanded inline so the application layer doesn't need a second indirection for display. """
id: UUID user_id: UUID debit_account_id: UUID credit_account_id: UUID amount: Money kind: TransactionKind category_id: UUID | None raw_text: str occurred_at: datetime posted_at: datetime voided_by_id: UUID | None voided_at: datetime | None compensates_id: UUID | None-
Step 12.4: Run tests, expect green (16 + 2 = 18).
-
Step 12.5: Lint + typecheck.
-
Step 12.6: Commit
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.pygit commit -m "$(cat <<'EOF'feat(domain): add Transaction read-side model
Same shape as PostedTransfer but with TransferMetadata fields flatinline (kind/category_id/raw_text/occurred_at). This is what theTransactionReadRepo returns to application code that needs to renderor aggregate transactions.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 13: Clock Protocol
Section titled “Task 13: Clock Protocol”Files:
-
Create:
src/finance_bot/ports/clock.py -
Create:
tests/unit/ports/__init__.py -
Create:
tests/unit/ports/test_protocols.py -
Step 13.1: Create the test directory and
__init__.py
mkdir -p tests/unit/portstouch tests/unit/ports/__init__.py- Step 13.2: Write failing test
Create tests/unit/ports/test_protocols.py:
"""Structural tests for the port Protocols.
These tests don't construct any concrete adapter (those land in Plan 3+).They check that the Protocols are runtime-checkable and that fakes (whenthey arrive in Tasks 17-20) can be verified against the interface."""from __future__ import annotations
from datetime import datetimefrom typing import get_type_hints
from finance_bot.ports.clock import Clock
def test_clock_protocol_has_now() -> None: hints = get_type_hints(Clock.now) assert hints.get("return") is datetime-
Step 13.3: Run test, expect ImportError
-
Step 13.4: Implement Clock
Create src/finance_bot/ports/clock.py:
"""Clock port — domain-level abstraction over the wall clock.
Adapters provide concrete implementations (real datetime.now inproduction; FakeClock in tests). Application use-cases depend onlyon this Protocol so they can be tested deterministically."""from __future__ import annotations
from datetime import datetimefrom typing import Protocol, runtime_checkable
@runtime_checkableclass Clock(Protocol): """Yields the current time. ALWAYS timezone-aware."""
def now(self) -> datetime: """Return the current instant. Implementations MUST return a timezone-aware datetime (preferring UTC).""" ...-
Step 13.5: Run test, expect 1 passed
-
Step 13.6: Lint + typecheck.
-
Step 13.7: Commit
git add src/finance_bot/ports/clock.py tests/unit/ports/__init__.py tests/unit/ports/test_protocols.pygit commit -m "$(cat <<'EOF'feat(ports): add Clock Protocol
Runtime-checkable Protocol with a single now() -> datetime method.Documented expectation: timezone-aware datetimes only (UTC preferred).Real clock and FakeClock follow in Plan 3 and Task 17 respectively.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 14: LedgerPort Protocol
Section titled “Task 14: LedgerPort Protocol”Files:
-
Create:
src/finance_bot/ports/ledger.py -
Modify:
tests/unit/ports/test_protocols.py -
Step 14.1: Append failing tests
Add to tests/unit/ports/test_protocols.py:
from finance_bot.ports.ledger import LedgerPort
def test_ledger_port_has_required_methods() -> None: for method in ("open_account", "post_transfer", "void", "get_balance"): assert hasattr(LedgerPort, method), f"LedgerPort missing {method}"
def test_ledger_port_is_runtime_checkable() -> None: # `isinstance(obj, LedgerPort)` should work for objects that have the # required methods. Detailed conformance tests live with the fakes. class MinimalLedger: async def open_account(self, **kwargs: object) -> None: return None
async def post_transfer(self, **kwargs: object) -> object: return None
async def void(self, **kwargs: object) -> object: return None
async def get_balance(self, account_id: object) -> object: return None
assert isinstance(MinimalLedger(), LedgerPort)-
Step 14.2: Run, expect failure
-
Step 14.3: Implement LedgerPort
Create src/finance_bot/ports/ledger.py:
"""LedgerPort — the write-side abstraction over the bookkeeping store.
This is the boundary that ADR-0001 commits to keeping stable acrossthe Postgres -> TigerBeetle swap. Concrete adapters land in Plan 3(Postgres) and a future plan (TigerBeetle).
Read-side queries (lists, sums, balances over a period) live inTransactionReadRepo to keep this port narrow."""from __future__ import annotations
from typing import Protocol, runtime_checkablefrom uuid import UUID
from finance_bot.domain.models import PostedTransfer, TransferMetadatafrom finance_bot.domain.money import Money
@runtime_checkableclass LedgerPort(Protocol): """Atomic write operations for the double-entry ledger."""
async def open_account( self, *, account_id: UUID, user_id: UUID, currency: str, min_balance_minor: int | None = None, ) -> None: """Register a ledger account. Idempotent on account_id.""" ...
async def post_transfer( self, *, transfer_id: UUID, user_id: UUID, debit_account_id: UUID, credit_account_id: UUID, amount: Money, metadata: TransferMetadata, ) -> PostedTransfer: """Atomically debit one account and credit another.
Idempotent on transfer_id.
Raises: SameAccountError, AmountNotPositiveError, CurrencyMismatchError, AccountNotFoundError, MinBalanceViolationError. """ ...
async def void( self, *, transfer_id: UUID, compensating_id: UUID, reason: str, ) -> PostedTransfer: """Compensate a posted transfer with a reverse-direction transfer.
Idempotent: if the original is already voided, returns the existing compensating PostedTransfer.
Raises: TransferNotFoundError. """ ...
async def get_balance(self, account_id: UUID) -> Money: """Return the current balance of the account.""" ...-
Step 14.4: Run tests, expect green (3 ports tests now).
-
Step 14.5: Lint + typecheck.
-
Step 14.6: Commit
git add src/finance_bot/ports/ledger.py tests/unit/ports/test_protocols.pygit commit -m "$(cat <<'EOF'feat(ports): add LedgerPort Protocol (write-side ledger boundary)
Four methods — open_account, post_transfer, void, get_balance —designed to be swappable from Postgres to TigerBeetle withouttouching domain or application code (ADR-0001 + ADR-0006).
All operations are idempotent on client-generated UUIDv7 ids.Read-side aggregations live separately in TransactionReadRepo(Task 16) — CQRS-lite.
Documented exceptions reference the codes from domain.errors so theSRS Error Handling table maps directly to FR acceptance criteria.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 15: Write-side repository Protocols (User, Account, Category, Budget)
Section titled “Task 15: Write-side repository Protocols (User, Account, Category, Budget)”Files:
-
Create:
src/finance_bot/ports/repositories.py -
Modify:
tests/unit/ports/test_protocols.py -
Step 15.1: Append failing tests
Add to tests/unit/ports/test_protocols.py:
from finance_bot.ports.repositories import ( AccountRepo, BudgetRepo, CategoryRepo, UserRepo,)
def test_user_repo_methods() -> None: for method in ("get_by_telegram_id", "add"): assert hasattr(UserRepo, method)
def test_account_repo_methods() -> None: for method in ("get", "get_default", "find_by_alias", "list_by_user", "add"): assert hasattr(AccountRepo, method)
def test_category_repo_methods() -> None: for method in ("resolve_alias", "list_for_user", "add"): assert hasattr(CategoryRepo, method)
def test_budget_repo_methods() -> None: for method in ("list_for_user", "add"): assert hasattr(BudgetRepo, method)-
Step 15.2: Run, expect failures
-
Step 15.3: Implement write-side repos
Create src/finance_bot/ports/repositories.py:
"""Repository Protocols.
Five repos: UserRepo, AccountRepo, CategoryRepo, BudgetRepo (writeside and lookups; this task) and TransactionReadRepo (read-sideaggregations; Task 16, in this same file).
Plan 4 supplies the concrete Postgres adapters; in-memory fakes forunit tests are added in Tasks 19-20."""from __future__ import annotations
from typing import Protocol, runtime_checkablefrom uuid import UUID
from finance_bot.domain.models import Account, Budget, Category, User
@runtime_checkableclass UserRepo(Protocol): async def get_by_telegram_id(self, telegram_id: int) -> User | None: ...
async def add(self, user: User) -> None: ...
@runtime_checkableclass AccountRepo(Protocol): async def get(self, account_id: UUID) -> Account | None: ...
async def get_default(self, user_id: UUID) -> Account | None: ...
async def find_by_alias(self, user_id: UUID, alias: str) -> Account | None: ...
async def list_by_user( self, user_id: UUID, *, include_archived: bool = False ) -> list[Account]: ...
async def add(self, account: Account) -> None: ...
@runtime_checkableclass CategoryRepo(Protocol): async def resolve_alias(self, user_id: UUID, alias: str) -> Category | None: ...
async def list_for_user(self, user_id: UUID) -> list[Category]: ...
async def add(self, category: Category) -> None: ...
@runtime_checkableclass BudgetRepo(Protocol): async def list_for_user(self, user_id: UUID) -> list[Budget]: ...
async def add(self, budget: Budget) -> None: ...-
Step 15.4: Run tests, expect green (3 + 4 = 7 ports tests).
-
Step 15.5: Lint + typecheck.
-
Step 15.6: Commit
git add src/finance_bot/ports/repositories.py tests/unit/ports/test_protocols.pygit commit -m "$(cat <<'EOF'feat(ports): add UserRepo, AccountRepo, CategoryRepo, BudgetRepo
Write-side and lookup Protocols for the four entity repos.Method shapes are deliberately minimal — only what Plan 5 use-casesactually need (resolve_alias, get_default, find_by_alias, etc.) —to keep adapter implementations small.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 16: TransactionReadRepo Protocol (read-side / CQRS-lite)
Section titled “Task 16: TransactionReadRepo Protocol (read-side / CQRS-lite)”Files:
-
Modify:
src/finance_bot/ports/repositories.py -
Modify:
tests/unit/ports/test_protocols.py -
Step 16.1: Append failing tests
Add to tests/unit/ports/test_protocols.py:
from finance_bot.ports.repositories import TransactionReadRepo
def test_transaction_read_repo_methods() -> None: for method in ("list_recent", "sum_by_period", "sum_by_category"): assert hasattr(TransactionReadRepo, method)-
Step 16.2: Run, expect failure
-
Step 16.3: Implement TransactionReadRepo
Edit src/finance_bot/ports/repositories.py. Add imports near the top (with the other model imports):
from datetime import datetime
from finance_bot.domain.enums import TransactionKindfrom finance_bot.domain.models import Account, Budget, Category, Transaction, Userfrom finance_bot.domain.money import MoneyAppend at the END of the file:
@runtime_checkableclass TransactionReadRepo(Protocol): """Read-side aggregations and listings.
Stays on Postgres forever (TigerBeetle does not do aggregations). """
async def list_recent( self, user_id: UUID, limit: int = 10 ) -> list[Transaction]: ...
async def sum_by_period( self, user_id: UUID, kind: TransactionKind, period_start: datetime, period_end: datetime, category_id: UUID | None = None, ) -> Money: ...
async def sum_by_category( self, user_id: UUID, period_start: datetime, period_end: datetime, ) -> dict[UUID, Money]: ...-
Step 16.4: Run tests, expect green (7 + 1 = 8 ports tests).
-
Step 16.5: Lint + typecheck.
-
Step 16.6: Commit
git add src/finance_bot/ports/repositories.py tests/unit/ports/test_protocols.pygit commit -m "$(cat <<'EOF'feat(ports): add TransactionReadRepo Protocol
Read-side aggregations split out from LedgerPort (CQRS-lite, design§6.2). Three queries:
- list_recent — for /expenses- sum_by_period — for /today, /month, budget checks- sum_by_category — for category breakdown reports
Stays on Postgres forever, including after the TigerBeetle swap(TigerBeetle has no aggregation surface).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 17: FakeClock + tests
Section titled “Task 17: FakeClock + tests”Files:
-
Create:
tests/_fakes/__init__.py -
Create:
tests/_fakes/clock.py -
Create:
tests/_fakes/test_clock.py -
Step 17.1: Create the package
mkdir -p tests/_fakestouch tests/_fakes/__init__.py- Step 17.2: Write failing tests
Create tests/_fakes/test_clock.py:
"""Contract tests for FakeClock."""from __future__ import annotations
from datetime import UTC, datetime, timedelta
from finance_bot.ports.clock import Clock
from tests._fakes.clock import FakeClock
def test_fake_clock_satisfies_protocol() -> None: fc = FakeClock(now=datetime(2026, 5, 2, tzinfo=UTC)) assert isinstance(fc, Clock)
def test_fake_clock_now_returns_set_value() -> None: fc = FakeClock(now=datetime(2026, 5, 2, 12, 0, tzinfo=UTC)) assert fc.now() == datetime(2026, 5, 2, 12, 0, tzinfo=UTC)
def test_fake_clock_advance() -> None: fc = FakeClock(now=datetime(2026, 5, 2, 12, 0, tzinfo=UTC)) fc.advance(timedelta(minutes=30)) assert fc.now() == datetime(2026, 5, 2, 12, 30, tzinfo=UTC)
def test_fake_clock_set() -> None: fc = FakeClock(now=datetime(2026, 5, 2, 12, 0, tzinfo=UTC)) fc.set(datetime(2026, 5, 3, 0, 0, tzinfo=UTC)) assert fc.now() == datetime(2026, 5, 3, 0, 0, tzinfo=UTC)- Step 17.3: Run, expect failures
.venv/bin/pytest tests/_fakes/test_clock.py -v- Step 17.4: Implement FakeClock
Create tests/_fakes/clock.py:
"""In-memory FakeClock used by unit tests in Plan 5+ to make timedeterministic in use-case behavior."""from __future__ import annotations
from datetime import datetime, timedelta
class FakeClock: """A controllable clock for tests. Satisfies finance_bot.ports.clock.Clock."""
def __init__(self, *, now: datetime) -> None: if now.tzinfo is None: raise ValueError("FakeClock requires a timezone-aware datetime") self._now = now
def now(self) -> datetime: return self._now
def advance(self, delta: timedelta) -> None: """Move the clock forward by delta.""" self._now = self._now + delta
def set(self, instant: datetime) -> None: """Pin the clock to a specific instant.""" if instant.tzinfo is None: raise ValueError("FakeClock.set requires a timezone-aware datetime") self._now = instant-
Step 17.5: Run, expect 4 passed
-
Step 17.6: Lint + typecheck
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypyNote: tests/_fakes/clock.py is collected by mypy via [tool.mypy] files = ["src", "tests"]. It MUST pass mypy. The Clock protocol is the contract — FakeClock has now() returning datetime so the runtime-checkable isinstance check succeeds.
- Step 17.7: Commit
git add tests/_fakes/__init__.py tests/_fakes/clock.py tests/_fakes/test_clock.pygit commit -m "$(cat <<'EOF'feat(tests): add FakeClock + contract tests
In-memory clock for use-case unit tests in Plan 5. Constructed witha tz-aware now; supports advance(timedelta) and set(instant).Refuses naive datetimes at construction and on set() to enforce theClock protocol's tz-aware contract.
4 contract tests: protocol satisfaction (isinstance), now() return,advance, set.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 18: FakeLedger + property-based invariant tests
Section titled “Task 18: FakeLedger + property-based invariant tests”Files:
-
Create:
tests/_fakes/ledger.py -
Create:
tests/_fakes/test_ledger.py -
Step 18.1: Write failing tests
Create tests/_fakes/test_ledger.py:
"""Contract + invariant tests for FakeLedger."""from __future__ import annotations
from datetime import UTC, datetimefrom uuid import UUID
import pytestfrom hypothesis import HealthCheck, given, settingsfrom hypothesis import strategies as st
from finance_bot.domain.enums import TransactionKindfrom finance_bot.domain.errors import ( AccountNotFoundError, AlreadyVoidedError, AmountNotPositiveError, CurrencyMismatchError, MinBalanceViolationError, SameAccountError, TransferNotFoundError,)from finance_bot.domain.models import TransferMetadatafrom finance_bot.domain.money import Moneyfrom finance_bot.ports.ledger import LedgerPort
from tests._fakes.ledger import FakeLedger
USER = UUID("11111111-1111-1111-1111-111111111111")ACC_A = UUID("22222222-2222-2222-2222-222222222222")ACC_B = UUID("66666666-6666-6666-6666-666666666666")
def _md(kind: TransactionKind = TransactionKind.TRANSFER) -> TransferMetadata: return TransferMetadata( category_id=None, kind=kind, raw_text="", occurred_at=datetime(2026, 5, 2, tzinfo=UTC), )
@pytest.fixturedef ledger() -> FakeLedger: fl = FakeLedger() return fl
@pytest.mark.asyncioasync def test_satisfies_protocol(ledger: FakeLedger) -> None: assert isinstance(ledger, LedgerPort)
@pytest.mark.asyncioasync def test_open_and_get_balance(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") assert await ledger.get_balance(ACC_A) == Money.zero("UAH")
@pytest.mark.asyncioasync def test_post_transfer_updates_balances(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") await ledger.open_account(account_id=ACC_B, user_id=USER, currency="UAH")
transfer_id = UUID("55555555-5555-5555-5555-555555555555") await ledger.post_transfer( transfer_id=transfer_id, user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, amount=Money(100, "UAH"), metadata=_md(), )
assert await ledger.get_balance(ACC_A) == Money(-100, "UAH") assert await ledger.get_balance(ACC_B) == Money(100, "UAH")
@pytest.mark.asyncioasync def test_post_transfer_idempotent_on_id(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") await ledger.open_account(account_id=ACC_B, user_id=USER, currency="UAH") transfer_id = UUID("55555555-5555-5555-5555-555555555555")
p1 = await ledger.post_transfer( transfer_id=transfer_id, user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, amount=Money(100, "UAH"), metadata=_md(), ) p2 = await ledger.post_transfer( transfer_id=transfer_id, user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, amount=Money(100, "UAH"), metadata=_md(), )
assert p1 == p2 # Balance unchanged after the duplicate assert await ledger.get_balance(ACC_A) == Money(-100, "UAH")
@pytest.mark.asyncioasync def test_same_account_raises(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") with pytest.raises(SameAccountError): await ledger.post_transfer( transfer_id=UUID("55555555-5555-5555-5555-555555555555"), user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_A, amount=Money(100, "UAH"), metadata=_md(), )
@pytest.mark.asyncioasync def test_amount_not_positive_raises(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") await ledger.open_account(account_id=ACC_B, user_id=USER, currency="UAH") with pytest.raises(AmountNotPositiveError): await ledger.post_transfer( transfer_id=UUID("55555555-5555-5555-5555-555555555555"), user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, amount=Money(0, "UAH"), metadata=_md(), )
@pytest.mark.asyncioasync def test_currency_mismatch_raises(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") await ledger.open_account(account_id=ACC_B, user_id=USER, currency="USD") with pytest.raises(CurrencyMismatchError): await ledger.post_transfer( transfer_id=UUID("55555555-5555-5555-5555-555555555555"), user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, amount=Money(100, "UAH"), metadata=_md(), )
@pytest.mark.asyncioasync def test_account_not_found_raises(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") with pytest.raises(AccountNotFoundError): await ledger.post_transfer( transfer_id=UUID("55555555-5555-5555-5555-555555555555"), user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, # not opened amount=Money(100, "UAH"), metadata=_md(), )
@pytest.mark.asyncioasync def test_min_balance_violation(ledger: FakeLedger) -> None: await ledger.open_account( account_id=ACC_A, user_id=USER, currency="UAH", min_balance_minor=0 ) await ledger.open_account(account_id=ACC_B, user_id=USER, currency="UAH") with pytest.raises(MinBalanceViolationError): await ledger.post_transfer( transfer_id=UUID("55555555-5555-5555-5555-555555555555"), user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, amount=Money(1, "UAH"), metadata=_md(), )
@pytest.mark.asyncioasync def test_void_creates_compensating_transfer(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") await ledger.open_account(account_id=ACC_B, user_id=USER, currency="UAH") original_id = UUID("55555555-5555-5555-5555-555555555555") await ledger.post_transfer( transfer_id=original_id, user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, amount=Money(100, "UAH"), metadata=_md(), )
comp_id = UUID("77777777-7777-7777-7777-777777777777") comp = await ledger.void( transfer_id=original_id, compensating_id=comp_id, reason="user request" )
assert comp.compensates_id == original_id # Balances back to zero assert await ledger.get_balance(ACC_A) == Money.zero("UAH") assert await ledger.get_balance(ACC_B) == Money.zero("UAH")
@pytest.mark.asyncioasync def test_void_idempotent(ledger: FakeLedger) -> None: await ledger.open_account(account_id=ACC_A, user_id=USER, currency="UAH") await ledger.open_account(account_id=ACC_B, user_id=USER, currency="UAH") original_id = UUID("55555555-5555-5555-5555-555555555555") await ledger.post_transfer( transfer_id=original_id, user_id=USER, debit_account_id=ACC_A, credit_account_id=ACC_B, amount=Money(100, "UAH"), metadata=_md(), )
comp_id = UUID("77777777-7777-7777-7777-777777777777") p1 = await ledger.void( transfer_id=original_id, compensating_id=comp_id, reason="x" ) p2 = await ledger.void( transfer_id=original_id, compensating_id=UUID("99999999-9999-9999-9999-999999999999"), reason="x", )
assert p1 == p2 # second call returns the existing compensation
@pytest.mark.asyncioasync def test_void_unknown_transfer_raises(ledger: FakeLedger) -> None: with pytest.raises(TransferNotFoundError): await ledger.void( transfer_id=UUID("55555555-5555-5555-5555-555555555555"), compensating_id=UUID("77777777-7777-7777-7777-777777777777"), reason="x", )
# Property-based invariant — the linchpin
_AMOUNTS = st.integers(min_value=1, max_value=10**9)
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture], max_examples=50)@given(amounts=st.lists(_AMOUNTS, min_size=1, max_size=20))@pytest.mark.asyncioasync def test_balance_invariant_under_random_transfers( ledger: FakeLedger, amounts: list[int]) -> None: """For any sequence of transfers between two accounts, the cached balances always equal the sum of credits minus the sum of debits.
This invariant carries over to PostgresLedgerAdapter (Plan 3) — the PG trigger maintains the same balance cache. If THIS fake passes the invariant, the contract is well-formed; if Plan 3's adapter fails the same property test, the bug is in PG, not the contract. """ a, b = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), UUID( "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" ) await ledger.open_account(account_id=a, user_id=USER, currency="UAH") await ledger.open_account(account_id=b, user_id=USER, currency="UAH")
expected_a = 0 expected_b = 0 for i, amt in enumerate(amounts): # alternate direction if i % 2 == 0: await ledger.post_transfer( transfer_id=UUID(int=10**6 + i), user_id=USER, debit_account_id=a, credit_account_id=b, amount=Money(amt, "UAH"), metadata=_md(), ) expected_a -= amt expected_b += amt else: await ledger.post_transfer( transfer_id=UUID(int=10**6 + i), user_id=USER, debit_account_id=b, credit_account_id=a, amount=Money(amt, "UAH"), metadata=_md(), ) expected_a += amt expected_b -= amt
assert await ledger.get_balance(a) == Money(expected_a, "UAH") assert await ledger.get_balance(b) == Money(expected_b, "UAH")-
Step 18.2: Run, expect ImportError
-
Step 18.3: Implement FakeLedger
Create tests/_fakes/ledger.py:
"""In-memory FakeLedger.
Contract: enforces every invariant LedgerPort documents (same-account,positive amount, currency match, min_balance, account-existence,idempotent post and void). Used to unit-test application use-cases inPlan 5 without touching Postgres.
Plan 3's PostgresLedgerAdapter must satisfy the same contract; theproperty-based invariant test in test_ledger.py is the canonicalcontract assertion."""from __future__ import annotations
from dataclasses import dataclass, fieldfrom datetime import UTC, datetimefrom uuid import UUID
from finance_bot.domain.errors import ( AccountNotFoundError, AlreadyVoidedError, AmountNotPositiveError, CurrencyMismatchError, MinBalanceViolationError, SameAccountError, TransferNotFoundError,)from finance_bot.domain.models import PostedTransfer, TransferMetadatafrom finance_bot.domain.money import Money
@dataclassclass _AccountState: user_id: UUID currency: str min_balance_minor: int | None balance_minor: int = 0
class FakeLedger: """In-memory ledger satisfying LedgerPort. Not threadsafe; tests are single-event-loop."""
def __init__(self) -> None: self._accounts: dict[UUID, _AccountState] = {} self._transfers: dict[UUID, PostedTransfer] = {}
async def open_account( self, *, account_id: UUID, user_id: UUID, currency: str, min_balance_minor: int | None = None, ) -> None: # Idempotent: re-opening with same id is a no-op if account_id in self._accounts: return self._accounts[account_id] = _AccountState( user_id=user_id, currency=currency, min_balance_minor=min_balance_minor, )
async def post_transfer( self, *, transfer_id: UUID, user_id: UUID, debit_account_id: UUID, credit_account_id: UUID, amount: Money, metadata: TransferMetadata, ) -> PostedTransfer: # Idempotent on transfer_id if transfer_id in self._transfers: return self._transfers[transfer_id]
if debit_account_id == credit_account_id: raise SameAccountError("debit and credit accounts must differ") if not amount.is_positive(): raise AmountNotPositiveError(f"amount must be > 0, got {amount.amount_minor}")
debit = self._accounts.get(debit_account_id) credit = self._accounts.get(credit_account_id) if debit is None or credit is None: raise AccountNotFoundError("debit or credit account is not opened")
if debit.currency != amount.currency or credit.currency != amount.currency: raise CurrencyMismatchError( "transfer currency must equal both accounts' currencies" )
# Min-balance check on debit new_debit_balance = debit.balance_minor - amount.amount_minor if ( debit.min_balance_minor is not None and new_debit_balance < debit.min_balance_minor ): raise MinBalanceViolationError( f"debit account would fall below min_balance " f"({new_debit_balance} < {debit.min_balance_minor})" )
debit.balance_minor = new_debit_balance credit.balance_minor = credit.balance_minor + amount.amount_minor
posted = PostedTransfer( id=transfer_id, user_id=user_id, debit_account_id=debit_account_id, credit_account_id=credit_account_id, amount=amount, metadata=metadata, posted_at=datetime.now(UTC), voided_by_id=None, voided_at=None, compensates_id=None, ) self._transfers[transfer_id] = posted return posted
async def void( self, *, transfer_id: UUID, compensating_id: UUID, reason: str, ) -> PostedTransfer: original = self._transfers.get(transfer_id) if original is None: raise TransferNotFoundError(f"no transfer with id={transfer_id}")
# Idempotent: if already voided, return the existing compensation if original.voided_by_id is not None: existing = self._transfers[original.voided_by_id] return existing
# Otherwise, build the compensating transfer with reversed # debit/credit and the same amount; bypass the public # post_transfer to avoid same-id collision and to allow it even # when the source has min_balance constraints (compensation # restores the prior state). compensating = PostedTransfer( id=compensating_id, user_id=original.user_id, debit_account_id=original.credit_account_id, credit_account_id=original.debit_account_id, amount=original.amount, metadata=TransferMetadata( category_id=original.metadata.category_id, kind=original.metadata.kind, raw_text=f"void: {reason}", occurred_at=datetime.now(UTC), ), posted_at=datetime.now(UTC), voided_by_id=None, voided_at=None, compensates_id=transfer_id, )
# Apply balance changes (reverse direction) debit = self._accounts[original.credit_account_id] credit = self._accounts[original.debit_account_id] debit.balance_minor -= original.amount.amount_minor credit.balance_minor += original.amount.amount_minor
self._transfers[compensating_id] = compensating
# Mark original as voided. Replace the dict entry with an updated # frozen-dataclass copy. self._transfers[transfer_id] = PostedTransfer( id=original.id, user_id=original.user_id, debit_account_id=original.debit_account_id, credit_account_id=original.credit_account_id, amount=original.amount, metadata=original.metadata, posted_at=original.posted_at, voided_by_id=compensating_id, voided_at=datetime.now(UTC), compensates_id=None, ) return compensating
async def get_balance(self, account_id: UUID) -> Money: state = self._accounts.get(account_id) if state is None: raise AccountNotFoundError(f"no account with id={account_id}") return Money(amount_minor=state.balance_minor, currency=state.currency)- Step 18.4: Run, expect 12 + 1 property-based passed
.venv/bin/pytest tests/_fakes/test_ledger.py -v- Step 18.5: Lint + typecheck
AlreadyVoidedError is imported in the test file but not used (the FakeLedger silently returns the existing compensation rather than raising). ruff will flag it. Remove the unused import from tests/_fakes/test_ledger.py.
.venv/bin/ruff check.venv/bin/ruff format.venv/bin/mypyIf ruff flags F401 for AlreadyVoidedError, delete the import line. Re-run.
- Step 18.6: Commit
git add tests/_fakes/ledger.py tests/_fakes/test_ledger.pygit commit -m "$(cat <<'EOF'feat(tests): add FakeLedger + invariant tests
In-memory LedgerPort implementation enforcing every invariant thecontract documents:- idempotent post_transfer + void on client-side ids- SameAccountError, AmountNotPositiveError, CurrencyMismatchError, AccountNotFoundError, MinBalanceViolationError on the write path- TransferNotFoundError on void of unknown id- void() returns the existing compensation if already voided
12 explicit contract tests + 1 property-based test asserting thatbalance_minor == sum(credits) - sum(debits) for any random sequenceof transfers. The property test is the contract assertion thatPostgresLedgerAdapter (Plan 3) must also satisfy.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 19: Write-side fakes (User, Account, Category, Budget)
Section titled “Task 19: Write-side fakes (User, Account, Category, Budget)”Files:
-
Create:
tests/_fakes/repositories.py -
Create:
tests/_fakes/test_repositories.py -
Step 19.1: Write failing tests
Create tests/_fakes/test_repositories.py:
"""Contract tests for write-side repository fakes."""from __future__ import annotations
from datetime import UTC, date, datetimefrom uuid import UUID
import pytest
from finance_bot.domain.enums import AccountTypefrom finance_bot.domain.models import Account, Budget, Category, Userfrom finance_bot.domain.money import Moneyfrom finance_bot.ports.repositories import ( AccountRepo, BudgetRepo, CategoryRepo, UserRepo,)
from tests._fakes.repositories import ( FakeAccountRepo, FakeBudgetRepo, FakeCategoryRepo, FakeUserRepo,)
USER_ID = UUID("11111111-1111-1111-1111-111111111111")
# UserRepo
@pytest.mark.asyncioasync def test_fake_user_repo_satisfies_protocol() -> None: assert isinstance(FakeUserRepo(), UserRepo)
@pytest.mark.asyncioasync def test_fake_user_repo_add_and_get() -> None: repo = FakeUserRepo() u = User( id=USER_ID, telegram_id=42, display_name="x", default_currency="UAH", created_at=datetime(2026, 5, 2, tzinfo=UTC), ) await repo.add(u) assert await repo.get_by_telegram_id(42) == u assert await repo.get_by_telegram_id(999) is None
# AccountRepo
def _account( aid: UUID, *, name: str = "Card", is_default: bool = False, archived: bool = False) -> Account: return Account( id=aid, user_id=USER_ID, name=name, currency="UAH", type=AccountType.CARD, is_default=is_default, is_archived=archived, min_balance_minor=None, created_at=datetime(2026, 5, 2, tzinfo=UTC), )
@pytest.mark.asyncioasync def test_fake_account_repo_satisfies_protocol() -> None: assert isinstance(FakeAccountRepo(), AccountRepo)
@pytest.mark.asyncioasync def test_fake_account_repo_get_default() -> None: repo = FakeAccountRepo() a1 = _account(UUID("22222222-2222-2222-2222-222222222222"), is_default=True) a2 = _account(UUID("33333333-3333-3333-3333-333333333333"), name="Cash") await repo.add(a1) await repo.add(a2) assert await repo.get_default(USER_ID) == a1
@pytest.mark.asyncioasync def test_fake_account_repo_find_by_alias() -> None: repo = FakeAccountRepo() a1 = _account(UUID("22222222-2222-2222-2222-222222222222"), name="Card") a2 = _account(UUID("33333333-3333-3333-3333-333333333333"), name="Cash") await repo.add(a1) await repo.add(a2) # case-insensitive name match assert await repo.find_by_alias(USER_ID, "card") == a1 assert await repo.find_by_alias(USER_ID, "CASH") == a2 assert await repo.find_by_alias(USER_ID, "deposit") is None
@pytest.mark.asyncioasync def test_fake_account_repo_list_excludes_archived_by_default() -> None: repo = FakeAccountRepo() live = _account(UUID("22222222-2222-2222-2222-222222222222")) arch = _account(UUID("33333333-3333-3333-3333-333333333333"), archived=True) await repo.add(live) await repo.add(arch) assert await repo.list_by_user(USER_ID) == [live] assert set( await repo.list_by_user(USER_ID, include_archived=True) ) == {live, arch}
# CategoryRepo
def _category(name: str, aliases: tuple[str, ...] = ()) -> Category: return Category( id=UUID(int=hash(name) & ((1 << 128) - 1)), user_id=USER_ID, codename=name, display_name=name, aliases=aliases, is_base_expense=True, )
@pytest.mark.asyncioasync def test_fake_category_repo_satisfies_protocol() -> None: assert isinstance(FakeCategoryRepo(), CategoryRepo)
@pytest.mark.asyncioasync def test_fake_category_repo_resolve_alias() -> None: repo = FakeCategoryRepo() cafe = _category("cafe", aliases=("кафе", "ресторан")) coffee = _category("coffee", aliases=("кофе",)) await repo.add(cafe) await repo.add(coffee)
assert await repo.resolve_alias(USER_ID, "кафе") == cafe # codename matches too assert await repo.resolve_alias(USER_ID, "coffee") == coffee # case-insensitive assert await repo.resolve_alias(USER_ID, "КАФЕ") == cafe # not found assert await repo.resolve_alias(USER_ID, "transport") is None
# BudgetRepo
@pytest.mark.asyncioasync def test_fake_budget_repo_satisfies_protocol() -> None: assert isinstance(FakeBudgetRepo(), BudgetRepo)
@pytest.mark.asyncioasync def test_fake_budget_repo_add_and_list() -> None: repo = FakeBudgetRepo() b = Budget( id=UUID("44444444-4444-4444-4444-444444444444"), user_id=USER_ID, category_id=UUID("33333333-3333-3333-3333-333333333333"), period="month", limit=Money(50000, "UAH"), effective_from=date(2026, 5, 1), ) await repo.add(b) assert await repo.list_for_user(USER_ID) == [b]-
Step 19.2: Run, expect ImportError
-
Step 19.3: Implement write-side fakes
Create tests/_fakes/repositories.py:
"""In-memory fakes for the write-side and read-side repositoryProtocols. Plan 5 use-cases use these for unit tests.
The TransactionReadRepo fake lives in this same module (Task 20)to keep all repo fakes together."""from __future__ import annotations
from uuid import UUID
from finance_bot.domain.models import Account, Budget, Category, User
class FakeUserRepo: """Satisfies UserRepo."""
def __init__(self) -> None: self._by_telegram: dict[int, User] = {}
async def get_by_telegram_id(self, telegram_id: int) -> User | None: return self._by_telegram.get(telegram_id)
async def add(self, user: User) -> None: self._by_telegram[user.telegram_id] = user
class FakeAccountRepo: """Satisfies AccountRepo. find_by_alias is case-insensitive on name."""
def __init__(self) -> None: self._accounts: dict[UUID, Account] = {}
async def get(self, account_id: UUID) -> Account | None: return self._accounts.get(account_id)
async def get_default(self, user_id: UUID) -> Account | None: for a in self._accounts.values(): if a.user_id == user_id and a.is_default and not a.is_archived: return a return None
async def find_by_alias(self, user_id: UUID, alias: str) -> Account | None: lowered = alias.lower() for a in self._accounts.values(): if a.user_id == user_id and not a.is_archived and a.name.lower() == lowered: return a return None
async def list_by_user( self, user_id: UUID, *, include_archived: bool = False ) -> list[Account]: return [ a for a in self._accounts.values() if a.user_id == user_id and (include_archived or not a.is_archived) ]
async def add(self, account: Account) -> None: self._accounts[account.id] = account
class FakeCategoryRepo: """Satisfies CategoryRepo. resolve_alias matches against codename, display_name, and aliases — case-insensitively."""
def __init__(self) -> None: self._categories: dict[UUID, Category] = {}
async def resolve_alias(self, user_id: UUID, alias: str) -> Category | None: lowered = alias.lower() for c in self._categories.values(): if c.user_id is not None and c.user_id != user_id: continue # other user's custom category if c.codename.lower() == lowered: return c if c.display_name.lower() == lowered: return c for a in c.aliases: if a.lower() == lowered: return c return None
async def list_for_user(self, user_id: UUID) -> list[Category]: return [ c for c in self._categories.values() if c.user_id is None or c.user_id == user_id ]
async def add(self, category: Category) -> None: self._categories[category.id] = category
class FakeBudgetRepo: """Satisfies BudgetRepo."""
def __init__(self) -> None: self._budgets: dict[UUID, Budget] = {}
async def list_for_user(self, user_id: UUID) -> list[Budget]: return [b for b in self._budgets.values() if b.user_id == user_id]
async def add(self, budget: Budget) -> None: self._budgets[budget.id] = budget- Step 19.4: Run tests, expect ~10 passed (depends on parametrize)
.venv/bin/pytest tests/_fakes/test_repositories.py -v-
Step 19.5: Lint + typecheck.
-
Step 19.6: Commit
git add tests/_fakes/repositories.py tests/_fakes/test_repositories.pygit commit -m "$(cat <<'EOF'feat(tests): add write-side repository fakes
FakeUserRepo, FakeAccountRepo, FakeCategoryRepo, FakeBudgetRepo —each satisfies its Protocol (asserted via isinstance) and matchesthe lookup semantics application code will rely on:
- AccountRepo.get_default ignores archived accounts.- AccountRepo.find_by_alias is case-insensitive on Account.name.- CategoryRepo.resolve_alias matches codename, display_name, or any alias entry (case-insensitive); respects user-vs-system scoping.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 20: FakeTransactionReadRepo
Section titled “Task 20: FakeTransactionReadRepo”Files:
-
Modify:
tests/_fakes/repositories.py -
Modify:
tests/_fakes/test_repositories.py -
Step 20.1: Append failing tests
Add to tests/_fakes/test_repositories.py:
from finance_bot.domain.enums import TransactionKindfrom finance_bot.domain.models import Transactionfrom finance_bot.ports.repositories import TransactionReadRepo
from tests._fakes.repositories import FakeTransactionReadRepo
def _transaction( *, id_: UUID, amount: int, kind: TransactionKind, posted_at: datetime, category_id: UUID | None,) -> Transaction: return Transaction( id=id_, user_id=USER_ID, debit_account_id=UUID("22222222-2222-2222-2222-222222222222"), credit_account_id=UUID("66666666-6666-6666-6666-666666666666"), amount=Money(amount, "UAH"), kind=kind, category_id=category_id, raw_text="", occurred_at=posted_at, posted_at=posted_at, voided_by_id=None, voided_at=None, compensates_id=None, )
@pytest.mark.asyncioasync def test_fake_read_repo_satisfies_protocol() -> None: assert isinstance(FakeTransactionReadRepo(), TransactionReadRepo)
@pytest.mark.asyncioasync def test_fake_read_repo_list_recent_orders_by_posted_at_desc() -> None: repo = FakeTransactionReadRepo() a = _transaction( id_=UUID(int=1), amount=100, kind=TransactionKind.EXPENSE, posted_at=datetime(2026, 5, 2, 10, tzinfo=UTC), category_id=None, ) b = _transaction( id_=UUID(int=2), amount=200, kind=TransactionKind.EXPENSE, posted_at=datetime(2026, 5, 2, 11, tzinfo=UTC), category_id=None, ) await repo.add(a) await repo.add(b) assert await repo.list_recent(USER_ID) == [b, a]
@pytest.mark.asyncioasync def test_fake_read_repo_sum_by_period_filters_kind_and_window() -> None: repo = FakeTransactionReadRepo() cat = UUID("33333333-3333-3333-3333-333333333333") inside = _transaction( id_=UUID(int=1), amount=100, kind=TransactionKind.EXPENSE, posted_at=datetime(2026, 5, 2, 10, tzinfo=UTC), category_id=cat, ) outside = _transaction( id_=UUID(int=2), amount=200, kind=TransactionKind.EXPENSE, posted_at=datetime(2026, 5, 1, 10, tzinfo=UTC), category_id=cat, ) income = _transaction( id_=UUID(int=3), amount=300, kind=TransactionKind.INCOME, posted_at=datetime(2026, 5, 2, 9, tzinfo=UTC), category_id=cat, ) for t in (inside, outside, income): await repo.add(t)
total = await repo.sum_by_period( USER_ID, TransactionKind.EXPENSE, period_start=datetime(2026, 5, 2, 0, tzinfo=UTC), period_end=datetime(2026, 5, 3, 0, tzinfo=UTC), ) assert total == Money(100, "UAH")
@pytest.mark.asyncioasync def test_fake_read_repo_sum_by_category() -> None: repo = FakeTransactionReadRepo() c1 = UUID("33333333-3333-3333-3333-333333333333") c2 = UUID("44444444-4444-4444-4444-444444444444")
for amt, cat in ((100, c1), (200, c1), (50, c2)): await repo.add( _transaction( id_=UUID(int=int(amt) * 100), amount=amt, kind=TransactionKind.EXPENSE, posted_at=datetime(2026, 5, 2, 10, tzinfo=UTC), category_id=cat, ) )
by_cat = await repo.sum_by_category( USER_ID, period_start=datetime(2026, 5, 2, 0, tzinfo=UTC), period_end=datetime(2026, 5, 3, 0, tzinfo=UTC), ) assert by_cat[c1] == Money(300, "UAH") assert by_cat[c2] == Money(50, "UAH")-
Step 20.2: Run, expect ImportError on
FakeTransactionReadRepo -
Step 20.3: Implement FakeTransactionReadRepo
Append to tests/_fakes/repositories.py:
from datetime import datetime
from finance_bot.domain.enums import TransactionKindfrom finance_bot.domain.models import Transactionfrom finance_bot.domain.money import Money
class FakeTransactionReadRepo: """Satisfies TransactionReadRepo. Backing store is a flat list of Transaction; queries are O(N). Fine for unit tests."""
def __init__(self) -> None: self._transactions: list[Transaction] = []
async def add(self, t: Transaction) -> None: """Test-only seeding helper. NOT part of the protocol.""" self._transactions.append(t)
async def list_recent( self, user_id: UUID, limit: int = 10 ) -> list[Transaction]: rows = [ t for t in self._transactions if t.user_id == user_id and t.voided_by_id is None ] rows.sort(key=lambda t: t.posted_at, reverse=True) return rows[:limit]
async def sum_by_period( self, user_id: UUID, kind: TransactionKind, period_start: datetime, period_end: datetime, category_id: UUID | None = None, ) -> Money: currency: str | None = None total = 0 for t in self._transactions: if t.user_id != user_id: continue if t.kind != kind: continue if not (period_start <= t.posted_at < period_end): continue if category_id is not None and t.category_id != category_id: continue if t.voided_by_id is not None: continue currency = t.amount.currency # all matched txns share a currency total += t.amount.amount_minor return Money(amount_minor=total, currency=currency or "UAH")
async def sum_by_category( self, user_id: UUID, period_start: datetime, period_end: datetime, ) -> dict[UUID, Money]: out: dict[UUID, Money] = {} for t in self._transactions: if t.user_id != user_id: continue if t.category_id is None: continue if not (period_start <= t.posted_at < period_end): continue if t.voided_by_id is not None: continue current = out.get(t.category_id) out[t.category_id] = ( current + t.amount if current is not None else t.amount ) return out- Step 20.4: Run tests, expect green
.venv/bin/pytest tests/_fakes/ -v-
Step 20.5: Lint + typecheck.
-
Step 20.6: Commit
git add tests/_fakes/repositories.py tests/_fakes/test_repositories.pygit commit -m "$(cat <<'EOF'feat(tests): add FakeTransactionReadRepo
Read-side fake satisfying TransactionReadRepo. Backing store is aflat list of Transaction; queries are O(N) which is fine for unittests. Voided transactions are excluded from list_recent and fromsum_* aggregations.
The sum_by_period helper picks currency from the first matchingtransaction or defaults to "UAH" when no rows match — sufficientfor Plan 5 tests where currency is fixed per user in MVP-1.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 21: ADR-0005 — Money representation
Section titled “Task 21: ADR-0005 — Money representation”Files:
-
Create:
docs/adr/0005-money-representation.md -
Step 21.1: Write the ADR
Create docs/adr/0005-money-representation.md:
---type: adrid: ADR-0005status: accepteddate: 2026-05-02deciders: - "@zipsybok"related: [ADR-0001, ADR-0009, ADR-0010]tags: [domain, money, datatypes]---
# ADR-0005 — Represent money as bigint minor units + ISO-4217 alpha currency
## Status
**Accepted** — *2026-05-02*
## Context
Every domain operation, ledger entry, balance, and budget limit dealsin money. We need a representation that is:
1. **Lossless under arithmetic.** No rounding surprises across add/sub/sum-of-many.2. **Type-safe.** Adding a UAH to a USD must be a hard error, not a silent merge.3. **Database-friendly.** The Postgres ledger needs to store amounts in a column type that survives precision and round-trips through asyncpg. The future TigerBeetle adapter (ADR-0001) needs a u128 integer.4. **Boundary-friendly.** User input arrives as decimal strings ("250.50"); display goes back as decimal strings. We need clean conversion at the edges without polluting the middle.
### Considered alternatives
| Option | Summary | Pros | Cons | Outcome ||--------|---------|------|------|---------|| A — `float` everywhere | One Python float per amount | Simplest | No money library uses floats. 0.1 + 0.2 ≠ 0.3. Catastrophic at scale | rejected outright || B — `Decimal` everywhere | Use stdlib Decimal as the sole money type | Lossless under arithmetic; familiar to fintech engineers | Variable precision (`Decimal("1") + Decimal("1.0") == Decimal("2.0")` — context-dependent quantization); serializing through asyncpg requires extra registration; TigerBeetle has no Decimal type so the swap requires a conversion layer in adapters; harder to express "currency-tagged" in the type system | rejected || **C (chosen) — `Money(amount_minor: int, currency: str)`** | Frozen dataclass: integer amount in minor units (kopecks/cents), ISO-4217 alpha currency. Decimal only at parse/display boundary | Lossless (int math is exact); type carries currency, so cross-currency arithmetic raises CurrencyMismatchError at the type level; native serialization to BIGINT in PG and to u128 (lossless) in TigerBeetle; the SCALE constant locks decimal places | Manual scale conversion at the boundary (2-line helpers); changing scale later is a hard migration | **selected** |
## Decision
We will represent money as the value object `domain.money.Money`:
```python@dataclass(frozen=True, slots=True)class Money: amount_minor: int currency: str # ISO-4217 alpha (3 uppercase letters) SCALE: ClassVar[int] = 100 # lockedLocked invariants:
SCALE = 100(two decimal places — kopecks for UAH, cents for USD). Increasing this is treated as a breaking change and would require a separate ADR.- Currency is the ISO-4217 alpha code (e.g.
"UAH"). The numeric code (e.g.980) is mapped only at the TigerBeetle boundary (see ADR-0010) — alpha is what lives in the schema, in logs, and in serialized payloads. amount_minormay be negative. The Money type does not distinguish balance vs transfer-amount semantics; the LedgerPort write side enforcesamount > 0separately (AmountNotPositiveError).- All arithmetic and comparison raise
CurrencyMismatchErroron mixed currencies. Money.from_decimal(d, currency)andMoney.to_decimal()are the ONLY allowed conversion path between Money anddecimal.Decimal. Domain code never touchesDecimaldirectly.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Arithmetic is exact and never surprises.
- The currency-mismatch check fires at the type system level, not in application logic.
- Postgres column type is
BIGINT(Plan 1’sapp.user.default_currencyisTEXTalready; ledger schema in Plan 3 follows the same pattern). - TigerBeetle migration (ADR-0001) is mechanically lossless: copy
amount_minor: inttoTransfer.amount: u128; map currency alpha to ledger u32 numeric (ADR-0010). - Hypothesis property tests for arithmetic invariants (commutativity, associativity, identity, negation) are trivial to write.
Negative / trade-offs
Section titled “Negative / trade-offs”- Boundaries (parser input, display output, CSV import in Tier-3)
need explicit
from_decimal/to_decimalcalls. Two lines, but enforce-able only by code review. - The locked SCALE means crypto-style microtransactions (8+ decimal places) are not supported on this ledger. A new ledger with a different SCALE would be a separate Money subtype — out of scope for MVP-1 through v1.
- Currency is a
str(validated by regex) rather than aCurrencyenum. We deliberately don’t enumerate ISO-4217 in code (180+ currencies; would bloat the codebase). Validation is by regex; the set of currencies actually used is bounded by what the ledger accepts onopen_account.
Neutral / follow-ups
Section titled “Neutral / follow-ups”- When (if) the bot supports a second active currency, a
Hypothesis test should generate currency pairs and assert
CurrencyMismatchError fires on every cross-currency op (the
test_cross_currency_addition_always_raisestest seeds this pattern). - Plan 3’s PostgresLedgerAdapter must round-trip Money through the DB without scale drift; an integration test should pin this.
References
Section titled “References”- Stripe / Square / PayPal: all expose money as
{ amount: int, currency: str }with minor-unit semantics. Industry convention. domain.money.Money— implementation.tests/unit/domain/test_money*.py— contract + property tests.- ADR-0001 (Defer TigerBeetle) — the swap is the load-bearing reason for keeping the boundary integer-clean.
- ADR-0009 (UUIDv7 for ledger IDs) — sister-decision about TB-friendly primitives.
- ADR-0010 (ISO-4217 numeric mapped to TB
ledger) — where the alpha currency code is converted to a numeric ledger id at the TB boundary.
- [ ] **Step 21.2: Commit**
```bashgit add docs/adr/0005-money-representation.mdgit commit -m "$(cat <<'EOF'docs(adr): ADR-0005 money as bigint minor units + ISO-4217 alpha
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"Task 22: Final sweep — full test/lint/mypy run + tally
Section titled “Task 22: Final sweep — full test/lint/mypy run + tally”Files: none (verification + summary commit if anything residual).
- Step 22.1: Full pytest run
.venv/bin/pytest -vExpected: total of roughly 50 tests passing (14 carried from Plan 1 + ~36 added in Plan 2). No failures. No collection errors.
- Step 22.2: Lint, format, typecheck
.venv/bin/ruff check.venv/bin/ruff format --check.venv/bin/mypyExpected: all clean. mypy now covers ~37 source files.
- Step 22.3: Verify hypothesis didn’t accidentally cache stale shrinks
ls -la .hypothesis/ 2>/dev/null || echo "no hypothesis cache"If .hypothesis/ exists with examples/ content, that’s expected — hypothesis caches counterexamples between runs. Already in .gitignore? If not, add it.
grep -q '^.hypothesis$' .gitignore || echo '.hypothesis/' >> .gitignoregit status -sIf .gitignore was modified, stage and commit:
git add .gitignoregit commit -m "$(cat <<'EOF'chore(gitignore): add .hypothesis/ cache directory
Hypothesis (Tasks 4 and 18) caches discovered counterexamples to.hypothesis/. Excluded from git so the cache stays per-developerand per-CI-run.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>EOF)"If no .gitignore change was needed, skip this commit.
- Step 22.4: Confirm
git statusclean
git statusExpected: clean tree on feature/domain-ports.
- Step 22.5: Confirm commit count
git log --oneline master..HEAD | wc -lgit log --oneline master..HEADExpected: ~21–23 commits (one per task, plus the ADR, plus the optional gitignore housekeeping).
Plan 2 — Exit criteria
Section titled “Plan 2 — Exit criteria”The plan is done when:
- All 22 tasks above are committed on
feature/domain-ports. -
.venv/bin/pytest -vreports ~50 tests passing. -
.venv/bin/ruff check && .venv/bin/ruff format --check && .venv/bin/mypyis clean. -
tests/_fakes/test_ledger.py::test_balance_invariant_under_random_transferspasses (the contract test that Plan 3’s PostgresLedgerAdapter must also pass). -
docs/adr/0005-money-representation.mdis committed. -
feature/domain-portsis ready for the same Path B finish (push → PR → merge → cleanup) used at the end of Plan 1.
After merge, Plan 3 (Postgres Ledger Adapter) starts on a fresh worktree. It will reuse the property-based balance invariant test from tests/_fakes/test_ledger.py as the contract assertion that the real Postgres adapter must satisfy — that’s the linchpin of approach β.