Two long-standing correctness gaps, both flagged in fin-research-frontier. A1 — POST /api/transactions could write half a linked pair TransactionCreate inherited the full six-value type Literal and accepted a free-standing linked_account_id, so any client could create a lone `transfer` row with no partner. That is exactly the balance corruption invariant #1 exists to prevent: the orphan counts toward SUM(amount - fee) and paired-delete can never find a partner to remove it with. TransactionCreate now narrows type to the single-leg kinds and rejects a caller-supplied linked_account_id. TransactionBase stays wide on purpose — reads must keep describing historical rows of every type. splitwise_pay deliberately stays allowed. The two-leg creator uses that type, but a standalone row carrying splitwise_partner_share is also a legitimate record, and is what `fin tx add --type splitwise_pay --partner-share` writes. Narrowing to expense/income would have broken it. B3 — no idempotency, so any retry double-booked Migration i9d0e1f2a3b4 adds a nullable client_ref to transactions. POST /api/transactions, /batch, /transfer and /settlement accept it, and crud.find_by_client_ref is checked before writing, so retrying a timed-out request replays the original rows instead of booking them again. NULL means "no idempotency requested" and never collides. Deviation from the frontier's sketch, which called for unique-when-not-null: the index is NOT unique. A transfer writes two rows and a batch writes many, all under one key, so uniqueness cannot live on the column. Suppression is a check-then-write inside the creators instead — sound here because SQLite serialises writers and the scheduler never writes transactions. Exposed on the MCP create_transaction/create_transfer/create_settlement tools, since a retrying agent is the case B3 was written about. The server instructions no longer tell agents that writes are non-idempotent. Tests: 60 passing, up from 47. Thirteen new cover retries collapsing to one write across all four endpoints, differing keys staying separate, NULL keys not colliding, and the orphan-leg guard refusing transfer/settlement/manual linkage while the four legitimate single-leg types still work. Migration verified both directions on a scratch DB seeded at the previous head: existing rows survive with NULL client_ref, index is created, and downgrade drops cleanly with rows intact. Verified live over HTTP that a transfer submitted three times with one client_ref yields one pair of legs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
178 lines
6.7 KiB
Python
178 lines
6.7 KiB
Python
"""Idempotency keys — retrying a write must not double-book.
|
|
|
|
Without these, an agent or a flaky network that retries a timed-out create
|
|
books the money twice and nothing detects it. A caller-supplied client_ref
|
|
makes the retry replay the original result instead.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from fin_cli.client import FinClientError
|
|
|
|
|
|
def _bank(client, name="Checking"):
|
|
return client.create_account({"name": name, "type": "bank", "currency": "EUR"})
|
|
|
|
|
|
def _expense(account_id, client_ref=None, amount=-12.40):
|
|
data = {
|
|
"account_id": account_id,
|
|
"date": "2026-08-22",
|
|
"amount": amount,
|
|
"currency": "EUR",
|
|
"type": "expense",
|
|
}
|
|
if client_ref is not None:
|
|
data["client_ref"] = client_ref
|
|
return data
|
|
|
|
|
|
class TestSingleCreate:
|
|
def test_repeat_with_same_key_writes_once(self, client):
|
|
acct = _bank(client)
|
|
|
|
first = client.create_transaction(_expense(acct["id"], "trip-001"))
|
|
second = client.create_transaction(_expense(acct["id"], "trip-001"))
|
|
|
|
assert first["id"] == second["id"]
|
|
assert len(client.list_transactions(account_id=acct["id"])) == 1
|
|
|
|
def test_retry_replays_the_original_row_not_the_new_payload(self, client):
|
|
"""The key identifies the operation; a differing body does not create a second row."""
|
|
acct = _bank(client)
|
|
|
|
first = client.create_transaction(_expense(acct["id"], "trip-002", amount=-12.40))
|
|
second = client.create_transaction(_expense(acct["id"], "trip-002", amount=-999.00))
|
|
|
|
assert second["id"] == first["id"]
|
|
assert second["amount"] == -12.40
|
|
assert len(client.list_transactions(account_id=acct["id"])) == 1
|
|
|
|
def test_different_keys_write_separately(self, client):
|
|
acct = _bank(client)
|
|
|
|
client.create_transaction(_expense(acct["id"], "trip-003"))
|
|
client.create_transaction(_expense(acct["id"], "trip-004"))
|
|
|
|
assert len(client.list_transactions(account_id=acct["id"])) == 2
|
|
|
|
def test_no_key_means_no_deduplication(self, client):
|
|
"""NULL client_ref must not collide with other NULLs."""
|
|
acct = _bank(client)
|
|
|
|
client.create_transaction(_expense(acct["id"]))
|
|
client.create_transaction(_expense(acct["id"]))
|
|
|
|
assert len(client.list_transactions(account_id=acct["id"])) == 2
|
|
|
|
def test_client_ref_is_returned_on_reads(self, client):
|
|
acct = _bank(client)
|
|
client.create_transaction(_expense(acct["id"], "trip-005"))
|
|
|
|
stored = client.list_transactions(account_id=acct["id"])[0]
|
|
assert stored["client_ref"] == "trip-005"
|
|
|
|
|
|
class TestMultiLegWrites:
|
|
def test_repeated_transfer_keeps_two_legs(self, client):
|
|
a = _bank(client, "A")
|
|
b = _bank(client, "B")
|
|
payload = {
|
|
"from_account_id": a["id"], "to_account_id": b["id"],
|
|
"amount": 40.0, "currency": "EUR", "date": "2026-08-22",
|
|
"client_ref": "xfer-001",
|
|
}
|
|
|
|
first = client.create_transfer(payload)
|
|
second = client.create_transfer(payload)
|
|
|
|
assert len(first) == 2 and len(second) == 2
|
|
assert [t["id"] for t in first] == [t["id"] for t in second]
|
|
assert len(client.list_transactions()) == 2, "retry created a second pair of legs"
|
|
|
|
def test_repeated_settlement_keeps_two_legs(self, client):
|
|
bank = _bank(client, "Bank")
|
|
sw = client.create_account({"name": "SW", "type": "splitwise", "currency": "EUR"})
|
|
payload = {
|
|
"bank_account_id": bank["id"], "splitwise_account_id": sw["id"],
|
|
"amount": 25.0, "currency": "EUR", "date": "2026-08-22",
|
|
"direction": "splitwise_to_bank", "client_ref": "settle-001",
|
|
}
|
|
|
|
first = client.create_settlement(payload)
|
|
second = client.create_settlement(payload)
|
|
|
|
assert [t["id"] for t in first] == [t["id"] for t in second]
|
|
assert len(client.list_transactions()) == 2
|
|
|
|
def test_repeated_batch_writes_rows_once(self, client):
|
|
acct = _bank(client)
|
|
payload = {
|
|
"account_id": acct["id"], "date": "2026-08-22", "client_ref": "batch-001",
|
|
"items": [{"amount": 12.40}, {"amount": 4.20}, {"amount": 31.00}],
|
|
}
|
|
|
|
first = client.create_transactions_batch(payload)
|
|
second = client.create_transactions_batch(payload)
|
|
|
|
assert len(first) == 3
|
|
assert [t["id"] for t in first] == [t["id"] for t in second]
|
|
assert len(client.list_transactions(account_id=acct["id"])) == 3
|
|
|
|
def test_every_row_of_a_batch_carries_the_key(self, client):
|
|
"""The whole group shares one key, which is how the retry finds all of it."""
|
|
acct = _bank(client)
|
|
client.create_transactions_batch({
|
|
"account_id": acct["id"], "date": "2026-08-22", "client_ref": "batch-002",
|
|
"items": [{"amount": 1.0}, {"amount": 2.0}],
|
|
})
|
|
|
|
stored = client.list_transactions(account_id=acct["id"])
|
|
assert all(t["client_ref"] == "batch-002" for t in stored)
|
|
|
|
|
|
class TestOrphanLegGuard:
|
|
"""A1 — the raw create endpoint must not be able to write half a pair."""
|
|
|
|
@pytest.mark.parametrize("bad_type", ["transfer", "settlement"])
|
|
def test_two_leg_types_are_refused(self, client, bad_type):
|
|
acct = _bank(client)
|
|
|
|
with pytest.raises(FinClientError) as exc:
|
|
client.create_transaction({
|
|
"account_id": acct["id"], "date": "2026-08-22", "amount": -10.0,
|
|
"currency": "EUR", "type": bad_type,
|
|
})
|
|
|
|
assert exc.value.status_code == 422
|
|
assert client.list_transactions() == []
|
|
|
|
def test_single_leg_types_still_work(self, client):
|
|
"""Narrowing must not break the flows that legitimately post single rows."""
|
|
acct = _bank(client)
|
|
sw = client.create_account({"name": "SW", "type": "splitwise", "currency": "EUR"})
|
|
|
|
for account_id, t in [
|
|
(acct["id"], "expense"), (acct["id"], "income"),
|
|
(sw["id"], "splitwise_receive"), (sw["id"], "splitwise_pay"),
|
|
]:
|
|
created = client.create_transaction({
|
|
"account_id": account_id, "date": "2026-08-22", "amount": -5.0,
|
|
"currency": "EUR", "type": t,
|
|
})
|
|
assert created["type"] == t
|
|
|
|
def test_manual_linkage_is_refused(self, client):
|
|
"""A free-standing linked_account_id would fake half of a linked pair."""
|
|
a = _bank(client, "A")
|
|
b = _bank(client, "B")
|
|
|
|
with pytest.raises(FinClientError) as exc:
|
|
client.create_transaction({
|
|
"account_id": a["id"], "date": "2026-08-22", "amount": -10.0,
|
|
"currency": "EUR", "type": "expense", "linked_account_id": b["id"],
|
|
})
|
|
|
|
assert exc.value.status_code == 422
|
|
assert client.list_transactions() == []
|