diff --git a/ledger/src/m2_ledger/__pycache__/models.cpython-312.pyc b/ledger/src/m2_ledger/__pycache__/models.cpython-312.pyc index 48525db..78ebe84 100644 Binary files a/ledger/src/m2_ledger/__pycache__/models.cpython-312.pyc and b/ledger/src/m2_ledger/__pycache__/models.cpython-312.pyc differ diff --git a/ledger/src/m2_ledger/models.py b/ledger/src/m2_ledger/models.py index 383d59b..d6b1079 100644 --- a/ledger/src/m2_ledger/models.py +++ b/ledger/src/m2_ledger/models.py @@ -121,8 +121,17 @@ def record_install( cut = int(amount * platform_pct) seller_amount = amount - cut - net_tx_id = _insert_tx(conn, ts, buyer, seller, seller_amount, "install", ref) - cut_tx_id = _insert_tx(conn, ts, buyer, "platform", cut, "install", ref) + # The tx table CHECKs amount > 0: a pct that rounds either side to zero + # (0.0 -> cut == 0, 1.0 -> seller_amount == 0) must skip that row, not + # violate the constraint. amount >= 1 guarantees at least one row. + tx_ids: list[int] = [] + net_tx_id = None + if seller_amount > 0: + net_tx_id = _insert_tx(conn, ts, buyer, seller, seller_amount, "install", ref) + tx_ids.append(net_tx_id) + if cut > 0: + tx_ids.append(_insert_tx(conn, ts, buyer, "platform", cut, "install", ref)) + paying_tx_id = net_tx_id if net_tx_id is not None else tx_ids[0] grant_id = f"gr_{uuid.uuid4().hex}" conn.execute( @@ -131,14 +140,14 @@ def record_install( solution_version_major, tx_id, ts) VALUES (?, ?, ?, ?, ?, ?) """, - (grant_id, buyer, ref, 1, net_tx_id, ts), + (grant_id, buyer, ref, 1, paying_tx_id, ts), ) grant_row = conn.execute( "SELECT * FROM grant_ WHERE grant_id = ?", (grant_id,) ).fetchone() - return {"tx_ids": [net_tx_id, cut_tx_id], "grant": dict(grant_row)} + return {"tx_ids": tx_ids, "grant": dict(grant_row)} def record_refund(conn: sqlite3.Connection, ref: str) -> dict: diff --git a/ledger/tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc b/ledger/tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000..60d4cfe Binary files /dev/null and b/ledger/tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc differ diff --git a/ledger/tests/__pycache__/test_properties.cpython-312-pytest-9.1.1.pyc b/ledger/tests/__pycache__/test_properties.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000..8bc7dee Binary files /dev/null and b/ledger/tests/__pycache__/test_properties.cpython-312-pytest-9.1.1.pyc differ diff --git a/ledger/tests/test_properties.py b/ledger/tests/test_properties.py index 9f67b0e..d33ab40 100644 --- a/ledger/tests/test_properties.py +++ b/ledger/tests/test_properties.py @@ -246,3 +246,26 @@ def test_api_install_402_leaves_tx_log_unchanged(client, service_headers, admin_ after = client.get("/tx", headers=service_headers).json()["transactions"] assert after == before + + +def test_edge_platform_pct_zero_and_one(tmp_path): + """Regression: pct that rounds a row to zero must skip it, not 500. + + (T013 worker finding: CHECK amount > 0 was violated by cut == 0 or + seller_amount == 0; fixed in models.record_install.) + """ + from m2_ledger import models as M + + conn = M.init_db(str(tmp_path / "edge.db")) + M.record_grant(conn, "buyer", 100) + + r0 = M.record_install(conn, "buyer", "seller", 10, 0.0, "ref:pct0") + assert M.balance(conn, "seller") == 10 + assert M.balance(conn, "platform") == 0 + assert len(r0["tx_ids"]) == 1 + + r1 = M.record_install(conn, "buyer", "seller", 10, 1.0, "ref:pct1") + assert M.balance(conn, "platform") == 10 + assert len(r1["tx_ids"]) == 1 + assert r1["grant"]["tx_id"] == r1["tx_ids"][0] + assert M.balance(conn, "buyer") == 80 diff --git a/specs/001-market-first-wedge/tasks.md b/specs/001-market-first-wedge/tasks.md index 88b32e5..e88022e 100644 --- a/specs/001-market-first-wedge/tasks.md +++ b/specs/001-market-first-wedge/tasks.md @@ -51,7 +51,7 @@ published listing - [x] T010 [P] [US1] Implement tx + grant models and derived-balance queries in `ledger/src/m2_ledger/models.py` (SQLite WAL, append-only invariants, data-model.md Transaction/LicenseGrant) - [x] T011 [P] [US1] Implement X-API-Key auth (service/admin key classes) in `ledger/src/m2_ledger/auth.py` - [x] T012 [US1] Implement API routes `/health`, `/balance/{op}`, `/tx/install`, `/tx/refund`, `/tx`, `/license/{op}/{listing}` in `ledger/src/m2_ledger/api.py` (402/409 semantics, all-or-nothing install tx, idempotent by ref) -- [ ] T013 [US1] Ledger unit + property tests in `ledger/tests/test_properties.py`: balance==fold(log) under generated sequences; no grant without tx; install atomicity; duplicate-ref 409 +- [x] T013 [US1] Ledger unit + property tests in `ledger/tests/test_properties.py`: balance==fold(log) under generated sequences; no grant without tx; install atomicity; duplicate-ref 409 - [ ] T014 [US1] Dockerfile + Coolify deployment for m2-ledger on the `coolify` network in `ledger/Dockerfile` + `ledger/README.md` deploy notes; verify `GET /health` from the network - [x] T015 [US1] Create `market:catalog` partition and implement indexer `indexer/src/m2_market_indexer/reindex.py` + `watch.py` per contracts/catalog-index.md (registry → memory-api upsert, tenant_visibility payload, delist removal)