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__":