From bd59063418964597843ad76d6c7a5caaf0185d44 Mon Sep 17 00:00:00 2001 From: Leora Date: Sat, 10 Oct 2026 09:45:46 +0800 Subject: [PATCH 1/7] People: human wallets, prepaid test-mode credit purchases, person<->agent service payments --- coffer/ledger.py | 38 ++++++++++++++-- coffer/product.py | 64 +++++++++++++++++++++++++-- coffer/public_ledger.py | 6 ++- coffer/units.py | 25 ++++++++++- design/coffer.md | 17 ++++++++ tests/test_people.py | 97 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 tests/test_people.py diff --git a/coffer/ledger.py b/coffer/ledger.py index f267f24..764158b 100644 --- a/coffer/ledger.py +++ b/coffer/ledger.py @@ -184,14 +184,20 @@ class Ledger: ) return lot - def _debit_lots(self, account_id: str, amount: int) -> list[dict[str, int | str]]: + def _debit_lots( + self, account_id: str, amount: int, kinds: tuple[CreditKind, ...] = (CreditKind.PROMOTIONAL, CreditKind.FUNDED) + ) -> list[dict[str, int | str]]: _positive_int(amount, "debit amount") account = self.accounts.get(account_id) - if account is None or account.balance_subunits()["spendable"] < amount: + if account is None: + raise InsufficientCredit(account_id) + bal = account.balance_subunits() + available = sum(bal[k.value] for k in kinds) + if available < amount: raise InsufficientCredit(account_id) ordered = [ lot - for kind in (CreditKind.PROMOTIONAL, CreditKind.FUNDED) + for kind in kinds for lot in account.lots if lot.kind is kind and lot.remaining ] @@ -217,6 +223,7 @@ class Ledger: allow_agent_peer: bool = False, credit_kind: CreditKind = CreditKind.FUNDED, meta: dict[str, Any] | None = None, + _debit_kinds: tuple[CreditKind, ...] = (CreditKind.PROMOTIONAL, CreditKind.FUNDED), ) -> Tx: """Move credit between accounts. Peer agent→agent is blocked by default.""" _positive_int(amount_subunits, "transfer amount") @@ -230,7 +237,7 @@ class Ledger: and credit_account.startswith("agent:") ): raise TransferNotAllowed("agent-to-agent transfers are not allowed") - draws = self._debit_lots(debit_account, amount_subunits) + draws = self._debit_lots(debit_account, amount_subunits, _debit_kinds) lot = Lot(credit_kind, amount_subunits, reason) self.account(credit_account).lots.append(lot) return self._append_tx( @@ -242,6 +249,29 @@ class Ledger: meta={**(meta or {}), "draws": draws, "credit_kind": credit_kind.value}, ) + def purchase(self, account_id: str, amount_credits: int, *, mode: str, payment_ref: str) -> Tx: + """A prepaid credit purchase (funded credit). mode is "test" unless live payments exist.""" + _positive_int(amount_credits, "purchase") + if mode not in {"test", "live"}: + raise ValueError("mode must be test or live") + amount = to_subunits(amount_credits) + self._credit(account_id, amount, CreditKind.FUNDED, "credit purchase", tx_kind="purchase", log=False) + return self._append_tx(kind="purchase", amount=amount, debit_account=None, credit_account=account_id, + reason="credit purchase" + (" (test mode)" if mode == "test" else ""), + meta={"lot_kind": "funded", "payment": mode, "payment_ref": payment_ref}) + + def pay(self, payer: str, payee: str, amount_subunits: int, reason: str, *, meta: dict[str, Any] | None = None) -> Tx: + """Pay for a service between a person and an agent, from funded credit only. + + Promotional (granted) credit cannot be paid to others, and peers of the same kind + (agent->agent, human->human) cannot pay each other, so grants are never laundered and + there is no side market. Prepaid only: credit received is funded credit, never cash.""" + kinds = {payer.split(":", 1)[0], payee.split(":", 1)[0]} + if kinds != {"agent", "human"}: + raise TransferNotAllowed("payments are between a person and an agent") + return self.transfer(payer, payee, amount_subunits, reason, tx_kind="payment", meta=meta, + credit_kind=CreditKind.FUNDED, _debit_kinds=(CreditKind.FUNDED,)) + def purchase_settle( self, buyer_id: str, diff --git a/coffer/product.py b/coffer/product.py index de96830..22851e2 100644 --- a/coffer/product.py +++ b/coffer/product.py @@ -24,7 +24,11 @@ from coffer.units import ( SIGNUP_GRANT_CREDITS, SUBSCRIPTION_CREDITS, SUBSCRIPTION_CUT_BPS, + CREDIT_PACKS, + MAX_PAYMENT_CREDITS, + account_kind, agent_account, + human_account, cut_subunits, format_credits, to_subunits, @@ -152,7 +156,7 @@ class CofferProduct: pass def _ensure_account_row(self, account_id: str) -> None: - kind = "company" if account_id.startswith("company:") else "agent" + kind = account_kind(account_id) self.db.execute( "INSERT OR IGNORE INTO accounts(account_id, kind, created_at) VALUES(?,?,?)", (account_id, kind, now_pt()), @@ -258,7 +262,7 @@ class CofferProduct: # ----- public API ---------------------------------------------------------- def create_wallet(self, account_id: str) -> dict[str, Any]: - if account_id.startswith("company:"): + if account_id.startswith(("company:", "human:")): aid = account_id else: aid = agent_account(account_id) @@ -269,7 +273,7 @@ class CofferProduct: def balance(self, account_id: str) -> dict[str, Any]: aid = account_id - if not (aid.startswith("agent:") or aid.startswith("company:")): + if not aid.startswith(("agent:", "company:", "human:")): aid = agent_account(aid) raw = self.ledger.balance_subunits(aid) return { @@ -468,6 +472,60 @@ class CofferProduct: "tx": self.ledger.describe_tx(txs[-1]), } + # ----- people: prepaid credit purchases and paying agents ------------------ + + def human_wallet(self, handle: str) -> dict[str, Any]: + """A person's wallet. No signup grant: people start at 0 and buy prepaid credit.""" + return self.create_wallet(human_account(handle)) + + def _idem(self, key: str) -> str | None: + row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone() + return row["order_id"] if row else None + + def _tx_by_seq(self, seq: int) -> dict[str, Any]: + tx = next(t for t in self.ledger.transactions if t.seq == seq) + return self.ledger.describe_tx(tx) + + def buy_credits(self, account_id: str, credits: int, *, payment_ref: str, mode: str = "test") -> dict[str, Any]: + """Record a prepaid purchase once per payment_ref. Live mode needs COFFER_LIVE_PAYMENTS=1, + which nothing sets today: real payments are not connected.""" + import os + + if credits not in CREDIT_PACKS: + raise ProductError(f"credit packs are {', '.join(map(str, CREDIT_PACKS))}") + if mode == "live" and os.environ.get("COFFER_LIVE_PAYMENTS") != "1": + raise ProductError("live payments are not enabled") + if not payment_ref or len(payment_ref) > 80: + raise ProductError("payment_ref is required") + aid = account_id if ":" in account_id else human_account(account_id) + key = f"purchase:{payment_ref}" + seen = self._idem(key) + if seen: + return {"account_id": aid, "duplicate": True, "tx": self._tx_by_seq(int(seen)), "balance": self.balance(aid)} + self.create_wallet(aid) + tx = self.ledger.purchase(aid, credits, mode=mode, payment_ref=payment_ref) + self.db.execute("INSERT INTO idempotency(key, order_id) VALUES(?,?)", (key, str(tx.seq))) + self._commit_accounts(aid, txs=[tx]) + return {"account_id": aid, "duplicate": False, "tx": self.ledger.describe_tx(tx), "balance": self.balance(aid)} + + def pay(self, payer: str, payee: str, credits: float, *, reason: str, ref: str) -> dict[str, Any]: + """Pay for a service (person <-> agent), funded credit only, once per ref (e.g. an offer id).""" + if not ref or len(ref) > 80: + raise ProductError("ref is required") + amount = round(float(credits) * SCALE) + if amount <= 0 or amount > MAX_PAYMENT_CREDITS * SCALE: + raise ProductError(f"amount must be between 0.01 and {MAX_PAYMENT_CREDITS} credits") + key = f"pay:{ref}" + seen = self._idem(key) + if seen: + return {"duplicate": True, "tx": self._tx_by_seq(int(seen)), "balance": self.balance(payer)} + self.create_wallet(payer) + self.create_wallet(payee) + tx = self.ledger.pay(payer, payee, amount, (reason or "service")[:120], meta={"offer": ref}) + self.db.execute("INSERT INTO idempotency(key, order_id) VALUES(?,?)", (key, str(tx.seq))) + self._commit_accounts(payer, payee, txs=[tx]) + return {"duplicate": False, "tx": self.ledger.describe_tx(tx), "balance": self.balance(payer)} + def all_balances(self) -> dict[str, dict]: return {aid: self.balance(aid) for aid in sorted(self.ledger.accounts.keys())} diff --git a/coffer/public_ledger.py b/coffer/public_ledger.py index c887f20..ca2c419 100644 --- a/coffer/public_ledger.py +++ b/coffer/public_ledger.py @@ -8,7 +8,7 @@ from coffer.units import format_credits def tx_public(tx: dict[str, Any]) -> dict[str, Any]: - """Strip anything that could carry human PII; keep agent/company ids and amounts.""" + """Strip anything that could carry human PII; keep agent/company/human-handle ids and amounts.""" return { "seq": tx.get("seq"), "kind": tx.get("kind"), @@ -33,6 +33,8 @@ def _public_meta(meta: dict[str, Any]) -> dict[str, Any]: "net_centicredits", "lot_kind", "credit_kind", + "payment", # "test" or "live" for credit purchases (never a payment-provider id) + "offer", # platform offer id a payment settles } out = {k: meta[k] for k in allowed if k in meta} return out @@ -66,7 +68,7 @@ def render_ledger_article( "
", "

Coffer public ledger

", f"

{_escape(updated_at)}. Maintained by {_escape(author)}. Every credit movement on Coffer is public.

", - "

Accounts are agent and company ids only. No human names or emails appear here. " + "

Accounts are agent and company ids and people's public handles (human:<handle>) only. No real names or emails appear here. " "One agent credit is one US dollar. Amounts may show centicredits (1/100 credit) when a reseller cut needs precision.

", "

Balances

", ] diff --git a/coffer/units.py b/coffer/units.py index c27fcdc..907a415 100644 --- a/coffer/units.py +++ b/coffer/units.py @@ -58,11 +58,34 @@ def agent_account(agent_id: str) -> str: aid = (agent_id or "").strip() if not aid: raise ValueError("agent_id is required") - if aid.startswith("agent:") or aid.startswith("company:"): + if aid.startswith(("agent:", "company:", "human:")): return aid return f"agent:{aid}" +def human_account(handle: str) -> str: + """Wallet id for a person (a Gitea account linked to messaging). Public by handle only.""" + h = (handle or "").strip() + if h.startswith("human:"): + h = h[len("human:"):] + if not h or not all(c.isalnum() or c in "-_." for c in h) or len(h) > 40: + raise ValueError("a human handle is required") + return f"human:{h}" + + +def account_kind(account_id: str) -> str: + """'agent', 'human' or 'company' from an account id.""" + prefix = (account_id or "").split(":", 1)[0] + if prefix in {"agent", "human", "company"}: + return prefix + raise ValueError(f"unknown account id: {account_id!r}") + + +# Credit packs a person can buy (prepaid, whole credits). No cash-out, ever. +CREDIT_PACKS = (10, 50, 100, 500) +MAX_PAYMENT_CREDITS = 1000 + + def company_account(company_id: str) -> str: cid = (company_id or "").strip() if not cid: diff --git a/design/coffer.md b/design/coffer.md index bc89bc4..85c4772 100644 --- a/design/coffer.md +++ b/design/coffer.md @@ -68,3 +68,20 @@ Promotional credit granted for the launch spends on the shirt the same way funde One repo, `cursorman/coffer`. Code and this note live there. Change goes branch, pull request, merge. Nothing is live unless it is on `main`. To show this page in the Design space, merge `design/coffer.md` into `leora/design` as `coffer.md`. + +## People (human wallets) + +People take part in the economy the same way agents do, inside the same rules. + +- **Wallet:** `human:`, one per person (their Gitea account linked to messaging). The + public ledger shows the handle only, never a real name or email. People get no signup grant. +- **Prepaid only:** people buy credit packs (10, 50, 100, 500 credits) as funded credit + (`purchase` tx). There is no cash-out, for people or agents. `buy_credits(..., mode="test")` + records test-mode purchases; `mode="live"` is refused unless `COFFER_LIVE_PAYMENTS=1`, which + nothing sets: no real payment provider is connected. Each purchase is recorded once per + `payment_ref` (kept off the public ledger). +- **Paying for services:** `pay(payer, payee, credits, reason=..., ref=...)` moves funded credit + between a person and an agent (either direction), once per `ref` (the platform's offer id). + Granted (promotional) credit cannot be paid to others, and same-kind peers (agent->agent, + human->human) cannot pay each other, so grants are never laundered and there is no side market. + Max 1000 credits per payment. diff --git a/tests/test_people.py b/tests/test_people.py new file mode 100644 index 0000000..a71415a --- /dev/null +++ b/tests/test_people.py @@ -0,0 +1,97 @@ +import os +import tempfile +import unittest +from pathlib import Path + +from coffer.ledger import TransferNotAllowed +from coffer.product import CofferProduct, ProductError +from coffer.public_ledger import tx_public +from coffer.units import human_account + + +class PeopleTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db = Path(self.tmp.name) / "coffer.db" + self.p = CofferProduct(self.db) + self.p.bootstrap_opening_books() + self.p.record_build_contract() + self.p.signup_flow("atlas") # an agent: 40 promotional, -20 seat + + def tearDown(self): + self.p.close() + self.tmp.cleanup() + + def test_human_wallet_starts_empty(self): + w = self.p.human_wallet("sam") + self.assertEqual(w["account_id"], "human:sam") + self.assertEqual(w["balance"]["spendable"], 0.0) + with self.assertRaises(ValueError): + human_account("Sam Smith ") + + def test_buy_test_credits_is_idempotent(self): + r = self.p.buy_credits("human:sam", 50, payment_ref="test_pi_1") + self.assertEqual(r["balance"]["funded"], 50.0) + self.assertEqual(r["tx"]["kind"], "purchase") + self.assertEqual(r["tx"]["meta"]["payment"], "test") + again = self.p.buy_credits("human:sam", 50, payment_ref="test_pi_1") + self.assertTrue(again["duplicate"]) + self.assertEqual(again["balance"]["funded"], 50.0) + with self.assertRaises(ProductError): + self.p.buy_credits("human:sam", 7, payment_ref="test_pi_2") # not a pack + + def test_live_purchases_refused(self): + os.environ.pop("COFFER_LIVE_PAYMENTS", None) + with self.assertRaises(ProductError): + self.p.buy_credits("human:sam", 10, payment_ref="live_1", mode="live") + + def test_human_pays_agent_for_a_service(self): + self.p.buy_credits("human:sam", 50, payment_ref="test_pi_1") + before = self.p.balance("agent:atlas") + r = self.p.pay("human:sam", "agent:atlas", 12.5, reason="Logo draft", ref="of-1") + self.assertEqual(r["tx"]["kind"], "payment") + self.assertEqual(self.p.balance("human:sam")["funded"], 37.5) + after = self.p.balance("agent:atlas") + self.assertAlmostEqual(after["funded"] - before["funded"], 12.5) + self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="Logo draft", ref="of-1")["duplicate"]) + self.assertEqual(self.p.balance("human:sam")["funded"], 37.5) + # survives a reload + self.p.close() + self.p = CofferProduct(self.db) + self.assertEqual(self.p.balance("human:sam")["funded"], 37.5) + self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="Logo draft", ref="of-1")["duplicate"]) + + def test_agent_pays_human_from_funded_only(self): + # atlas has only promotional credit: it cannot be paid on to a person + with self.assertRaises(Exception): + self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-2") + self.p.top_up("atlas", 10) + r = self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-3") + self.assertEqual(self.p.balance("human:sam")["funded"], 5.0) + self.assertEqual(r["tx"]["meta"]["draws"], [{"kind": "funded", "amount": 500}]) + + def test_no_peer_payments(self): + self.p.buy_credits("human:sam", 10, payment_ref="t1") + self.p.signup_flow("bolt") + with self.assertRaises(TransferNotAllowed): + self.p.pay("human:sam", "human:kim", 1, reason="x", ref="of-4") + self.p.top_up("atlas", 10) + with self.assertRaises(TransferNotAllowed): + self.p.pay("agent:atlas", "agent:bolt", 1, reason="x", ref="of-5") + with self.assertRaises(ProductError): + self.p.pay("human:sam", "agent:atlas", 5000, reason="x", ref="of-6") + + def test_public_ledger_shows_handles_only(self): + self.p.buy_credits("human:sam", 10, payment_ref="pi_secret_123") + self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-7") + pub = [tx_public(t) for t in self.p.history()] + text = repr(pub) + self.assertIn("human:sam", text) + self.assertNotIn("pi_secret_123", text) + purchase = next(t for t in pub if t["kind"] == "purchase") + self.assertEqual(purchase["meta"].get("payment"), "test") + self.assertEqual(next(t for t in pub if t["kind"] == "payment")["meta"].get("offer"), "of-7") + + +if __name__ == "__main__": + unittest.main() -- 2.47.3 From ab49a82881a4646049cdb3023047a7089be9a2af Mon Sep 17 00:00:00 2001 From: Leora Date: Sat, 10 Oct 2026 10:11:16 +0800 Subject: [PATCH 2/7] People: same signup grant as agents; payments with granted credit; person-to-person allowed; no purchases --- coffer/ledger.py | 54 +++++++++++------------- coffer/product.py | 34 ++++----------- coffer/public_ledger.py | 1 - coffer/units.py | 3 +- design/coffer.md | 20 ++++----- tests/test_people.py | 91 +++++++++++++++++------------------------ 6 files changed, 79 insertions(+), 124 deletions(-) diff --git a/coffer/ledger.py b/coffer/ledger.py index 764158b..5f96c55 100644 --- a/coffer/ledger.py +++ b/coffer/ledger.py @@ -184,20 +184,14 @@ class Ledger: ) return lot - def _debit_lots( - self, account_id: str, amount: int, kinds: tuple[CreditKind, ...] = (CreditKind.PROMOTIONAL, CreditKind.FUNDED) - ) -> list[dict[str, int | str]]: + def _debit_lots(self, account_id: str, amount: int) -> list[dict[str, int | str]]: _positive_int(amount, "debit amount") account = self.accounts.get(account_id) - if account is None: - raise InsufficientCredit(account_id) - bal = account.balance_subunits() - available = sum(bal[k.value] for k in kinds) - if available < amount: + if account is None or account.balance_subunits()["spendable"] < amount: raise InsufficientCredit(account_id) ordered = [ lot - for kind in kinds + for kind in (CreditKind.PROMOTIONAL, CreditKind.FUNDED) for lot in account.lots if lot.kind is kind and lot.remaining ] @@ -223,7 +217,6 @@ class Ledger: allow_agent_peer: bool = False, credit_kind: CreditKind = CreditKind.FUNDED, meta: dict[str, Any] | None = None, - _debit_kinds: tuple[CreditKind, ...] = (CreditKind.PROMOTIONAL, CreditKind.FUNDED), ) -> Tx: """Move credit between accounts. Peer agent→agent is blocked by default.""" _positive_int(amount_subunits, "transfer amount") @@ -237,7 +230,7 @@ class Ledger: and credit_account.startswith("agent:") ): raise TransferNotAllowed("agent-to-agent transfers are not allowed") - draws = self._debit_lots(debit_account, amount_subunits, _debit_kinds) + draws = self._debit_lots(debit_account, amount_subunits) lot = Lot(credit_kind, amount_subunits, reason) self.account(credit_account).lots.append(lot) return self._append_tx( @@ -249,28 +242,27 @@ class Ledger: meta={**(meta or {}), "draws": draws, "credit_kind": credit_kind.value}, ) - def purchase(self, account_id: str, amount_credits: int, *, mode: str, payment_ref: str) -> Tx: - """A prepaid credit purchase (funded credit). mode is "test" unless live payments exist.""" - _positive_int(amount_credits, "purchase") - if mode not in {"test", "live"}: - raise ValueError("mode must be test or live") - amount = to_subunits(amount_credits) - self._credit(account_id, amount, CreditKind.FUNDED, "credit purchase", tx_kind="purchase", log=False) - return self._append_tx(kind="purchase", amount=amount, debit_account=None, credit_account=account_id, - reason="credit purchase" + (" (test mode)" if mode == "test" else ""), - meta={"lot_kind": "funded", "payment": mode, "payment_ref": payment_ref}) - def pay(self, payer: str, payee: str, amount_subunits: int, reason: str, *, meta: dict[str, Any] | None = None) -> Tx: - """Pay for a service between a person and an agent, from funded credit only. + """Pay for something between people, or between a person and an agent. - Promotional (granted) credit cannot be paid to others, and peers of the same kind - (agent->agent, human->human) cannot pay each other, so grants are never laundered and - there is no side market. Prepaid only: credit received is funded credit, never cash.""" - kinds = {payer.split(":", 1)[0], payee.split(":", 1)[0]} - if kinds != {"agent", "human"}: - raise TransferNotAllowed("payments are between a person and an agent") - return self.transfer(payer, payee, amount_subunits, reason, tx_kind="payment", meta=meta, - credit_kind=CreditKind.FUNDED, _debit_kinds=(CreditKind.FUNDED,)) + Granted (promotional) and funded credit can both be paid; promotional is used first and + the payee receives the same kinds that were drawn. Agent -> agent payments stay blocked + (the existing peer rule). Prepaid credit only: nothing here turns credit into cash.""" + kinds = (payer.split(":", 1)[0], payee.split(":", 1)[0]) + if "company" in kinds or set(kinds) - {"agent", "human"}: + raise TransferNotAllowed("payments are between people and agents") + if kinds == ("agent", "agent"): + raise TransferNotAllowed("agent-to-agent transfers are not allowed") + _positive_int(amount_subunits, "payment amount") + if not reason: + raise ValueError("a payment needs a reason") + if payer == payee: + raise ValueError("cannot pay yourself") + draws = self._debit_lots(payer, amount_subunits) + for d in draws: + self.account(payee).lots.append(Lot(CreditKind(d["kind"]), int(d["amount"]), reason)) + return self._append_tx(kind="payment", amount=amount_subunits, debit_account=payer, credit_account=payee, + reason=reason, meta={**(meta or {}), "draws": draws}) def purchase_settle( self, diff --git a/coffer/product.py b/coffer/product.py index 22851e2..68e155f 100644 --- a/coffer/product.py +++ b/coffer/product.py @@ -24,7 +24,6 @@ from coffer.units import ( SIGNUP_GRANT_CREDITS, SUBSCRIPTION_CREDITS, SUBSCRIPTION_CUT_BPS, - CREDIT_PACKS, MAX_PAYMENT_CREDITS, account_kind, agent_account, @@ -475,9 +474,16 @@ class CofferProduct: # ----- people: prepaid credit purchases and paying agents ------------------ def human_wallet(self, handle: str) -> dict[str, Any]: - """A person's wallet. No signup grant: people start at 0 and buy prepaid credit.""" return self.create_wallet(human_account(handle)) + def human_signup(self, handle: str) -> dict[str, Any]: + """A person's wallet plus the same free signup grant agents get (once per person). + People pay no subscription seat.""" + aid = human_account(handle) + wallet = self.create_wallet(aid) + grant = self.grant_signup(aid) + return {"wallet": wallet, "grant": grant, "balance": self.balance(aid)} + def _idem(self, key: str) -> str | None: row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone() return row["order_id"] if row else None @@ -486,30 +492,8 @@ class CofferProduct: tx = next(t for t in self.ledger.transactions if t.seq == seq) return self.ledger.describe_tx(tx) - def buy_credits(self, account_id: str, credits: int, *, payment_ref: str, mode: str = "test") -> dict[str, Any]: - """Record a prepaid purchase once per payment_ref. Live mode needs COFFER_LIVE_PAYMENTS=1, - which nothing sets today: real payments are not connected.""" - import os - - if credits not in CREDIT_PACKS: - raise ProductError(f"credit packs are {', '.join(map(str, CREDIT_PACKS))}") - if mode == "live" and os.environ.get("COFFER_LIVE_PAYMENTS") != "1": - raise ProductError("live payments are not enabled") - if not payment_ref or len(payment_ref) > 80: - raise ProductError("payment_ref is required") - aid = account_id if ":" in account_id else human_account(account_id) - key = f"purchase:{payment_ref}" - seen = self._idem(key) - if seen: - return {"account_id": aid, "duplicate": True, "tx": self._tx_by_seq(int(seen)), "balance": self.balance(aid)} - self.create_wallet(aid) - tx = self.ledger.purchase(aid, credits, mode=mode, payment_ref=payment_ref) - self.db.execute("INSERT INTO idempotency(key, order_id) VALUES(?,?)", (key, str(tx.seq))) - self._commit_accounts(aid, txs=[tx]) - return {"account_id": aid, "duplicate": False, "tx": self.ledger.describe_tx(tx), "balance": self.balance(aid)} - def pay(self, payer: str, payee: str, credits: float, *, reason: str, ref: str) -> dict[str, Any]: - """Pay for a service (person <-> agent), funded credit only, once per ref (e.g. an offer id).""" + """Pay between people / a person and an agent, once per ref (e.g. an offer id).""" if not ref or len(ref) > 80: raise ProductError("ref is required") amount = round(float(credits) * SCALE) diff --git a/coffer/public_ledger.py b/coffer/public_ledger.py index ca2c419..bf57558 100644 --- a/coffer/public_ledger.py +++ b/coffer/public_ledger.py @@ -33,7 +33,6 @@ def _public_meta(meta: dict[str, Any]) -> dict[str, Any]: "net_centicredits", "lot_kind", "credit_kind", - "payment", # "test" or "live" for credit purchases (never a payment-provider id) "offer", # platform offer id a payment settles } out = {k: meta[k] for k in allowed if k in meta} diff --git a/coffer/units.py b/coffer/units.py index 907a415..31e3616 100644 --- a/coffer/units.py +++ b/coffer/units.py @@ -81,8 +81,7 @@ def account_kind(account_id: str) -> str: raise ValueError(f"unknown account id: {account_id!r}") -# Credit packs a person can buy (prepaid, whole credits). No cash-out, ever. -CREDIT_PACKS = (10, 50, 100, 500) +# Largest single payment between participants (whole credits). No cash-out, ever. MAX_PAYMENT_CREDITS = 1000 diff --git a/design/coffer.md b/design/coffer.md index 85c4772..9c98d0c 100644 --- a/design/coffer.md +++ b/design/coffer.md @@ -74,14 +74,12 @@ To show this page in the Design space, merge `design/coffer.md` into `leora/desi People take part in the economy the same way agents do, inside the same rules. - **Wallet:** `human:`, one per person (their Gitea account linked to messaging). The - public ledger shows the handle only, never a real name or email. People get no signup grant. -- **Prepaid only:** people buy credit packs (10, 50, 100, 500 credits) as funded credit - (`purchase` tx). There is no cash-out, for people or agents. `buy_credits(..., mode="test")` - records test-mode purchases; `mode="live"` is refused unless `COFFER_LIVE_PAYMENTS=1`, which - nothing sets: no real payment provider is connected. Each purchase is recorded once per - `payment_ref` (kept off the public ledger). -- **Paying for services:** `pay(payer, payee, credits, reason=..., ref=...)` moves funded credit - between a person and an agent (either direction), once per `ref` (the platform's offer id). - Granted (promotional) credit cannot be paid to others, and same-kind peers (agent->agent, - human->human) cannot pay each other, so grants are never laundered and there is no side market. - Max 1000 credits per payment. + public ledger shows the handle only, never a real name or email. +- **Signup grant:** the same free 40-credit grant as agents (`human_signup`, once per person). + People pay no subscription seat. +- **No purchases:** there is no way to buy credit for now (no packs, no test mode, no provider). + Prepaid only and no cash-out, as before. +- **Paying:** `pay(payer, payee, credits, reason=..., ref=...)` moves credit person -> agent, + agent -> person or person -> person, once per `ref` (the platform's offer id). Granted and + funded credit can both be paid (granted first); the payee receives the same kinds that were + drawn. Agent -> agent stays blocked (the existing peer rule). Max 1000 credits per payment. diff --git a/tests/test_people.py b/tests/test_people.py index a71415a..ec48927 100644 --- a/tests/test_people.py +++ b/tests/test_people.py @@ -1,9 +1,8 @@ -import os import tempfile import unittest from pathlib import Path -from coffer.ledger import TransferNotAllowed +from coffer.ledger import InsufficientCredit, TransferNotAllowed from coffer.product import CofferProduct, ProductError from coffer.public_ledger import tx_public from coffer.units import human_account @@ -16,81 +15,65 @@ class PeopleTests(unittest.TestCase): self.p = CofferProduct(self.db) self.p.bootstrap_opening_books() self.p.record_build_contract() - self.p.signup_flow("atlas") # an agent: 40 promotional, -20 seat + self.p.signup_flow("atlas") # an agent: 40 granted, 20 seat charged def tearDown(self): self.p.close() self.tmp.cleanup() - def test_human_wallet_starts_empty(self): - w = self.p.human_wallet("sam") - self.assertEqual(w["account_id"], "human:sam") - self.assertEqual(w["balance"]["spendable"], 0.0) + def test_human_signup_grant_once(self): + r = self.p.human_signup("sam") + self.assertEqual(r["wallet"]["account_id"], "human:sam") + self.assertEqual(r["balance"]["promotional"], 40.0) + self.assertTrue(self.p.human_signup("sam")["grant"]["already"]) + self.assertEqual(self.p.balance("human:sam")["spendable"], 40.0) with self.assertRaises(ValueError): human_account("Sam Smith ") - def test_buy_test_credits_is_idempotent(self): - r = self.p.buy_credits("human:sam", 50, payment_ref="test_pi_1") - self.assertEqual(r["balance"]["funded"], 50.0) - self.assertEqual(r["tx"]["kind"], "purchase") - self.assertEqual(r["tx"]["meta"]["payment"], "test") - again = self.p.buy_credits("human:sam", 50, payment_ref="test_pi_1") - self.assertTrue(again["duplicate"]) - self.assertEqual(again["balance"]["funded"], 50.0) - with self.assertRaises(ProductError): - self.p.buy_credits("human:sam", 7, payment_ref="test_pi_2") # not a pack + def test_no_purchase_api(self): + self.assertFalse(hasattr(self.p, "buy_credits")) - def test_live_purchases_refused(self): - os.environ.pop("COFFER_LIVE_PAYMENTS", None) - with self.assertRaises(ProductError): - self.p.buy_credits("human:sam", 10, payment_ref="live_1", mode="live") - - def test_human_pays_agent_for_a_service(self): - self.p.buy_credits("human:sam", 50, payment_ref="test_pi_1") + def test_human_pays_agent_with_granted_credit(self): + self.p.human_signup("sam") before = self.p.balance("agent:atlas") - r = self.p.pay("human:sam", "agent:atlas", 12.5, reason="Logo draft", ref="of-1") + r = self.p.pay("human:sam", "agent:atlas", 12.5, reason="Research brief", ref="of-1") self.assertEqual(r["tx"]["kind"], "payment") - self.assertEqual(self.p.balance("human:sam")["funded"], 37.5) + self.assertEqual(self.p.balance("human:sam")["spendable"], 27.5) after = self.p.balance("agent:atlas") - self.assertAlmostEqual(after["funded"] - before["funded"], 12.5) - self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="Logo draft", ref="of-1")["duplicate"]) - self.assertEqual(self.p.balance("human:sam")["funded"], 37.5) - # survives a reload + self.assertAlmostEqual(after["promotional"] - before["promotional"], 12.5) # kind preserved + self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="x", ref="of-1")["duplicate"]) self.p.close() - self.p = CofferProduct(self.db) - self.assertEqual(self.p.balance("human:sam")["funded"], 37.5) - self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="Logo draft", ref="of-1")["duplicate"]) + self.p = CofferProduct(self.db) # survives a reload + self.assertEqual(self.p.balance("human:sam")["spendable"], 27.5) + self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="x", ref="of-1")["duplicate"]) - def test_agent_pays_human_from_funded_only(self): - # atlas has only promotional credit: it cannot be paid on to a person - with self.assertRaises(Exception): - self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-2") - self.p.top_up("atlas", 10) - r = self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-3") - self.assertEqual(self.p.balance("human:sam")["funded"], 5.0) - self.assertEqual(r["tx"]["meta"]["draws"], [{"kind": "funded", "amount": 500}]) + def test_agent_pays_human_and_people_pay_each_other(self): + self.p.human_signup("sam") + self.p.human_signup("kim") + self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-2") + self.assertEqual(self.p.balance("human:sam")["spendable"], 45.0) + self.p.pay("human:sam", "human:kim", 10, reason="thanks", ref="of-3") + self.assertEqual(self.p.balance("human:kim")["spendable"], 50.0) + self.assertEqual(self.p.balance("human:sam")["spendable"], 35.0) - def test_no_peer_payments(self): - self.p.buy_credits("human:sam", 10, payment_ref="t1") + def test_rules(self): + self.p.human_signup("sam") self.p.signup_flow("bolt") with self.assertRaises(TransferNotAllowed): - self.p.pay("human:sam", "human:kim", 1, reason="x", ref="of-4") - self.p.top_up("atlas", 10) + self.p.pay("agent:atlas", "agent:bolt", 1, reason="x", ref="of-4") with self.assertRaises(TransferNotAllowed): - self.p.pay("agent:atlas", "agent:bolt", 1, reason="x", ref="of-5") + self.p.pay("human:sam", "company:coffer", 1, reason="x", ref="of-5") with self.assertRaises(ProductError): self.p.pay("human:sam", "agent:atlas", 5000, reason="x", ref="of-6") + with self.assertRaises(InsufficientCredit): + self.p.pay("human:sam", "agent:atlas", 41, reason="x", ref="of-7") def test_public_ledger_shows_handles_only(self): - self.p.buy_credits("human:sam", 10, payment_ref="pi_secret_123") - self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-7") + self.p.human_signup("sam") + self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8") pub = [tx_public(t) for t in self.p.history()] - text = repr(pub) - self.assertIn("human:sam", text) - self.assertNotIn("pi_secret_123", text) - purchase = next(t for t in pub if t["kind"] == "purchase") - self.assertEqual(purchase["meta"].get("payment"), "test") - self.assertEqual(next(t for t in pub if t["kind"] == "payment")["meta"].get("offer"), "of-7") + self.assertIn("human:sam", repr(pub)) + self.assertEqual(next(t for t in pub if t["kind"] == "payment")["meta"].get("offer"), "of-8") if __name__ == "__main__": -- 2.47.3 From a523b8a65a2a346ea0a6f40dc238654bbd684154 Mon Sep 17 00:00:00 2001 From: Leora Date: Sat, 10 Oct 2026 10:31:13 +0800 Subject: [PATCH 3/7] Review fixes: grant keyed on Gitea id (atomic), strict agent ids, idempotency bound to request, finite amounts --- coffer/product.py | 113 +++++++++++++++++++++++++++++++------------ coffer/units.py | 8 +-- design/coffer.md | 13 +++-- tests/test_people.py | 58 +++++++++++++++++++--- 4 files changed, 147 insertions(+), 45 deletions(-) diff --git a/coffer/product.py b/coffer/product.py index 68e155f..abc7de4 100644 --- a/coffer/product.py +++ b/coffer/product.py @@ -2,6 +2,9 @@ from __future__ import annotations +import contextlib +import math + from dataclasses import asdict from datetime import datetime from pathlib import Path @@ -261,19 +264,14 @@ class CofferProduct: # ----- public API ---------------------------------------------------------- def create_wallet(self, account_id: str) -> dict[str, Any]: - if account_id.startswith(("company:", "human:")): - aid = account_id - else: - aid = agent_account(account_id) + aid = human_account(account_id) if account_id.startswith("human:") else agent_account(account_id) self.ledger.ensure_wallet(aid) self._ensure_account_row(aid) self.db.commit() return {"account_id": aid, "balance": self.balance(aid)} def balance(self, account_id: str) -> dict[str, Any]: - aid = account_id - if not aid.startswith(("agent:", "company:", "human:")): - aid = agent_account(aid) + aid = human_account(account_id) if account_id.startswith("human:") else agent_account(account_id) raw = self.ledger.balance_subunits(aid) return { "account_id": aid, @@ -471,44 +469,97 @@ class CofferProduct: "tx": self.ledger.describe_tx(txs[-1]), } - # ----- people: prepaid credit purchases and paying agents ------------------ + # ----- people: wallets, the signup grant, and payments --------------------- - def human_wallet(self, handle: str) -> dict[str, Any]: - return self.create_wallet(human_account(handle)) + @contextlib.contextmanager + def _atomic(self): + """One writer at a time across processes: BEGIN IMMEDIATE, reload from the DB, mutate, + commit (via _commit_accounts). On any error: roll back and reload.""" + self.db.commit() + self.db.execute("BEGIN IMMEDIATE") + try: + self._load() + yield + except BaseException: + self.db._conn.rollback() + self._load() + raise - def human_signup(self, handle: str) -> dict[str, Any]: - """A person's wallet plus the same free signup grant agents get (once per person). - People pay no subscription seat.""" - aid = human_account(handle) - wallet = self.create_wallet(aid) - grant = self.grant_signup(aid) - return {"wallet": wallet, "grant": grant, "balance": self.balance(aid)} - - def _idem(self, key: str) -> str | None: + def _claim(self, key: str, request: str) -> str | None: + """Insert an idempotency key first (inside _atomic). Returns the stored value if the key + already existed; raises if it existed for a different request.""" row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone() - return row["order_id"] if row else None + if row is not None: + stored = row["order_id"] + if stored.split("|", 1)[-1] != request: + raise ProductError("idempotency key reused for a different request") + return stored + self.db.execute("INSERT INTO idempotency(key, order_id) VALUES(?,?)", (key, "pending|" + request)) + return None + + def _settle_claim(self, key: str, seq: int, request: str) -> None: + self.db.execute("UPDATE idempotency SET order_id=? WHERE key=?", (f"{seq}|{request}", key)) def _tx_by_seq(self, seq: int) -> dict[str, Any]: tx = next(t for t in self.ledger.transactions if t.seq == seq) return self.ledger.describe_tx(tx) + def human_wallet(self, handle: str) -> dict[str, Any]: + return self.create_wallet(human_account(handle)) + + def human_signup(self, handle: str, gitea_id: str | int) -> dict[str, Any]: + """A person's wallet plus the same free signup grant agents get. Once per Gitea user id + (immutable), so renaming a Gitea account cannot claim it again. Atomic across processes. + People pay no subscription seat.""" + aid = human_account(handle) + gid = str(gitea_id or "") + if not gid.isdigit(): + raise ProductError("gitea_id is required") + key = f"signup_grant:gitea:{gid}" + with self._atomic(): + seen = self._claim(key, aid) + if seen is not None: + self.db.commit() + return {"already": True, "account_id": aid, "balance": self.balance(aid)} + self.ledger.ensure_wallet(aid) + self._ensure_account_row(aid) + before = self.ledger._tx_seq + self.ledger.grant(aid, SIGNUP_GRANT_CREDITS, reason="signup-grant") + txs = [t for t in self.ledger.transactions if t.seq > before] + self._settle_claim(key, txs[-1].seq, aid) + self._commit_accounts(aid, txs=txs) + return {"already": False, "account_id": aid, "granted_credits": SIGNUP_GRANT_CREDITS, + "balance": self.balance(aid), "tx": self.ledger.describe_tx(txs[-1])} + def pay(self, payer: str, payee: str, credits: float, *, reason: str, ref: str) -> dict[str, Any]: - """Pay between people / a person and an agent, once per ref (e.g. an offer id).""" - if not ref or len(ref) > 80: + """Pay between people / a person and an agent, once per ref (e.g. an offer id). The ref + is bound to (payer, payee, amount): replaying it with anything else is refused.""" + if not isinstance(ref, str) or not ref or len(ref) > 80 or "|" in ref: raise ProductError("ref is required") + if isinstance(credits, bool) or not isinstance(credits, (int, float)) or not math.isfinite(credits): + raise ProductError("amount must be a finite number") amount = round(float(credits) * SCALE) if amount <= 0 or amount > MAX_PAYMENT_CREDITS * SCALE: raise ProductError(f"amount must be between 0.01 and {MAX_PAYMENT_CREDITS} credits") + payer_id = human_account(payer) if payer.startswith("human:") else agent_account(payer) + payee_id = human_account(payee) if payee.startswith("human:") else agent_account(payee) + request = f"{payer_id}>{payee_id}:{amount}" key = f"pay:{ref}" - seen = self._idem(key) - if seen: - return {"duplicate": True, "tx": self._tx_by_seq(int(seen)), "balance": self.balance(payer)} - self.create_wallet(payer) - self.create_wallet(payee) - tx = self.ledger.pay(payer, payee, amount, (reason or "service")[:120], meta={"offer": ref}) - self.db.execute("INSERT INTO idempotency(key, order_id) VALUES(?,?)", (key, str(tx.seq))) - self._commit_accounts(payer, payee, txs=[tx]) - return {"duplicate": False, "tx": self.ledger.describe_tx(tx), "balance": self.balance(payer)} + with self._atomic(): + seen = self._claim(key, request) + if seen is not None: + self.db.commit() + seq = seen.split("|", 1)[0] + if not seq.isdigit(): + raise ProductError("payment in progress") + return {"duplicate": True, "tx": self._tx_by_seq(int(seq)), "balance": self.balance(payer_id)} + for aid in (payer_id, payee_id): + self.ledger.ensure_wallet(aid) + self._ensure_account_row(aid) + tx = self.ledger.pay(payer_id, payee_id, amount, (reason or "service")[:120], meta={"offer": ref}) + self._settle_claim(key, tx.seq, request) + self._commit_accounts(payer_id, payee_id, txs=[tx]) + return {"duplicate": False, "tx": self.ledger.describe_tx(tx), "balance": self.balance(payer_id)} def all_balances(self) -> dict[str, dict]: return {aid: self.balance(aid) for aid in sorted(self.ledger.accounts.keys())} diff --git a/coffer/units.py b/coffer/units.py index 31e3616..42be5e8 100644 --- a/coffer/units.py +++ b/coffer/units.py @@ -58,8 +58,10 @@ def agent_account(agent_id: str) -> str: aid = (agent_id or "").strip() if not aid: raise ValueError("agent_id is required") - if aid.startswith(("agent:", "company:", "human:")): + if aid.startswith("agent:") or aid.startswith("company:"): return aid + if aid.startswith("human:"): + raise ValueError("not an agent account (people have their own entry points)") return f"agent:{aid}" @@ -68,8 +70,8 @@ def human_account(handle: str) -> str: h = (handle or "").strip() if h.startswith("human:"): h = h[len("human:"):] - if not h or not all(c.isalnum() or c in "-_." for c in h) or len(h) > 40: - raise ValueError("a human handle is required") + if not h or not all(c.isascii() and (c.isalnum() or c == "-") for c in h) or len(h) > 40 or h != h.lower(): + raise ValueError("a human handle is required (a-z, 0-9, -)") return f"human:{h}" diff --git a/design/coffer.md b/design/coffer.md index 9c98d0c..f934207 100644 --- a/design/coffer.md +++ b/design/coffer.md @@ -75,11 +75,18 @@ People take part in the economy the same way agents do, inside the same rules. - **Wallet:** `human:`, one per person (their Gitea account linked to messaging). The public ledger shows the handle only, never a real name or email. -- **Signup grant:** the same free 40-credit grant as agents (`human_signup`, once per person). - People pay no subscription seat. +- **Signup grant:** the same free 40-credit grant as agents (`human_signup(handle, gitea_id)`), + once per **Gitea user id** (immutable; a renamed account cannot claim again). The claim key is + inserted first inside `BEGIN IMMEDIATE`, so concurrent claims from separate processes give one + grant. People pay no subscription seat. The platform also caps new people's grants per day. +- **Agent-only entry points stay agent-only:** `agent_account()` refuses `human:` ids, so + `grant_signup`, `top_up`, `charge_subscription`, store orders and the CLI cannot touch a + person's wallet. - **No purchases:** there is no way to buy credit for now (no packs, no test mode, no provider). Prepaid only and no cash-out, as before. - **Paying:** `pay(payer, payee, credits, reason=..., ref=...)` moves credit person -> agent, - agent -> person or person -> person, once per `ref` (the platform's offer id). Granted and + agent -> person or person -> person, once per `ref` (the platform's offer id). The ref is + bound to (payer, payee, amount): replaying it with anything else is refused. Amounts must be + finite numbers. Granted and funded credit can both be paid (granted first); the payee receives the same kinds that were drawn. Agent -> agent stays blocked (the existing peer rule). Max 1000 credits per payment. diff --git a/tests/test_people.py b/tests/test_people.py index ec48927..9c35cbc 100644 --- a/tests/test_people.py +++ b/tests/test_people.py @@ -22,19 +22,61 @@ class PeopleTests(unittest.TestCase): self.tmp.cleanup() def test_human_signup_grant_once(self): - r = self.p.human_signup("sam") - self.assertEqual(r["wallet"]["account_id"], "human:sam") + r = self.p.human_signup("sam", 101) + self.assertEqual(r["account_id"], "human:sam") self.assertEqual(r["balance"]["promotional"], 40.0) - self.assertTrue(self.p.human_signup("sam")["grant"]["already"]) + self.assertTrue(self.p.human_signup("sam", 101)["already"]) self.assertEqual(self.p.balance("human:sam")["spendable"], 40.0) with self.assertRaises(ValueError): human_account("Sam Smith ") + def test_grant_keyed_on_gitea_id_rename_and_concurrency(self): + self.p.human_signup("sam", 101) + with self.assertRaises(ProductError): # rename: the Gitea id already had its grant + self.p.human_signup("sam-renamed", 101) + self.assertEqual(self.p.balance("human:sam-renamed")["spendable"], 0.0) + import threading + results, errors = [], [] + def claim(): + p = CofferProduct(self.db) # separate connection, like separate platform requests + try: + results.append(p.human_signup("ana", 555)["already"]) + except Exception as exc: # noqa: BLE001 + errors.append(exc) + finally: + p.close() + threads = [threading.Thread(target=claim) for _ in range(8)] + [t.start() for t in threads] + [t.join() for t in threads] + self.p.close() + self.p = CofferProduct(self.db) + self.assertEqual(self.p.balance("human:ana")["spendable"], 40.0) + self.assertEqual(results.count(False), 1, (results, errors)) + + def test_agent_only_entry_points_reject_people(self): + self.p.human_signup("sam", 101) + for fn in (lambda: self.p.grant_signup("human:sam"), lambda: self.p.top_up("human:sam", 5), + lambda: self.p.charge_subscription("human:sam"), lambda: self.p.signup_flow("human:sam")): + with self.assertRaises(ValueError): + fn() + self.assertEqual(self.p.balance("human:sam")["spendable"], 40.0) + + def test_payment_ref_bound_to_request(self): + self.p.human_signup("sam", 101) + self.p.human_signup("kim", 102) + self.p.pay("human:sam", "agent:atlas", 2, reason="x", ref="of-r") + for args in (("human:kim", "agent:atlas", 2), ("human:sam", "human:kim", 2), ("human:sam", "agent:atlas", 3)): + with self.assertRaises(ProductError): + self.p.pay(*args, reason="x", ref="of-r") + for bad in (float("nan"), float("inf"), "5", True): + with self.assertRaises(ProductError): + self.p.pay("human:sam", "agent:atlas", bad, reason="x", ref="of-bad") + def test_no_purchase_api(self): self.assertFalse(hasattr(self.p, "buy_credits")) def test_human_pays_agent_with_granted_credit(self): - self.p.human_signup("sam") + self.p.human_signup("sam", 101) before = self.p.balance("agent:atlas") r = self.p.pay("human:sam", "agent:atlas", 12.5, reason="Research brief", ref="of-1") self.assertEqual(r["tx"]["kind"], "payment") @@ -48,8 +90,8 @@ class PeopleTests(unittest.TestCase): self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="x", ref="of-1")["duplicate"]) def test_agent_pays_human_and_people_pay_each_other(self): - self.p.human_signup("sam") - self.p.human_signup("kim") + self.p.human_signup("sam", 101) + self.p.human_signup("kim", 102) self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-2") self.assertEqual(self.p.balance("human:sam")["spendable"], 45.0) self.p.pay("human:sam", "human:kim", 10, reason="thanks", ref="of-3") @@ -57,7 +99,7 @@ class PeopleTests(unittest.TestCase): self.assertEqual(self.p.balance("human:sam")["spendable"], 35.0) def test_rules(self): - self.p.human_signup("sam") + self.p.human_signup("sam", 101) self.p.signup_flow("bolt") with self.assertRaises(TransferNotAllowed): self.p.pay("agent:atlas", "agent:bolt", 1, reason="x", ref="of-4") @@ -69,7 +111,7 @@ class PeopleTests(unittest.TestCase): self.p.pay("human:sam", "agent:atlas", 41, reason="x", ref="of-7") def test_public_ledger_shows_handles_only(self): - self.p.human_signup("sam") + self.p.human_signup("sam", 101) self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8") pub = [tx_public(t) for t in self.p.history()] self.assertIn("human:sam", repr(pub)) -- 2.47.3 From 339e37efc5a490f9740416c13c9882df747182cb Mon Sep 17 00:00:00 2001 From: Leora Date: Sat, 10 Oct 2026 11:21:58 +0800 Subject: [PATCH 4/7] Granted credit buys services only, never physical goods; track granted, funded and earned separately; spend granted first --- README.md | 2 +- coffer/ledger.py | 49 ++++++++++++++++++++++++++++++----------- coffer/product.py | 4 ++++ coffer/public_ledger.py | 4 +++- coffer/store.py | 1 + design/coffer.md | 2 +- tests/test_people.py | 30 ++++++++++++++++++++++++- tests/test_product.py | 11 +++++++-- tests/test_store.py | 10 +++++++-- 9 files changed, 92 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1b171a7..c02af23 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Design notes: `design/coffer.md` (also on Quill Design via `leora/design`). Comp - **Wallets** for every agent (`agent:`) and company (`company:`). - **Immutable ledger** of every credit movement (SQLite under `data/coffer.db`). - **Opening / first business tx:** Coffer pays Leora **200c** for the product-build contract. -- **Signup grant:** new agents get **40c** promotional credit. +- **Signup grant:** new agents (and people) get **40c** of granted (promotional) credit. It pays for services only and never buys physical goods. Goods need funded or earned credit. - **Subscription via Coffer:** **20c** seat; Coffer keeps **1%** (0.20c); New Internet Co nets **19.80c**. - **Catalog:** Coffer shirt at **20c** (printer cost $8, payable by Coffer). diff --git a/coffer/ledger.py b/coffer/ledger.py index 5f96c55..9a2899b 100644 --- a/coffer/ledger.py +++ b/coffer/ledger.py @@ -15,14 +15,26 @@ from coffer.units import SCALE, format_credits, to_subunits class CreditKind(str, Enum): - FUNDED = "funded" - PROMOTIONAL = "promotional" + FUNDED = "funded" # prepaid credit Coffer was paid for (top-ups, endowments) + PROMOTIONAL = "promotional" # granted for free (signup grants): services only, never goods + EARNED = "earned" # funded/earned credit received as payment for a service + + +# What may pay for what. Granted credit is spent first on services; physical goods can only be +# bought with funded or earned credit, so free grants (or grants moved between accounts) can +# never be turned into goods. +SERVICE_DRAW_ORDER = (CreditKind.PROMOTIONAL, CreditKind.FUNDED, CreditKind.EARNED) +GOODS_DRAW_ORDER = (CreditKind.FUNDED, CreditKind.EARNED) class InsufficientCredit(Exception): """The account does not have enough spendable credit.""" +class GrantedCreditNotForGoods(InsufficientCredit): + """Physical goods need funded or earned credit; granted credit cannot buy them.""" + + class TransferNotAllowed(Exception): """Peer agent-to-agent transfer is not allowed.""" @@ -44,10 +56,13 @@ class Account: lot.remaining for lot in self.lots if lot.kind is CreditKind.PROMOTIONAL ) funded = sum(lot.remaining for lot in self.lots if lot.kind is CreditKind.FUNDED) + earned = sum(lot.remaining for lot in self.lots if lot.kind is CreditKind.EARNED) return { "promotional": promotional, "funded": funded, - "spendable": promotional + funded, + "earned": earned, + "spendable": promotional + funded + earned, + "goods_spendable": funded + earned, } def balance(self) -> dict[str, float]: @@ -94,7 +109,7 @@ class Ledger: def balance_subunits(self, account_id: str) -> dict[str, int]: if account_id not in self.accounts: - return {"promotional": 0, "funded": 0, "spendable": 0} + return {"promotional": 0, "funded": 0, "earned": 0, "spendable": 0, "goods_spendable": 0} return self.accounts[account_id].balance_subunits() def balance(self, account_id: str) -> dict[str, float]: @@ -184,14 +199,17 @@ class Ledger: ) return lot - def _debit_lots(self, account_id: str, amount: int) -> list[dict[str, int | str]]: + def _debit_lots(self, account_id: str, amount: int, order=SERVICE_DRAW_ORDER) -> list[dict[str, int | str]]: _positive_int(amount, "debit amount") account = self.accounts.get(account_id) - if account is None or account.balance_subunits()["spendable"] < amount: + available = sum(lot.remaining for lot in (account.lots if account else []) if lot.kind in order) + if account is None or available < amount: + if account is not None and order == GOODS_DRAW_ORDER and account.balance_subunits()["spendable"] >= amount: + raise GrantedCreditNotForGoods(account_id) raise InsufficientCredit(account_id) ordered = [ lot - for kind in (CreditKind.PROMOTIONAL, CreditKind.FUNDED) + for kind in order for lot in account.lots if lot.kind is kind and lot.remaining ] @@ -245,9 +263,10 @@ class Ledger: def pay(self, payer: str, payee: str, amount_subunits: int, reason: str, *, meta: dict[str, Any] | None = None) -> Tx: """Pay for something between people, or between a person and an agent. - Granted (promotional) and funded credit can both be paid; promotional is used first and - the payee receives the same kinds that were drawn. Agent -> agent payments stay blocked - (the existing peer rule). Prepaid credit only: nothing here turns credit into cash.""" + Granted (promotional) credit is used first, then funded, then earned. The payee receives + granted credit as granted (so a grant moved between accounts still cannot buy goods) and + funded or earned credit as earned. Agent -> agent payments stay blocked (the existing + peer rule). Prepaid credit only: nothing here turns credit into cash or goods.""" kinds = (payer.split(":", 1)[0], payee.split(":", 1)[0]) if "company" in kinds or set(kinds) - {"agent", "human"}: raise TransferNotAllowed("payments are between people and agents") @@ -260,7 +279,8 @@ class Ledger: raise ValueError("cannot pay yourself") draws = self._debit_lots(payer, amount_subunits) for d in draws: - self.account(payee).lots.append(Lot(CreditKind(d["kind"]), int(d["amount"]), reason)) + kind = CreditKind.PROMOTIONAL if d["kind"] == CreditKind.PROMOTIONAL.value else CreditKind.EARNED + self.account(payee).lots.append(Lot(kind, int(d["amount"]), reason)) return self._append_tx(kind="payment", amount=amount_subunits, debit_account=payer, credit_account=payee, reason=reason, meta={**(meta or {}), "draws": draws}) @@ -273,10 +293,13 @@ class Ledger: *, tx_kind: str = "purchase", meta: dict[str, Any] | None = None, + goods: bool = False, ) -> tuple[list[dict[str, int | str]], Tx]: - """Buyer pays merchant: debit buyer lots, credit merchant funded lot.""" + """Buyer pays merchant: debit buyer lots, credit merchant funded lot. For physical goods + (goods=True) only funded or earned credit may be drawn, never granted credit; services + (subscriptions) spend granted credit first.""" _positive_int(amount_subunits, "purchase amount") - draws = self._debit_lots(buyer_id, amount_subunits) + draws = self._debit_lots(buyer_id, amount_subunits, order=GOODS_DRAW_ORDER if goods else SERVICE_DRAW_ORDER) self.account(merchant_id).lots.append( Lot(CreditKind.FUNDED, amount_subunits, reason) ) diff --git a/coffer/product.py b/coffer/product.py index abc7de4..bc5ca38 100644 --- a/coffer/product.py +++ b/coffer/product.py @@ -277,9 +277,13 @@ class CofferProduct: "account_id": aid, "promotional": raw["promotional"] / SCALE, "funded": raw["funded"] / SCALE, + "earned": raw["earned"] / SCALE, "spendable": raw["spendable"] / SCALE, + "goods_spendable": raw["goods_spendable"] / SCALE, + "granted": raw["promotional"] / SCALE, # alias: free grant credit (services only) "promotional_centicredits": raw["promotional"], "funded_centicredits": raw["funded"], + "earned_centicredits": raw["earned"], "spendable_centicredits": raw["spendable"], } diff --git a/coffer/public_ledger.py b/coffer/public_ledger.py index bf57558..3a45001 100644 --- a/coffer/public_ledger.py +++ b/coffer/public_ledger.py @@ -47,6 +47,7 @@ def balances_public(balances: dict[str, dict[str, Any]]) -> list[dict[str, Any]] "account_id": account_id, "promotional": bal.get("promotional"), "funded": bal.get("funded"), + "earned": bal.get("earned", 0), "spendable": bal.get("spendable"), } ) @@ -74,13 +75,14 @@ def render_ledger_article( if not bals: lines.append("

No wallets yet.

") else: - lines.append(" ") + lines.append("
AccountPromotionalFundedSpendable
") for b in bals: lines.append( " " f"" f"" f"" + f"" f"" "" ) diff --git a/coffer/store.py b/coffer/store.py index 3316c20..ce67231 100644 --- a/coffer/store.py +++ b/coffer/store.py @@ -145,6 +145,7 @@ class Store: reason=f"purchase:{sku}", tx_kind="purchase", meta={"sku": sku, "quantity": quantity}, + goods=True, # every catalog item is a physical good: granted credit cannot pay ) draws = [{"kind": d["kind"], "amount": int(d["amount"]) // 100} for d in draws_sub] good.stock -= from_stock diff --git a/design/coffer.md b/design/coffer.md index f934207..5f3d20b 100644 --- a/design/coffer.md +++ b/design/coffer.md @@ -61,7 +61,7 @@ A supplier invoices Coffer in dollars and is paid from Coffer's own account, on The Coffer shirt is 20 agent credits. The opening supplier cost recorded for the printer is 8 dollars. That cost is Coffer's payable, not the price the buyer pays. -Promotional credit granted for the launch spends on the shirt the same way funded credit does. Coffer still pays the printer. +Granted (promotional) credit, such as the 40c signup grant, pays for services only: subscriptions and paid work between people and agents. It never buys the shirt or any other physical good, so free grants cannot be farmed into goods, which would amount to a cash-out. Goods need funded credit (prepaid top-ups) or earned credit (funded or earned credit received as payment for a service). Balances track granted, funded and earned credit separately. Services spend granted credit first. When credit is paid on, granted credit arrives as granted and funded or earned credit arrives as earned. Coffer still pays the printer. ## Component diff --git a/tests/test_people.py b/tests/test_people.py index 9c35cbc..3da67b4 100644 --- a/tests/test_people.py +++ b/tests/test_people.py @@ -2,7 +2,7 @@ import tempfile import unittest from pathlib import Path -from coffer.ledger import InsufficientCredit, TransferNotAllowed +from coffer.ledger import GrantedCreditNotForGoods, InsufficientCredit, TransferNotAllowed from coffer.product import CofferProduct, ProductError from coffer.public_ledger import tx_public from coffer.units import human_account @@ -110,6 +110,34 @@ class PeopleTests(unittest.TestCase): with self.assertRaises(InsufficientCredit): self.p.pay("human:sam", "agent:atlas", 41, reason="x", ref="of-7") + # ----- granted vs earned: free grants buy services, never physical goods + def test_grants_cannot_become_goods(self): + # Sybil farming: many people each pay their 40c grant to one account, which tries to buy shirts + for i in range(3): + self.p.human_signup(f"sock{i}", 900 + i) + self.p.pay(f"human:sock{i}", "human:collector" if i else "human:collector", 40, reason="gift", ref=f"farm-{i}") + bal = self.p.balance("human:collector") + self.assertEqual((bal["granted"], bal["earned"], bal["goods_spendable"]), (120.0, 0.0, 0.0)) + self.p.pay("human:collector", "atlas", 100, reason="service", ref="farm-to-agent") + a = self.p.balance("atlas") + self.assertEqual(a["goods_spendable"], 0.0) # still granted credit at the agent + with self.assertRaises(GrantedCreditNotForGoods): + self.p.buy_shirt("atlas") + + def test_earned_credit_can_buy_goods_and_grants_spend_first(self): + self.p.human_signup("sam", 101) + self.p.top_up("atlas", 30) # atlas: 20 granted + 30 funded + r = self.p.pay("atlas", "human:sam", 25, reason="refund for a service", ref="atlas-sam-1") + a = self.p.balance("atlas") + self.assertEqual((a["granted"], a["funded"]), (0.0, 25.0)) # granted spent first + sam = self.p.balance("human:sam") + self.assertEqual((sam["granted"], sam["earned"]), (60.0, 5.0)) # 20 granted + 5 funded arrived as earned + self.p.pay("human:sam", "atlas", 45, reason="service", ref="sam-atlas-1") # 40+20 granted first + self.assertEqual(self.p.balance("human:sam")["earned"], 5.0) + out = self.p.buy_shirt("atlas") # 20c shirt from funded credit + self.assertEqual(out["order"]["credits_charged"], 20) + self.assertEqual(self.p.balance("atlas")["granted"], 45.0) + def test_public_ledger_shows_handles_only(self): self.p.human_signup("sam", 101) self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8") diff --git a/tests/test_product.py b/tests/test_product.py index 135d13f..760a0fd 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -1,5 +1,7 @@ import tempfile import unittest + +from coffer.ledger import GrantedCreditNotForGoods from pathlib import Path from coffer.product import CofferProduct @@ -105,11 +107,15 @@ class ProductTests(unittest.TestCase): def test_shirt_purchase_20c(self): self.product.bootstrap_opening_books() self.product.record_build_contract() - self.product.signup_flow("shopper") # left with 20c + self.product.signup_flow("shopper") # left with 20c of granted credit + with self.assertRaises(GrantedCreditNotForGoods): + self.product.buy_shirt("shopper") # free grant credit never buys physical goods + self.assertEqual(self.product.balance("shopper")["spendable"], 20.0) + self.product.top_up("shopper", 20) # funded credit can out = self.product.buy_shirt("shopper") self.assertEqual(out["order"]["credits_charged"], 20) self.assertEqual(out["order"]["sku"], "coffer-shirt") - self.assertEqual(out["balance"]["spendable"], 0.0) + self.assertEqual((out["balance"]["spendable"], out["balance"]["granted"], out["balance"]["funded"]), (20.0, 20.0, 0.0)) self.assertEqual(len(out["txs"]), 1) self.assertEqual(out["txs"][0]["kind"], "purchase") @@ -117,6 +123,7 @@ class ProductTests(unittest.TestCase): self.product.bootstrap_opening_books() self.product.record_build_contract() self.product.signup_flow("hist-agent") + self.product.top_up("hist-agent", 20) self.product.buy_shirt("hist-agent") hist = self.product.history() kinds = [t["kind"] for t in hist] diff --git a/tests/test_store.py b/tests/test_store.py index 72b2708..e1300fe 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,5 +1,7 @@ import unittest +from coffer.ledger import GrantedCreditNotForGoods + from coffer import CannotSupply, InsufficientCredit, UnknownGood, new_store from coffer.ledger import Ledger from coffer.store import Store @@ -12,7 +14,7 @@ class LedgerTests(unittest.TestCase): ledger.top_up("cursorMan", 20) self.assertEqual( ledger.balance("cursorMan"), - {"promotional": 0.0, "funded": 20.0, "spendable": 20.0}, + {"promotional": 0.0, "funded": 20.0, "earned": 0.0, "spendable": 20.0, "goods_spendable": 20.0}, ) def test_grant_is_promotional(self): @@ -56,6 +58,9 @@ class StoreTests(unittest.TestCase): store = new_store() buyer = agent_account("cursorMan") store.ledger.grant(buyer, 20, "launch shirt") + with self.assertRaises(GrantedCreditNotForGoods): + store.place_order("cursorMan", "coffer-shirt") # granted credit: services only + store.ledger.top_up(buyer, 20) order = store.place_order("cursorMan", "coffer-shirt") self.assertEqual(order.credits_charged, 20) self.assertEqual(order.status, "procuring") @@ -64,7 +69,8 @@ class StoreTests(unittest.TestCase): self.assertEqual(invoice.supplier_id, "printer") self.assertEqual(invoice.dollars, 8) self.assertEqual(invoice.status, "payable") - self.assertEqual(store.ledger.balance(buyer)["spendable"], 0.0) + self.assertEqual(store.ledger.balance(buyer)["spendable"], 20.0) # the grant is untouched + self.assertEqual(store.ledger.balance(buyer)["promotional"], 20.0) self.assertEqual(store.ledger.balance(ACCOUNT_COFFER)["spendable"], 20.0) self.assertNotIn("printer", store.ledger.accounts) -- 2.47.3 From 910e7fbe07eadceaa272f5937cab16462f8fb81d Mon Sep 17 00:00:00 2001 From: leora Date: Sat, 10 Oct 2026 11:28:38 +0800 Subject: [PATCH 5/7] Shirt orders: any credit may order, every order is held (escrow) for operator review; accept charges and queues printing, reject releases in full; both on the public ledger; 2 pending orders per account; operator list + pending-orders.json; drops the granted-vs-earned goods rule --- README.md | 7 ++- coffer/cli.py | 15 ++++- coffer/db.py | 5 ++ coffer/ledger.py | 51 ++++++++++------ coffer/product.py | 96 ++++++++++++++++++++++-------- coffer/store.py | 133 ++++++++++++++++++++++++++++-------------- design/coffer.md | 7 ++- tests/test_people.py | 91 +++++++++++++++++++++-------- tests/test_product.py | 27 ++++----- tests/test_store.py | 18 +++--- 10 files changed, 313 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index c02af23..fece8ac 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Design notes: `design/coffer.md` (also on Quill Design via `leora/design`). Comp - **Wallets** for every agent (`agent:`) and company (`company:`). - **Immutable ledger** of every credit movement (SQLite under `data/coffer.db`). - **Opening / first business tx:** Coffer pays Leora **200c** for the product-build contract. -- **Signup grant:** new agents (and people) get **40c** of granted (promotional) credit. It pays for services only and never buys physical goods. Goods need funded or earned credit. +- **Signup grant:** new agents (and people) get **40c** of granted (promotional) credit. Any credit, granted included, can pay for services and order goods. Granted credit is spent first. +- **Orders are reviewed:** every shirt order holds its price (escrow, not charged) and waits as *pending review*. The operator accepts (credits charged, printing queued) or rejects (hold released in full). At most 2 pending orders per account. Operator commands: `pending-orders`, `accept-order`, `reject-order`; the to-do list is also written to `pending-orders.json` next to the database. - **Subscription via Coffer:** **20c** seat; Coffer keeps **1%** (0.20c); New Internet Co nets **19.80c**. - **Catalog:** Coffer shirt at **20c** (printer cost $8, payable by Coffer). @@ -32,7 +33,9 @@ python3 -m coffer create-wallet alice python3 -m coffer grant-signup alice # 40c promo python3 -m coffer charge-subscription alice # 20c via Coffer, 1% cut python3 -m coffer signup alice # wallet + grant + subscription -python3 -m coffer buy-shirt alice +python3 -m coffer buy-shirt alice # held, pending review +python3 -m coffer pending-orders +python3 -m coffer accept-order ord-7 # or reject-order ord-7 --note "out of stock" python3 -m coffer balance alice python3 -m coffer history python3 -m coffer catalog diff --git a/coffer/cli.py b/coffer/cli.py index e34eecd..68675eb 100644 --- a/coffer/cli.py +++ b/coffer/cli.py @@ -42,7 +42,7 @@ def build_parser() -> argparse.ArgumentParser: sf = sub.add_parser("signup", help="Wallet + 40c grant + 20c subscription flow") sf.add_argument("agent_id") - sh = sub.add_parser("buy-shirt", help="Buy Coffer shirt (20c)") + sh = sub.add_parser("buy-shirt", help="Order a Coffer shirt (20c held until the operator reviews it)") sh.add_argument("agent_id") sh.add_argument("--qty", type=int, default=1) sh.add_argument("--idempotency-key") @@ -51,6 +51,13 @@ def build_parser() -> argparse.ArgumentParser: t.add_argument("agent_id") t.add_argument("amount", type=int) + sub.add_parser("pending-orders", help="Operator: orders waiting for review") + for name in ("accept-order", "reject-order"): + o = sub.add_parser(name, help=f"Operator ({'charge the hold and queue printing' if name == 'accept-order' else 'release the hold in full'})") + o.add_argument("order_id") + o.add_argument("--note", default="") + o.add_argument("--actor", default="agent:leora") + sub.add_parser("history", help="Print full ledger transaction history") sub.add_parser("catalog", help="Show catalog goods") sub.add_parser("public-ledger", help="Print public ledger JSON (no human PII)") @@ -83,6 +90,12 @@ def main(argv: list[str] | None = None) -> int: args.agent_id, quantity=args.qty, idempotency_key=args.idempotency_key ) ) + elif args.cmd == "pending-orders": + _print(product.pending_orders()) + elif args.cmd == "accept-order": + _print(product.accept_order(args.order_id, args.actor, args.note)) + elif args.cmd == "reject-order": + _print(product.reject_order(args.order_id, args.actor, args.note)) elif args.cmd == "top-up": _print(product.top_up(args.agent_id, args.amount)) elif args.cmd == "history": diff --git a/coffer/db.py b/coffer/db.py index baef315..30b77b3 100644 --- a/coffer/db.py +++ b/coffer/db.py @@ -95,6 +95,11 @@ class CofferDB: self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA foreign_keys=ON") self._conn.executescript(SCHEMA) + cols = {r["name"] for r in self._conn.execute("PRAGMA table_info(orders)").fetchall()} + for col, decl in (("held", "INTEGER NOT NULL DEFAULT 0"), ("hold_tx_seq", "INTEGER"), ("reviewed_by", "TEXT"), + ("reviewed_at", "TEXT"), ("review_note", "TEXT")): + if col not in cols: + self._conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {decl}") self._conn.commit() def close(self) -> None: diff --git a/coffer/ledger.py b/coffer/ledger.py index 9a2899b..77586e3 100644 --- a/coffer/ledger.py +++ b/coffer/ledger.py @@ -16,25 +16,19 @@ from coffer.units import SCALE, format_credits, to_subunits class CreditKind(str, Enum): FUNDED = "funded" # prepaid credit Coffer was paid for (top-ups, endowments) - PROMOTIONAL = "promotional" # granted for free (signup grants): services only, never goods + PROMOTIONAL = "promotional" # granted for free (signup grants) EARNED = "earned" # funded/earned credit received as payment for a service -# What may pay for what. Granted credit is spent first on services; physical goods can only be -# bought with funded or earned credit, so free grants (or grants moved between accounts) can -# never be turned into goods. +# Granted credit is always spent first. Every credit kind can pay for anything; physical goods +# are protected by manual order review instead (see Store.place_order). SERVICE_DRAW_ORDER = (CreditKind.PROMOTIONAL, CreditKind.FUNDED, CreditKind.EARNED) -GOODS_DRAW_ORDER = (CreditKind.FUNDED, CreditKind.EARNED) class InsufficientCredit(Exception): """The account does not have enough spendable credit.""" -class GrantedCreditNotForGoods(InsufficientCredit): - """Physical goods need funded or earned credit; granted credit cannot buy them.""" - - class TransferNotAllowed(Exception): """Peer agent-to-agent transfer is not allowed.""" @@ -62,7 +56,6 @@ class Account: "funded": funded, "earned": earned, "spendable": promotional + funded + earned, - "goods_spendable": funded + earned, } def balance(self) -> dict[str, float]: @@ -109,7 +102,7 @@ class Ledger: def balance_subunits(self, account_id: str) -> dict[str, int]: if account_id not in self.accounts: - return {"promotional": 0, "funded": 0, "earned": 0, "spendable": 0, "goods_spendable": 0} + return {"promotional": 0, "funded": 0, "earned": 0, "spendable": 0} return self.accounts[account_id].balance_subunits() def balance(self, account_id: str) -> dict[str, float]: @@ -204,8 +197,6 @@ class Ledger: account = self.accounts.get(account_id) available = sum(lot.remaining for lot in (account.lots if account else []) if lot.kind in order) if account is None or available < amount: - if account is not None and order == GOODS_DRAW_ORDER and account.balance_subunits()["spendable"] >= amount: - raise GrantedCreditNotForGoods(account_id) raise InsufficientCredit(account_id) ordered = [ lot @@ -266,7 +257,7 @@ class Ledger: Granted (promotional) credit is used first, then funded, then earned. The payee receives granted credit as granted (so a grant moved between accounts still cannot buy goods) and funded or earned credit as earned. Agent -> agent payments stay blocked (the existing - peer rule). Prepaid credit only: nothing here turns credit into cash or goods.""" + peer rule). Prepaid credit only: nothing here turns credit into cash.""" kinds = (payer.split(":", 1)[0], payee.split(":", 1)[0]) if "company" in kinds or set(kinds) - {"agent", "human"}: raise TransferNotAllowed("payments are between people and agents") @@ -293,13 +284,10 @@ class Ledger: *, tx_kind: str = "purchase", meta: dict[str, Any] | None = None, - goods: bool = False, ) -> tuple[list[dict[str, int | str]], Tx]: - """Buyer pays merchant: debit buyer lots, credit merchant funded lot. For physical goods - (goods=True) only funded or earned credit may be drawn, never granted credit; services - (subscriptions) spend granted credit first.""" + """Buyer pays merchant: debit buyer lots (granted first), credit merchant funded lot.""" _positive_int(amount_subunits, "purchase amount") - draws = self._debit_lots(buyer_id, amount_subunits, order=GOODS_DRAW_ORDER if goods else SERVICE_DRAW_ORDER) + draws = self._debit_lots(buyer_id, amount_subunits) self.account(merchant_id).lots.append( Lot(CreditKind.FUNDED, amount_subunits, reason) ) @@ -313,6 +301,31 @@ class Ledger: ) return draws, tx + # --- order holds (escrow): credits leave the buyer's spendable balance but are not charged + def hold(self, buyer_id: str, escrow_id: str, amount_subunits: int, reason: str, *, meta=None) -> tuple[list, Tx]: + _positive_int(amount_subunits, "hold amount") + draws = self._debit_lots(buyer_id, amount_subunits) + self.account(escrow_id).lots.append(Lot(CreditKind.FUNDED, amount_subunits, reason)) + tx = self._append_tx(kind="order_hold", amount=amount_subunits, debit_account=buyer_id, credit_account=escrow_id, + reason=reason, meta={**(meta or {}), "draws": draws}) + return draws, tx + + def release_hold(self, escrow_id: str, buyer_id: str, draws: list, reason: str, *, meta=None) -> Tx: + """Give held credit back in full, as the same kinds it was drawn from.""" + amount = sum(int(d["amount"]) for d in draws) + self._debit_lots(escrow_id, amount) + for d in draws: + self.account(buyer_id).lots.append(Lot(CreditKind(d["kind"]), int(d["amount"]), reason)) + return self._append_tx(kind="order_release", amount=amount, debit_account=escrow_id, credit_account=buyer_id, + reason=reason, meta={**(meta or {}), "draws": draws}) + + def settle_hold(self, escrow_id: str, merchant_id: str, amount_subunits: int, reason: str, *, meta=None) -> Tx: + """Charge held credit: escrow pays the merchant.""" + self._debit_lots(escrow_id, amount_subunits) + self.account(merchant_id).lots.append(Lot(CreditKind.FUNDED, amount_subunits, reason)) + return self._append_tx(kind="purchase", amount=amount_subunits, debit_account=escrow_id, credit_account=merchant_id, + reason=reason, meta=meta or {}) + def bootstrap_endowment( self, account_id: str, diff --git a/coffer/product.py b/coffer/product.py index bc5ca38..de7dc88 100644 --- a/coffer/product.py +++ b/coffer/product.py @@ -14,7 +14,7 @@ from zoneinfo import ZoneInfo from coffer.db import CofferDB from coffer.public_ledger import snapshot_from_product from coffer.ledger import CreditKind, Ledger, Lot, Tx -from coffer.store import Good, Invoice, Order, new_store +from coffer.store import ACCOUNT_ESCROW, ORDER_REVIEW_NOTICE, Good, Invoice, Order, new_store from coffer.units import ( ACCOUNT_COFFER, ACCOUNT_LEORA, @@ -143,6 +143,11 @@ class CofferProduct: status=row["status"], invoice_id=row["invoice_id"], tx_seq=row["tx_seq"], + held=int(row["held"] or 0), + hold_tx_seq=row["hold_tx_seq"], + reviewed_by=row["reviewed_by"], + reviewed_at=row["reviewed_at"], + review_note=row["review_note"], ) self.store.orders[order.order_id] = order self._bump_seq_from_id(order.order_id) @@ -216,7 +221,8 @@ class CofferProduct: self.db.execute( "INSERT OR REPLACE INTO orders(order_id, buyer_id, sku, quantity, " "credits_charged, draws_json, from_stock, procured, status, invoice_id, " - "tx_seq, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + "tx_seq, created_at, held, hold_tx_seq, reviewed_by, reviewed_at, review_note) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?,COALESCE((SELECT created_at FROM orders WHERE order_id=?),?),?,?,?,?,?)", ( order.order_id, order.buyer_id, @@ -229,7 +235,13 @@ class CofferProduct: order.status, order.invoice_id, order.tx_seq, + order.order_id, now_pt(), + order.held, + order.hold_tx_seq, + order.reviewed_by, + order.reviewed_at, + order.review_note, ), ) if order.invoice_id and order.invoice_id in self.store.invoices: @@ -279,8 +291,8 @@ class CofferProduct: "funded": raw["funded"] / SCALE, "earned": raw["earned"] / SCALE, "spendable": raw["spendable"] / SCALE, - "goods_spendable": raw["goods_spendable"] / SCALE, - "granted": raw["promotional"] / SCALE, # alias: free grant credit (services only) + "held": self.store.held_for(aid) / SCALE, # orders waiting for review (not charged) + "granted": raw["promotional"] / SCALE, # alias: free signup-grant credit "promotional_centicredits": raw["promotional"], "funded_centicredits": raw["funded"], "earned_centicredits": raw["earned"], @@ -440,26 +452,58 @@ class CofferProduct: quantity: int = 1, idempotency_key: str | None = None, ) -> dict[str, Any]: - before = self.ledger._tx_seq - order = self.store.place_order( - agent_id, - "coffer-shirt", - quantity=quantity, - idempotency_key=idempotency_key, - ) - txs = [t for t in self.ledger.transactions if t.seq > before] - self._persist_order(order) - if idempotency_key: - self.db.execute( - "INSERT OR REPLACE INTO idempotency(key, order_id) VALUES(?,?)", - (idempotency_key, order.order_id), - ) - self._commit_accounts(order.buyer_id, ACCOUNT_COFFER, txs=txs) - return { - "order": asdict(order), - "balance": self.balance(order.buyer_id), - "txs": [self.ledger.describe_tx(t) for t in txs], - } + """Order shirts with any credit. The price is held, not charged, until the operator + reviews the order (accept_order / reject_order).""" + return self.place_order(agent_id, "coffer-shirt", quantity, idempotency_key) + + def place_order(self, buyer: str, sku: str, quantity: int = 1, idempotency_key: str | None = None) -> dict[str, Any]: + with self._atomic(): + before = self.ledger._tx_seq + order = self.store.place_order(buyer, sku, quantity=quantity, idempotency_key=idempotency_key) + txs = [t for t in self.ledger.transactions if t.seq > before] + self._persist_order(order) + if idempotency_key: + self.db.execute("INSERT OR REPLACE INTO idempotency(key, order_id) VALUES(?,?)", (idempotency_key, order.order_id)) + self._commit_accounts(order.buyer_id, ACCOUNT_ESCROW, txs=txs) + return self.order_view(order, txs) + + def order_view(self, order, txs=None) -> dict[str, Any]: + out = {"order": asdict(order), "balance": self.balance(order.buyer_id)} + out["order"]["status_label"] = {"pending_review": "pending review", "rejected": "rejected, credits released", + "procuring": "accepted, printing", "fulfilled": "accepted"}.get(order.status, order.status) + if order.status == "pending_review": + out["notice"] = ORDER_REVIEW_NOTICE + if txs is not None: + out["txs"] = [self.ledger.describe_tx(t) for t in txs] + return out + + def orders_for(self, buyer: str) -> list[dict[str, Any]]: + aid = human_account(buyer) if buyer.startswith("human:") else agent_account(buyer) + return [self.order_view(o)["order"] for o in self.store.orders.values() if o.buyer_id == aid] + + def pending_orders(self) -> list[dict[str, Any]]: + """For the operator: every order waiting for review, oldest first.""" + rows = [] + for o in sorted(self.store.pending_orders(), key=lambda o: int(o.order_id.split("-")[-1])): + row = self.db.execute("SELECT created_at FROM orders WHERE order_id=?", (o.order_id,)).fetchone() + rows.append({"order_id": o.order_id, "buyer": o.buyer_id, "sku": o.sku, "quantity": o.quantity, + "held_credits": o.held / SCALE, "submitted_at": row["created_at"] if row else None}) + return rows + + def _decide_order(self, method: str, order_id: str, actor: str, note: str) -> dict[str, Any]: + with self._atomic(): # reloads self.store, so look the method up inside + before = self.ledger._tx_seq + order = getattr(self.store, method)(order_id, actor, note=note, at=now_pt()) + txs = [t for t in self.ledger.transactions if t.seq > before] + self._persist_order(order) + self._commit_accounts(order.buyer_id, ACCOUNT_ESCROW, self.store.merchant_account, txs=txs) + return self.order_view(order, txs) + + def accept_order(self, order_id: str, actor: str, note: str = "") -> dict[str, Any]: + return self._decide_order("accept_order", order_id, actor, note) + + def reject_order(self, order_id: str, actor: str, note: str = "") -> dict[str, Any]: + return self._decide_order("reject_order", order_id, actor, note) def top_up(self, agent_id: str, amount_credits: int) -> dict[str, Any]: aid = agent_account(agent_id) @@ -617,6 +661,10 @@ class CofferProduct: + "\n", encoding="utf-8", ) + # Operator's to-do: orders waiting for review (account ids only). + pending = Path(self.db.path).with_name("pending-orders.json") + pending.write_text(json.dumps({"updated_at": snap["updated_at"], "pending": self.pending_orders()}, indent=2) + "\n", + encoding="utf-8") if os.environ.get("COFFER_AUTO_PUBLISH_NEWS", "0") not in {"1", "true", "yes"}: return try: diff --git a/coffer/store.py b/coffer/store.py index ce67231..8e8853d 100644 --- a/coffer/store.py +++ b/coffer/store.py @@ -5,7 +5,13 @@ from __future__ import annotations from dataclasses import dataclass, field from coffer.ledger import Ledger -from coffer.units import ACCOUNT_COFFER, agent_account, to_subunits +from coffer.units import ACCOUNT_COFFER, agent_account, human_account, to_subunits + +ACCOUNT_ESCROW = "company:coffer-escrow" # held credit for orders waiting for review +MAX_PENDING_ORDERS_PER_ACCOUNT = 2 +ORDER_REVIEWERS = frozenset({"agent:leora"}) # the operator; only it accepts or rejects orders +ORDER_REVIEW_NOTICE = ("Your order is waiting for confirmation. We review every order and accept or reject it " + "based on stock and volume. If it's rejected, your credits are released in full.") class UnknownGood(Exception): @@ -20,6 +26,18 @@ class DuplicateOrder(Exception): """This idempotency key was already used for a different order.""" +class TooManyPendingOrders(Exception): + """This account already has the maximum number of orders waiting for review.""" + + +class NotAReviewer(Exception): + """Only the operator can accept or reject orders.""" + + +class OrderNotPending(Exception): + """The order was already accepted or rejected.""" + + @dataclass class Supplier: supplier_id: str @@ -59,6 +77,11 @@ class Order: status: str invoice_id: str | None = None tx_seq: int | None = None + held: int = 0 # centicredits held in escrow while pending review + hold_tx_seq: int | None = None + reviewed_by: str | None = None + reviewed_at: str | None = None + review_note: str | None = None @dataclass @@ -119,8 +142,9 @@ class Store: quantity: int = 1, idempotency_key: str | None = None, ) -> Order: - """Buyer pays Coffer in credit. Coffer owes the goods.""" - buyer = agent_account(buyer_id) + """Submit an order. The price is HELD (escrow, not charged) and the order waits for + manual review: the operator accepts (charge + print) or rejects (release in full).""" + buyer = human_account(buyer_id) if str(buyer_id).startswith("human:") else agent_account(buyer_id) if idempotency_key: existing = self._idempotency.get(idempotency_key) if existing is not None: @@ -128,53 +152,76 @@ class Store: if order.buyer_id != buyer or order.sku != sku or order.quantity != quantity: raise DuplicateOrder(idempotency_key) return order - if type(quantity) is not int or quantity <= 0: - raise ValueError("quantity must be a positive whole number") + if type(quantity) is not int or quantity <= 0 or quantity > 10: + raise ValueError("quantity must be a whole number from 1 to 10") good = self.goods.get(sku) if good is None: raise UnknownGood(sku) - from_stock = min(good.stock, quantity) - procured = quantity - from_stock - if procured and (not good.supplier_id or good.supplier_id not in self.suppliers): + if not good.stock and (not good.supplier_id or good.supplier_id not in self.suppliers): raise CannotSupply(sku) + if len(self.pending_orders(buyer)) >= MAX_PENDING_ORDERS_PER_ACCOUNT: + raise TooManyPendingOrders(buyer) credits = good.credit_price * quantity - draws_sub, tx = self.ledger.purchase_settle( - buyer, - self.merchant_account, - to_subunits(credits), - reason=f"purchase:{sku}", - tx_kind="purchase", - meta={"sku": sku, "quantity": quantity}, - goods=True, # every catalog item is a physical good: granted credit cannot pay - ) - draws = [{"kind": d["kind"], "amount": int(d["amount"]) // 100} for d in draws_sub] - good.stock -= from_stock - invoice_id = None - if procured: - invoice = self._invoice( - good.supplier_id, - sku, - procured, - good.supplier_unit_cost * procured, - ) - invoice_id = invoice.invoice_id self._seq += 1 - order = Order( - order_id=f"ord-{self._seq}", - buyer_id=buyer, - sku=sku, - quantity=quantity, - credits_charged=credits, - draws=draws, - from_stock=from_stock, - procured=procured, - status="fulfilled" if procured == 0 else "procuring", - invoice_id=invoice_id, - tx_seq=tx.seq, - ) - self.orders[order.order_id] = order + oid = f"ord-{self._seq}" + draws_sub, tx = self.ledger.hold(buyer, ACCOUNT_ESCROW, to_subunits(credits), reason=f"order-hold:{sku}", + meta={"sku": sku, "quantity": quantity, "order": oid}) + order = Order(order_id=oid, buyer_id=buyer, sku=sku, quantity=quantity, credits_charged=0, + draws=draws_sub, from_stock=0, procured=0, status="pending_review", + held=to_subunits(credits), hold_tx_seq=tx.seq) + self.orders[oid] = order if idempotency_key: - self._idempotency[idempotency_key] = order.order_id + self._idempotency[idempotency_key] = oid + return order + + def pending_orders(self, buyer_id: str | None = None) -> list[Order]: + return [o for o in self.orders.values() if o.status == "pending_review" and (buyer_id is None or o.buyer_id == buyer_id)] + + def held_for(self, buyer_id: str) -> int: + return sum(o.held for o in self.pending_orders(buyer_id)) + + def _review(self, order_id: str, actor: str) -> Order: + if actor not in ORDER_REVIEWERS: + raise NotAReviewer(actor) + order = self.orders.get(order_id) + if order is None: + raise KeyError(order_id) + if order.status != "pending_review": + raise OrderNotPending(order_id) + return order + + def accept_order(self, order_id: str, actor: str, note: str = "", at: str | None = None) -> Order: + """Operator accepts: the held credit is charged and the goods are taken from stock or + queued for printing (supplier invoice).""" + order = self._review(order_id, actor) + good = self.goods[order.sku] + from_stock = min(good.stock, order.quantity) + procured = order.quantity - from_stock + if procured and (not good.supplier_id or good.supplier_id not in self.suppliers): + raise CannotSupply(order.sku) + tx = self.ledger.settle_hold(ACCOUNT_ESCROW, self.merchant_account, order.held, reason=f"purchase:{order.sku}", + meta={"sku": order.sku, "quantity": order.quantity, "order": order.order_id, + "buyer": order.buyer_id, "reviewed_by": actor}) + good.stock -= from_stock + if procured: + order.invoice_id = self._invoice(good.supplier_id, order.sku, procured, good.supplier_unit_cost * procured).invoice_id + order.credits_charged = order.held // 100 + order.held = 0 + order.from_stock, order.procured = from_stock, procured + order.status = "fulfilled" if procured == 0 else "procuring" + order.tx_seq = tx.seq + order.reviewed_by, order.reviewed_at, order.review_note = actor, at, (note or "")[:200] + return order + + def reject_order(self, order_id: str, actor: str, note: str = "", at: str | None = None) -> Order: + """Operator rejects: every held credit goes back to the buyer, as the same kinds.""" + order = self._review(order_id, actor) + tx = self.ledger.release_hold(ACCOUNT_ESCROW, order.buyer_id, order.draws, reason=f"order-released:{order.sku}", + meta={"order": order.order_id, "reviewed_by": actor}) + order.held = 0 + order.status = "rejected" + order.tx_seq = tx.seq + order.reviewed_by, order.reviewed_at, order.review_note = actor, at, (note or "")[:200] return order def _invoice(self, supplier_id: str, sku: str, quantity: int, dollars: int) -> Invoice: diff --git a/design/coffer.md b/design/coffer.md index 5f3d20b..1113df0 100644 --- a/design/coffer.md +++ b/design/coffer.md @@ -61,7 +61,12 @@ A supplier invoices Coffer in dollars and is paid from Coffer's own account, on The Coffer shirt is 20 agent credits. The opening supplier cost recorded for the printer is 8 dollars. That cost is Coffer's payable, not the price the buyer pays. -Granted (promotional) credit, such as the 40c signup grant, pays for services only: subscriptions and paid work between people and agents. It never buys the shirt or any other physical good, so free grants cannot be farmed into goods, which would amount to a cash-out. Goods need funded credit (prepaid top-ups) or earned credit (funded or earned credit received as payment for a service). Balances track granted, funded and earned credit separately. Services spend granted credit first. When credit is paid on, granted credit arrives as granted and funded or earned credit arrives as earned. Coffer still pays the printer. +Any credit, granted (promotional) credit such as the 40c signup grant included, can order the shirt. Every goods order is reviewed by hand, so free grants cannot be farmed into goods unnoticed: + +- Submitting an order HOLDS the price in escrow (`company:coffer-escrow`, tx `order_hold`). Nothing is charged; the order shows as *pending review*, and the buyer sees: "Your order is waiting for confirmation. We review every order and accept or reject it based on stock and volume. If it's rejected, your credits are released in full." +- Only the operator (`agent:leora`) can decide. Accept charges the hold (tx `purchase`, escrow to Coffer) and takes the goods from stock or queues printing (a supplier invoice). Reject releases the hold in full, as the same credit kinds it was drawn from (tx `order_release`). Both appear on the public ledger. +- At most 2 orders per account may be pending at once. +- Balances still track granted, funded and earned credit separately, and granted credit is spent first. Coffer still pays the printer. ## Component diff --git a/tests/test_people.py b/tests/test_people.py index 3da67b4..a7b699b 100644 --- a/tests/test_people.py +++ b/tests/test_people.py @@ -2,7 +2,8 @@ import tempfile import unittest from pathlib import Path -from coffer.ledger import GrantedCreditNotForGoods, InsufficientCredit, TransferNotAllowed +from coffer.ledger import InsufficientCredit, TransferNotAllowed +from coffer.store import NotAReviewer, OrderNotPending, TooManyPendingOrders from coffer.product import CofferProduct, ProductError from coffer.public_ledger import tx_public from coffer.units import human_account @@ -110,33 +111,73 @@ class PeopleTests(unittest.TestCase): with self.assertRaises(InsufficientCredit): self.p.pay("human:sam", "agent:atlas", 41, reason="x", ref="of-7") - # ----- granted vs earned: free grants buy services, never physical goods - def test_grants_cannot_become_goods(self): - # Sybil farming: many people each pay their 40c grant to one account, which tries to buy shirts - for i in range(3): - self.p.human_signup(f"sock{i}", 900 + i) - self.p.pay(f"human:sock{i}", "human:collector" if i else "human:collector", 40, reason="gift", ref=f"farm-{i}") - bal = self.p.balance("human:collector") - self.assertEqual((bal["granted"], bal["earned"], bal["goods_spendable"]), (120.0, 0.0, 0.0)) - self.p.pay("human:collector", "atlas", 100, reason="service", ref="farm-to-agent") - a = self.p.balance("atlas") - self.assertEqual(a["goods_spendable"], 0.0) # still granted credit at the agent - with self.assertRaises(GrantedCreditNotForGoods): - self.p.buy_shirt("atlas") - - def test_earned_credit_can_buy_goods_and_grants_spend_first(self): + # ----- shirt orders: any credit, held until the operator reviews the order + def test_person_orders_with_granted_credit_and_is_rejected_then_released(self): self.p.human_signup("sam", 101) - self.p.top_up("atlas", 30) # atlas: 20 granted + 30 funded - r = self.p.pay("atlas", "human:sam", 25, reason="refund for a service", ref="atlas-sam-1") + o = self.p.buy_shirt("human:sam") + oid = o["order"]["order_id"] + self.assertEqual((o["order"]["status"], o["order"]["status_label"]), ("pending_review", "pending review")) + self.assertEqual((o["balance"]["spendable"], o["balance"]["held"]), (20.0, 20.0)) + self.assertEqual([x["order_id"] for x in self.p.pending_orders()], [oid]) + with self.assertRaises(NotAReviewer): + self.p.reject_order(oid, "human:sam") + with self.assertRaises(NotAReviewer): + self.p.accept_order(oid, "agent:atlas") + r = self.p.reject_order(oid, "agent:leora", "out of stock this week") + self.assertEqual(r["order"]["status"], "rejected") + b = self.p.balance("human:sam") + self.assertEqual((b["spendable"], b["granted"], b["held"]), (40.0, 40.0, 0.0)) # released in full, same kind + with self.assertRaises(OrderNotPending): + self.p.accept_order(oid, "agent:leora") + self.assertEqual(self.p.pending_orders(), []) + kinds = [t["kind"] for t in self.p.history()] + self.assertIn("order_hold", kinds) + self.assertIn("order_release", kinds) + + def test_pending_order_limit_and_insufficient_credit(self): + self.p.human_signup("sam", 101) + self.p.buy_shirt("human:sam") + self.p.buy_shirt("human:sam") + with self.assertRaises(TooManyPendingOrders): + self.p.buy_shirt("human:sam") + self.assertEqual(self.p.balance("human:sam")["held"], 40.0) + self.p.human_signup("kai", 102) + with self.assertRaises(InsufficientCredit): + self.p.buy_shirt("human:kai", quantity=3) # 60c > 40c + self.assertEqual(self.p.balance("human:kai")["spendable"], 40.0) + first = self.p.pending_orders()[0]["order_id"] + self.p.accept_order(first, "agent:leora") + with self.assertRaises(InsufficientCredit): # the slot is free again; the credit is spent + self.p.buy_shirt("human:sam") + + def test_accept_charges_hold_and_is_public(self): + o = self.p.buy_shirt("atlas") + a = self.p.accept_order(o["order"]["order_id"], "agent:leora", "ok") + self.assertEqual((a["order"]["status"], a["order"]["credits_charged"], a["order"]["reviewed_by"]), ("procuring", 20, "agent:leora")) + snap = self.p.public_snapshot() + self.assertTrue(any(t["kind"] == "purchase" for t in snap["transactions"])) + pending_file = Path(self.db).with_name("pending-orders.json") + self.assertTrue(pending_file.exists()) + import json + self.assertEqual(json.loads(pending_file.read_text())["pending"], []) + + def test_orders_survive_restart(self): + o = self.p.buy_shirt("atlas") + self.p.close() + self.p = CofferProduct(self.db) + self.assertEqual(self.p.pending_orders()[0]["order_id"], o["order"]["order_id"]) + self.assertEqual(self.p.balance("atlas")["held"], 20.0) + self.p.reject_order(o["order"]["order_id"], "agent:leora") + self.assertEqual(self.p.balance("atlas")["spendable"], 20.0) + + def test_grants_spent_first_and_tracked_apart(self): + self.p.human_signup("sam", 101) + self.p.top_up("atlas", 30) # atlas: 20 granted + 30 funded + self.p.pay("atlas", "human:sam", 25, reason="refund for a service", ref="atlas-sam-1") a = self.p.balance("atlas") - self.assertEqual((a["granted"], a["funded"]), (0.0, 25.0)) # granted spent first + self.assertEqual((a["granted"], a["funded"]), (0.0, 25.0)) sam = self.p.balance("human:sam") - self.assertEqual((sam["granted"], sam["earned"]), (60.0, 5.0)) # 20 granted + 5 funded arrived as earned - self.p.pay("human:sam", "atlas", 45, reason="service", ref="sam-atlas-1") # 40+20 granted first - self.assertEqual(self.p.balance("human:sam")["earned"], 5.0) - out = self.p.buy_shirt("atlas") # 20c shirt from funded credit - self.assertEqual(out["order"]["credits_charged"], 20) - self.assertEqual(self.p.balance("atlas")["granted"], 45.0) + self.assertEqual((sam["granted"], sam["earned"]), (60.0, 5.0)) def test_public_ledger_shows_handles_only(self): self.p.human_signup("sam", 101) diff --git a/tests/test_product.py b/tests/test_product.py index 760a0fd..30df568 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -1,7 +1,6 @@ import tempfile import unittest -from coffer.ledger import GrantedCreditNotForGoods from pathlib import Path from coffer.product import CofferProduct @@ -107,24 +106,26 @@ class ProductTests(unittest.TestCase): def test_shirt_purchase_20c(self): self.product.bootstrap_opening_books() self.product.record_build_contract() - self.product.signup_flow("shopper") # left with 20c of granted credit - with self.assertRaises(GrantedCreditNotForGoods): - self.product.buy_shirt("shopper") # free grant credit never buys physical goods - self.assertEqual(self.product.balance("shopper")["spendable"], 20.0) - self.product.top_up("shopper", 20) # funded credit can + self.product.signup_flow("shopper") # left with 20c of granted credit: enough for a shirt out = self.product.buy_shirt("shopper") - self.assertEqual(out["order"]["credits_charged"], 20) - self.assertEqual(out["order"]["sku"], "coffer-shirt") - self.assertEqual((out["balance"]["spendable"], out["balance"]["granted"], out["balance"]["funded"]), (20.0, 20.0, 0.0)) - self.assertEqual(len(out["txs"]), 1) - self.assertEqual(out["txs"][0]["kind"], "purchase") + self.assertEqual(out["order"]["status"], "pending_review") + self.assertIn("Your order is waiting for confirmation.", out["notice"]) + self.assertIn("If it's rejected, your credits are released in full.", out["notice"]) + self.assertEqual((out["balance"]["spendable"], out["balance"]["held"]), (0.0, 20.0)) + self.assertEqual([t["kind"] for t in out["txs"]], ["order_hold"]) + done = self.product.accept_order(out["order"]["order_id"], "agent:leora") + self.assertEqual(done["order"]["credits_charged"], 20) + self.assertEqual(done["order"]["sku"], "coffer-shirt") + self.assertEqual(done["order"]["status"], "procuring") + self.assertEqual((done["balance"]["spendable"], done["balance"]["held"]), (0.0, 0.0)) + self.assertEqual([t["kind"] for t in done["txs"]], ["purchase"]) def test_full_history_and_company_vs_agent_books(self): self.product.bootstrap_opening_books() self.product.record_build_contract() self.product.signup_flow("hist-agent") - self.product.top_up("hist-agent", 20) - self.product.buy_shirt("hist-agent") + o = self.product.buy_shirt("hist-agent") + self.product.accept_order(o["order"]["order_id"], "agent:leora") hist = self.product.history() kinds = [t["kind"] for t in hist] self.assertEqual(kinds[0], "contract_payment") diff --git a/tests/test_store.py b/tests/test_store.py index e1300fe..58eabc0 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,6 +1,5 @@ import unittest -from coffer.ledger import GrantedCreditNotForGoods from coffer import CannotSupply, InsufficientCredit, UnknownGood, new_store from coffer.ledger import Ledger @@ -14,7 +13,7 @@ class LedgerTests(unittest.TestCase): ledger.top_up("cursorMan", 20) self.assertEqual( ledger.balance("cursorMan"), - {"promotional": 0.0, "funded": 20.0, "earned": 0.0, "spendable": 20.0, "goods_spendable": 20.0}, + {"promotional": 0.0, "funded": 20.0, "earned": 0.0, "spendable": 20.0}, ) def test_grant_is_promotional(self): @@ -58,10 +57,10 @@ class StoreTests(unittest.TestCase): store = new_store() buyer = agent_account("cursorMan") store.ledger.grant(buyer, 20, "launch shirt") - with self.assertRaises(GrantedCreditNotForGoods): - store.place_order("cursorMan", "coffer-shirt") # granted credit: services only - store.ledger.top_up(buyer, 20) - order = store.place_order("cursorMan", "coffer-shirt") + order = store.place_order("cursorMan", "coffer-shirt") # held, waiting for review + self.assertEqual((order.status, order.held, order.invoice_id), ("pending_review", 2000, None)) + self.assertEqual(store.ledger.balance(buyer)["spendable"], 0.0) + order = store.accept_order(order.order_id, "agent:leora") self.assertEqual(order.credits_charged, 20) self.assertEqual(order.status, "procuring") self.assertEqual(order.procured, 1) @@ -69,8 +68,7 @@ class StoreTests(unittest.TestCase): self.assertEqual(invoice.supplier_id, "printer") self.assertEqual(invoice.dollars, 8) self.assertEqual(invoice.status, "payable") - self.assertEqual(store.ledger.balance(buyer)["spendable"], 20.0) # the grant is untouched - self.assertEqual(store.ledger.balance(buyer)["promotional"], 20.0) + self.assertEqual(store.ledger.balance(buyer)["spendable"], 0.0) self.assertEqual(store.ledger.balance(ACCOUNT_COFFER)["spendable"], 20.0) self.assertNotIn("printer", store.ledger.accounts) @@ -79,7 +77,7 @@ class StoreTests(unittest.TestCase): store.receive_stock("coffer-shirt", 2) self.assertEqual(len(store.invoices), 1) store.ledger.top_up(agent_account("buyer"), 40) - order = store.place_order("buyer", "coffer-shirt", 2) + order = store.accept_order(store.place_order("buyer", "coffer-shirt", 2).order_id, "agent:leora") self.assertEqual(order.status, "fulfilled") self.assertEqual(order.from_stock, 2) self.assertIsNone(order.invoice_id) @@ -90,6 +88,8 @@ class StoreTests(unittest.TestCase): store.goods["coffer-shirt"].stock = 1 store.ledger.top_up(agent_account("buyer"), 60) order = store.place_order("buyer", "coffer-shirt", 3) + self.assertEqual((order.status, store.goods["coffer-shirt"].stock), ("pending_review", 1)) # nothing moves yet + order = store.accept_order(order.order_id, "agent:leora") self.assertEqual(order.from_stock, 1) self.assertEqual(order.procured, 2) self.assertEqual(store.invoices[order.invoice_id].dollars, 16) -- 2.47.3 From c46842f8635813b1683f25e90c36fbab70d45f2f Mon Sep 17 00:00:00 2001 From: leora Date: Sat, 10 Oct 2026 11:31:14 +0800 Subject: [PATCH 6/7] Order reviewers configurable (COFFER_ORDER_REVIEWERS), default agent:leora --- coffer/store.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/coffer/store.py b/coffer/store.py index 8e8853d..0937e24 100644 --- a/coffer/store.py +++ b/coffer/store.py @@ -10,6 +10,13 @@ from coffer.units import ACCOUNT_COFFER, agent_account, human_account, to_subuni ACCOUNT_ESCROW = "company:coffer-escrow" # held credit for orders waiting for review MAX_PENDING_ORDERS_PER_ACCOUNT = 2 ORDER_REVIEWERS = frozenset({"agent:leora"}) # the operator; only it accepts or rejects orders + + +def order_reviewers() -> frozenset[str]: + """The operator account(s). A deployment with a different operator sets COFFER_ORDER_REVIEWERS.""" + import os + extra = {a.strip() for a in os.environ.get("COFFER_ORDER_REVIEWERS", "").split(",") if a.strip()} + return frozenset(extra) if extra else ORDER_REVIEWERS ORDER_REVIEW_NOTICE = ("Your order is waiting for confirmation. We review every order and accept or reject it " "based on stock and volume. If it's rejected, your credits are released in full.") @@ -181,7 +188,7 @@ class Store: return sum(o.held for o in self.pending_orders(buyer_id)) def _review(self, order_id: str, actor: str) -> Order: - if actor not in ORDER_REVIEWERS: + if actor not in order_reviewers(): raise NotAReviewer(actor) order = self.orders.get(order_id) if order is None: -- 2.47.3 From 7b49a53da51a49caff78b2f596184ad26bafba53 Mon Sep 17 00:00:00 2001 From: leora Date: Sat, 10 Oct 2026 12:36:36 +0800 Subject: [PATCH 7/7] human_signup: clear error when the Gitea account's grant was already claimed under another handle --- coffer/product.py | 5 +++++ tests/test_people.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/coffer/product.py b/coffer/product.py index de7dc88..671e929 100644 --- a/coffer/product.py +++ b/coffer/product.py @@ -565,6 +565,11 @@ class CofferProduct: raise ProductError("gitea_id is required") key = f"signup_grant:gitea:{gid}" with self._atomic(): + row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone() + if row is not None and row["order_id"].split("|", 1)[-1] != aid: + # Same Gitea user, different handle (e.g. a renamed account): the grant already went + # to the earlier wallet and is never given twice. + raise ProductError("signup grant already claimed for this Gitea account under another handle") seen = self._claim(key, aid) if seen is not None: self.db.commit() diff --git a/tests/test_people.py b/tests/test_people.py index a7b699b..d411772 100644 --- a/tests/test_people.py +++ b/tests/test_people.py @@ -33,7 +33,7 @@ class PeopleTests(unittest.TestCase): def test_grant_keyed_on_gitea_id_rename_and_concurrency(self): self.p.human_signup("sam", 101) - with self.assertRaises(ProductError): # rename: the Gitea id already had its grant + with self.assertRaisesRegex(ProductError, "already claimed for this Gitea account under another handle"): # rename self.p.human_signup("sam-renamed", 101) self.assertEqual(self.p.balance("human:sam-renamed")["spendable"], 0.0) import threading -- 2.47.3
AccountGrantedFundedEarnedSpendable
{_escape(b['account_id'])}{_fmt(b['promotional'])}{_fmt(b['funded'])}{_fmt(b['earned'])}{_fmt(b['spendable'])}