Перейти до вмісту

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).


  • Working directory: /Users/zipsybok/dev/telegram-finance-bot-domain-ports for every command. The main checkout at /Users/zipsybok/dev/telegram-finance-bot is on master and is NOT touched in this plan.
  • Run python tools as .venv/bin/<tool> or uv run <tool>. README.md exists on master so plain uv run works 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 matching test_*.py are 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.

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 NEW

After 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 — makes tests/unit/domain a package).

Terminal window
mkdir -p tests/unit/domain
touch 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_enums.py -v

Expected: 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 in
docs/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
Terminal window
.venv/bin/pytest tests/unit/domain/test_enums.py -v

Expected: 5 passed.

  • Step 1.6: Lint + typecheck
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy

Expected: clean. mypy now covers 28 source files.

  • Step 1.7: Commit
Terminal window
git add src/finance_bot/domain/enums.py tests/unit/domain/__init__.py tests/unit/domain/test_enums.py
git commit -m "$(cat <<'EOF'
feat(domain): add TransactionKind and AccountType StrEnums
Both are StrEnum so values compare directly to their lowercase
string literals (matching the schema in design.md §5.2 and the
future 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 via
constructor, and the real-vs-virtual partition.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"

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
Terminal window
.venv/bin/pytest tests/unit/domain/test_errors.py -v

Expected: 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 a
stable string code on its class. Codes are mapped 1:1 to acceptance
criteria in the SRS (see design.md §9.1) and are surfaced both in
log lines and (where appropriate) in user-visible error messages.
Adapter-level transport errors (DBError, TelegramAPIError, etc.) live
elsewhere; they do NOT inherit from DomainError because handling them
is 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)
Terminal window
.venv/bin/pytest tests/unit/domain/test_errors.py -v

Expected: all pass.

  • Step 2.5: Lint + typecheck
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy

Expected: clean.

  • Step 2.6: Commit
Terminal window
git add src/finance_bot/domain/errors.py tests/unit/domain/test_errors.py
git commit -m "$(cat <<'EOF'
feat(domain): add DomainError hierarchy with stable codes
DomainError base + 9 subclasses, each with a ClassVar[str] code that
maps 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_money.py -v

Expected: 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, cents
for USD, …) and currency as an ISO-4217 alpha code (3 uppercase
letters). Negative amounts ARE allowed at the type level so that
balances can be expressed under overdraft; the *write side* of the
ledger (LedgerPort.post_transfer) enforces amount > 0 separately.
Arithmetic, comparison, and Decimal helpers are added in Tasks 4-6.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_money.py -v

Expected: 7 passed.

  • Step 3.5: Lint + typecheck
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy

Expected: clean.

  • Step 3.6: Commit
Terminal window
git add src/finance_bot/domain/money.py tests/unit/domain/test_money.py
git 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 in
Tasks 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-based
checks via hypothesis."""
from __future__ import annotations
import pytest
from hypothesis import given
from hypothesis import strategies as st
from finance_bot.domain.errors import CurrencyMismatchError
from 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_money_arithmetic.py -v

Expected: 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_money.py tests/unit/domain/test_money_arithmetic.py -v

Expected: 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
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy

Expected: clean.

  • Step 4.6: Commit
Terminal window
git add src/finance_bot/domain/money.py tests/unit/domain/test_money_arithmetic.py
git commit -m "$(cat <<'EOF'
feat(domain): add Money arithmetic (+, -, unary -)
Operations enforce currency match and raise CurrencyMismatchError on
mismatch. Inline import inside _check_same_currency keeps the
module-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 pattern
that 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_money.py -v

Expected: 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 < 0

Note: 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_money.py tests/unit/domain/test_money_arithmetic.py -v

Expected: all pass (7 + 5 new = 12 from test_money; 12 from arithmetic).

  • Step 5.5: Lint + typecheck
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy

Expected: clean.

  • Step 5.6: Commit
Terminal window
git add src/finance_bot/domain/money.py tests/unit/domain/test_money.py
git 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_money.py -v

Expected: 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_EVEN

Then 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
Terminal window
.venv/bin/pytest tests/unit/domain/ -v

Expected: 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
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy

Expected: clean.

  • Step 6.6: Commit
Terminal window
git add src/finance_bot/domain/money.py tests/unit/domain/test_money.py
git 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 inverse
direction, and full round-trip identity.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"

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, datetime
from 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
Terminal window
.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 DB
mapping. 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 dataclass
from datetime import datetime
from 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_models.py -v
  • Step 7.5: Lint + typecheck
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy
  • Step 7.6: Commit
Terminal window
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.py
git 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 in
Tasks 8-12 as separate commits so each can be reviewed in isolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"

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 AccountType
from 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
Terminal window
.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 AccountType

Append 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
Terminal window
.venv/bin/pytest tests/unit/domain/test_models.py -v

Expected: 3 (User) + 4 (Account) = 7 passed.

  • Step 8.5: Lint + typecheck
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy
  • Step 8.6: Commit
Terminal window
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.py
git commit -m "$(cat <<'EOF'
feat(domain): add Account model
Holds the user-facing identity of a wallet. Type is one of the five
AccountType members (Task 1); external_expense and external_income
are virtual accounts used by the ledger to express expenses/income
as transfers.
min_balance_minor is None when overdraft is allowed (default), or
an int when the account must not go below that amount — enforced on
the LedgerPort write side.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"

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

Terminal window
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.py
git 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 the
dataclass hashable and to forbid accidental mutation.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"

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 Budget
from 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 Money

Append 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

Terminal window
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.py
git commit -m "$(cat <<'EOF'
feat(domain): add Budget model
Per-category limit tied to a calendar period (month/week). limit
is 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 TransactionKind
from 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

Terminal window
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.py
git 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 on
post_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

Terminal window
git add src/finance_bot/domain/models.py tests/unit/domain/test_models.py
git commit -m "$(cat <<'EOF'
feat(domain): add Transaction read-side model
Same shape as PostedTransfer but with TransferMetadata fields flat
inline (kind/category_id/raw_text/occurred_at). This is what the
TransactionReadRepo returns to application code that needs to render
or aggregate transactions.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"

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

Terminal window
mkdir -p tests/unit/ports
touch 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 (when
they arrive in Tasks 17-20) can be verified against the interface.
"""
from __future__ import annotations
from datetime import datetime
from 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 in
production; FakeClock in tests). Application use-cases depend only
on this Protocol so they can be tested deterministically.
"""
from __future__ import annotations
from datetime import datetime
from typing import Protocol, runtime_checkable
@runtime_checkable
class 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

Terminal window
git add src/finance_bot/ports/clock.py tests/unit/ports/__init__.py tests/unit/ports/test_protocols.py
git 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
)"

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 across
the 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 in
TransactionReadRepo to keep this port narrow.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from uuid import UUID
from finance_bot.domain.models import PostedTransfer, TransferMetadata
from finance_bot.domain.money import Money
@runtime_checkable
class 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

Terminal window
git add src/finance_bot/ports/ledger.py tests/unit/ports/test_protocols.py
git 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 without
touching 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 the
SRS 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 (write
side and lookups; this task) and TransactionReadRepo (read-side
aggregations; Task 16, in this same file).
Plan 4 supplies the concrete Postgres adapters; in-memory fakes for
unit tests are added in Tasks 19-20.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
from uuid import UUID
from finance_bot.domain.models import Account, Budget, Category, User
@runtime_checkable
class UserRepo(Protocol):
async def get_by_telegram_id(self, telegram_id: int) -> User | None: ...
async def add(self, user: User) -> None: ...
@runtime_checkable
class 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_checkable
class 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_checkable
class 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

Terminal window
git add src/finance_bot/ports/repositories.py tests/unit/ports/test_protocols.py
git 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-cases
actually 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 TransactionKind
from finance_bot.domain.models import Account, Budget, Category, Transaction, User
from finance_bot.domain.money import Money

Append at the END of the file:

@runtime_checkable
class 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

Terminal window
git add src/finance_bot/ports/repositories.py tests/unit/ports/test_protocols.py
git 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
)"

Files:

  • Create: tests/_fakes/__init__.py

  • Create: tests/_fakes/clock.py

  • Create: tests/_fakes/test_clock.py

  • Step 17.1: Create the package

Terminal window
mkdir -p tests/_fakes
touch 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
Terminal window
.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 time
deterministic 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

Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy

Note: 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
Terminal window
git add tests/_fakes/__init__.py tests/_fakes/clock.py tests/_fakes/test_clock.py
git commit -m "$(cat <<'EOF'
feat(tests): add FakeClock + contract tests
In-memory clock for use-case unit tests in Plan 5. Constructed with
a tz-aware now; supports advance(timedelta) and set(instant).
Refuses naive datetimes at construction and on set() to enforce the
Clock 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, datetime
from uuid import UUID
import pytest
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from finance_bot.domain.enums import TransactionKind
from finance_bot.domain.errors import (
AccountNotFoundError,
AlreadyVoidedError,
AmountNotPositiveError,
CurrencyMismatchError,
MinBalanceViolationError,
SameAccountError,
TransferNotFoundError,
)
from finance_bot.domain.models import TransferMetadata
from finance_bot.domain.money import Money
from 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.fixture
def ledger() -> FakeLedger:
fl = FakeLedger()
return fl
@pytest.mark.asyncio
async def test_satisfies_protocol(ledger: FakeLedger) -> None:
assert isinstance(ledger, LedgerPort)
@pytest.mark.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async 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 in
Plan 5 without touching Postgres.
Plan 3's PostgresLedgerAdapter must satisfy the same contract; the
property-based invariant test in test_ledger.py is the canonical
contract assertion.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from uuid import UUID
from finance_bot.domain.errors import (
AccountNotFoundError,
AlreadyVoidedError,
AmountNotPositiveError,
CurrencyMismatchError,
MinBalanceViolationError,
SameAccountError,
TransferNotFoundError,
)
from finance_bot.domain.models import PostedTransfer, TransferMetadata
from finance_bot.domain.money import Money
@dataclass
class _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
Terminal window
.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.

Terminal window
.venv/bin/ruff check
.venv/bin/ruff format
.venv/bin/mypy

If ruff flags F401 for AlreadyVoidedError, delete the import line. Re-run.

  • Step 18.6: Commit
Terminal window
git add tests/_fakes/ledger.py tests/_fakes/test_ledger.py
git commit -m "$(cat <<'EOF'
feat(tests): add FakeLedger + invariant tests
In-memory LedgerPort implementation enforcing every invariant the
contract 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 that
balance_minor == sum(credits) - sum(debits) for any random sequence
of transfers. The property test is the contract assertion that
PostgresLedgerAdapter (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, datetime
from uuid import UUID
import pytest
from finance_bot.domain.enums import AccountType
from finance_bot.domain.models import Account, Budget, Category, User
from finance_bot.domain.money import Money
from 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.asyncio
async def test_fake_user_repo_satisfies_protocol() -> None:
assert isinstance(FakeUserRepo(), UserRepo)
@pytest.mark.asyncio
async 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.asyncio
async def test_fake_account_repo_satisfies_protocol() -> None:
assert isinstance(FakeAccountRepo(), AccountRepo)
@pytest.mark.asyncio
async 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.asyncio
async 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.asyncio
async 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.asyncio
async def test_fake_category_repo_satisfies_protocol() -> None:
assert isinstance(FakeCategoryRepo(), CategoryRepo)
@pytest.mark.asyncio
async 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.asyncio
async def test_fake_budget_repo_satisfies_protocol() -> None:
assert isinstance(FakeBudgetRepo(), BudgetRepo)
@pytest.mark.asyncio
async 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 repository
Protocols. 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)
Terminal window
.venv/bin/pytest tests/_fakes/test_repositories.py -v
  • Step 19.5: Lint + typecheck.

  • Step 19.6: Commit

Terminal window
git add tests/_fakes/repositories.py tests/_fakes/test_repositories.py
git commit -m "$(cat <<'EOF'
feat(tests): add write-side repository fakes
FakeUserRepo, FakeAccountRepo, FakeCategoryRepo, FakeBudgetRepo —
each satisfies its Protocol (asserted via isinstance) and matches
the 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
)"

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 TransactionKind
from finance_bot.domain.models import Transaction
from 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.asyncio
async def test_fake_read_repo_satisfies_protocol() -> None:
assert isinstance(FakeTransactionReadRepo(), TransactionReadRepo)
@pytest.mark.asyncio
async 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.asyncio
async 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.asyncio
async 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 TransactionKind
from finance_bot.domain.models import Transaction
from 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
Terminal window
.venv/bin/pytest tests/_fakes/ -v
  • Step 20.5: Lint + typecheck.

  • Step 20.6: Commit

Terminal window
git add tests/_fakes/repositories.py tests/_fakes/test_repositories.py
git commit -m "$(cat <<'EOF'
feat(tests): add FakeTransactionReadRepo
Read-side fake satisfying TransactionReadRepo. Backing store is a
flat list of Transaction; queries are O(N) which is fine for unit
tests. Voided transactions are excluded from list_recent and from
sum_* aggregations.
The sum_by_period helper picks currency from the first matching
transaction or defaults to "UAH" when no rows match — sufficient
for 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: adr
id: ADR-0005
status: accepted
date: 2026-05-02
deciders:
- "@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 deals
in 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 # locked

Locked 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_minor may be negative. The Money type does not distinguish balance vs transfer-amount semantics; the LedgerPort write side enforces amount > 0 separately (AmountNotPositiveError).
  • All arithmetic and comparison raise CurrencyMismatchError on mixed currencies.
  • Money.from_decimal(d, currency) and Money.to_decimal() are the ONLY allowed conversion path between Money and decimal.Decimal. Domain code never touches Decimal directly.
  • 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’s app.user.default_currency is TEXT already; ledger schema in Plan 3 follows the same pattern).
  • TigerBeetle migration (ADR-0001) is mechanically lossless: copy amount_minor: int to Transfer.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.
  • Boundaries (parser input, display output, CSV import in Tier-3) need explicit from_decimal/to_decimal calls. 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 a Currency enum. 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 on open_account.
  • 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_raises test seeds this pattern).
  • Plan 3’s PostgresLedgerAdapter must round-trip Money through the DB without scale drift; an integration test should pin this.
  • 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**
```bash
git add docs/adr/0005-money-representation.md
git 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
Terminal window
.venv/bin/pytest -v

Expected: 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
Terminal window
.venv/bin/ruff check
.venv/bin/ruff format --check
.venv/bin/mypy

Expected: all clean. mypy now covers ~37 source files.

  • Step 22.3: Verify hypothesis didn’t accidentally cache stale shrinks
Terminal window
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.

Terminal window
grep -q '^.hypothesis$' .gitignore || echo '.hypothesis/' >> .gitignore
git status -s

If .gitignore was modified, stage and commit:

Terminal window
git add .gitignore
git 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-developer
and 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 status clean
Terminal window
git status

Expected: clean tree on feature/domain-ports.

  • Step 22.5: Confirm commit count
Terminal window
git log --oneline master..HEAD | wc -l
git log --oneline master..HEAD

Expected: ~21–23 commits (one per task, plus the ADR, plus the optional gitignore housekeeping).


The plan is done when:

  • All 22 tasks above are committed on feature/domain-ports.
  • .venv/bin/pytest -v reports ~50 tests passing.
  • .venv/bin/ruff check && .venv/bin/ruff format --check && .venv/bin/mypy is clean.
  • tests/_fakes/test_ledger.py::test_balance_invariant_under_random_transfers passes (the contract test that Plan 3’s PostgresLedgerAdapter must also pass).
  • docs/adr/0005-money-representation.md is committed.
  • feature/domain-ports is 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 β.