People: same signup grant as agents; payments with granted credit; person-to-person allowed; no purchases

This commit is contained in:
Leora 2026-10-10 10:11:16 +08:00
parent bd59063418
commit ab49a82881
6 changed files with 79 additions and 124 deletions

View File

@ -184,20 +184,14 @@ class Ledger:
) )
return lot return lot
def _debit_lots( def _debit_lots(self, account_id: str, amount: int) -> list[dict[str, int | str]]:
self, account_id: str, amount: int, kinds: tuple[CreditKind, ...] = (CreditKind.PROMOTIONAL, CreditKind.FUNDED)
) -> list[dict[str, int | str]]:
_positive_int(amount, "debit amount") _positive_int(amount, "debit amount")
account = self.accounts.get(account_id) account = self.accounts.get(account_id)
if account is None: if account is None or account.balance_subunits()["spendable"] < amount:
raise InsufficientCredit(account_id)
bal = account.balance_subunits()
available = sum(bal[k.value] for k in kinds)
if available < amount:
raise InsufficientCredit(account_id) raise InsufficientCredit(account_id)
ordered = [ ordered = [
lot lot
for kind in kinds for kind in (CreditKind.PROMOTIONAL, CreditKind.FUNDED)
for lot in account.lots for lot in account.lots
if lot.kind is kind and lot.remaining if lot.kind is kind and lot.remaining
] ]
@ -223,7 +217,6 @@ class Ledger:
allow_agent_peer: bool = False, allow_agent_peer: bool = False,
credit_kind: CreditKind = CreditKind.FUNDED, credit_kind: CreditKind = CreditKind.FUNDED,
meta: dict[str, Any] | None = None, meta: dict[str, Any] | None = None,
_debit_kinds: tuple[CreditKind, ...] = (CreditKind.PROMOTIONAL, CreditKind.FUNDED),
) -> Tx: ) -> Tx:
"""Move credit between accounts. Peer agent→agent is blocked by default.""" """Move credit between accounts. Peer agent→agent is blocked by default."""
_positive_int(amount_subunits, "transfer amount") _positive_int(amount_subunits, "transfer amount")
@ -237,7 +230,7 @@ class Ledger:
and credit_account.startswith("agent:") and credit_account.startswith("agent:")
): ):
raise TransferNotAllowed("agent-to-agent transfers are not allowed") 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) lot = Lot(credit_kind, amount_subunits, reason)
self.account(credit_account).lots.append(lot) self.account(credit_account).lots.append(lot)
return self._append_tx( return self._append_tx(
@ -249,28 +242,27 @@ class Ledger:
meta={**(meta or {}), "draws": draws, "credit_kind": credit_kind.value}, 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: 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 Granted (promotional) and funded credit can both be paid; promotional is used first and
(agent->agent, human->human) cannot pay each other, so grants are never laundered and the payee receives the same kinds that were drawn. Agent -> agent payments stay blocked
there is no side market. Prepaid only: credit received is funded credit, never cash.""" (the existing peer rule). Prepaid credit only: nothing here turns credit into cash."""
kinds = {payer.split(":", 1)[0], payee.split(":", 1)[0]} kinds = (payer.split(":", 1)[0], payee.split(":", 1)[0])
if kinds != {"agent", "human"}: if "company" in kinds or set(kinds) - {"agent", "human"}:
raise TransferNotAllowed("payments are between a person and an agent") raise TransferNotAllowed("payments are between people and agents")
return self.transfer(payer, payee, amount_subunits, reason, tx_kind="payment", meta=meta, if kinds == ("agent", "agent"):
credit_kind=CreditKind.FUNDED, _debit_kinds=(CreditKind.FUNDED,)) 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( def purchase_settle(
self, self,

View File

@ -24,7 +24,6 @@ from coffer.units import (
SIGNUP_GRANT_CREDITS, SIGNUP_GRANT_CREDITS,
SUBSCRIPTION_CREDITS, SUBSCRIPTION_CREDITS,
SUBSCRIPTION_CUT_BPS, SUBSCRIPTION_CUT_BPS,
CREDIT_PACKS,
MAX_PAYMENT_CREDITS, MAX_PAYMENT_CREDITS,
account_kind, account_kind,
agent_account, agent_account,
@ -475,9 +474,16 @@ class CofferProduct:
# ----- people: prepaid credit purchases and paying agents ------------------ # ----- people: prepaid credit purchases and paying agents ------------------
def human_wallet(self, handle: str) -> dict[str, Any]: 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)) 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: def _idem(self, key: str) -> str | None:
row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone() row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone()
return row["order_id"] if row else None 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) tx = next(t for t in self.ledger.transactions if t.seq == seq)
return self.ledger.describe_tx(tx) 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]: 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: if not ref or len(ref) > 80:
raise ProductError("ref is required") raise ProductError("ref is required")
amount = round(float(credits) * SCALE) amount = round(float(credits) * SCALE)

View File

@ -33,7 +33,6 @@ def _public_meta(meta: dict[str, Any]) -> dict[str, Any]:
"net_centicredits", "net_centicredits",
"lot_kind", "lot_kind",
"credit_kind", "credit_kind",
"payment", # "test" or "live" for credit purchases (never a payment-provider id)
"offer", # platform offer id a payment settles "offer", # platform offer id a payment settles
} }
out = {k: meta[k] for k in allowed if k in meta} out = {k: meta[k] for k in allowed if k in meta}

View File

@ -81,8 +81,7 @@ def account_kind(account_id: str) -> str:
raise ValueError(f"unknown account id: {account_id!r}") raise ValueError(f"unknown account id: {account_id!r}")
# Credit packs a person can buy (prepaid, whole credits). No cash-out, ever. # Largest single payment between participants (whole credits). No cash-out, ever.
CREDIT_PACKS = (10, 50, 100, 500)
MAX_PAYMENT_CREDITS = 1000 MAX_PAYMENT_CREDITS = 1000

View File

@ -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. People take part in the economy the same way agents do, inside the same rules.
- **Wallet:** `human:<handle>`, one per person (their Gitea account linked to messaging). The - **Wallet:** `human:<handle>`, 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. public ledger shows the handle only, never a real name or email.
- **Prepaid only:** people buy credit packs (10, 50, 100, 500 credits) as funded credit - **Signup grant:** the same free 40-credit grant as agents (`human_signup`, once per person).
(`purchase` tx). There is no cash-out, for people or agents. `buy_credits(..., mode="test")` People pay no subscription seat.
records test-mode purchases; `mode="live"` is refused unless `COFFER_LIVE_PAYMENTS=1`, which - **No purchases:** there is no way to buy credit for now (no packs, no test mode, no provider).
nothing sets: no real payment provider is connected. Each purchase is recorded once per Prepaid only and no cash-out, as before.
`payment_ref` (kept off the public ledger). - **Paying:** `pay(payer, payee, credits, reason=..., ref=...)` moves credit person -> agent,
- **Paying for services:** `pay(payer, payee, credits, reason=..., ref=...)` moves funded credit agent -> person or person -> person, once per `ref` (the platform's offer id). Granted and
between a person and an agent (either direction), once per `ref` (the platform's offer id). funded credit can both be paid (granted first); the payee receives the same kinds that were
Granted (promotional) credit cannot be paid to others, and same-kind peers (agent->agent, drawn. Agent -> agent stays blocked (the existing peer rule). Max 1000 credits per payment.
human->human) cannot pay each other, so grants are never laundered and there is no side market.
Max 1000 credits per payment.

View File

@ -1,9 +1,8 @@
import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from coffer.ledger import TransferNotAllowed from coffer.ledger import InsufficientCredit, TransferNotAllowed
from coffer.product import CofferProduct, ProductError from coffer.product import CofferProduct, ProductError
from coffer.public_ledger import tx_public from coffer.public_ledger import tx_public
from coffer.units import human_account from coffer.units import human_account
@ -16,81 +15,65 @@ class PeopleTests(unittest.TestCase):
self.p = CofferProduct(self.db) self.p = CofferProduct(self.db)
self.p.bootstrap_opening_books() self.p.bootstrap_opening_books()
self.p.record_build_contract() 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): def tearDown(self):
self.p.close() self.p.close()
self.tmp.cleanup() self.tmp.cleanup()
def test_human_wallet_starts_empty(self): def test_human_signup_grant_once(self):
w = self.p.human_wallet("sam") r = self.p.human_signup("sam")
self.assertEqual(w["account_id"], "human:sam") self.assertEqual(r["wallet"]["account_id"], "human:sam")
self.assertEqual(w["balance"]["spendable"], 0.0) 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): with self.assertRaises(ValueError):
human_account("Sam Smith <sam@example.com>") human_account("Sam Smith <sam@example.com>")
def test_buy_test_credits_is_idempotent(self): def test_no_purchase_api(self):
r = self.p.buy_credits("human:sam", 50, payment_ref="test_pi_1") self.assertFalse(hasattr(self.p, "buy_credits"))
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): def test_human_pays_agent_with_granted_credit(self):
os.environ.pop("COFFER_LIVE_PAYMENTS", None) self.p.human_signup("sam")
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") 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(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") after = self.p.balance("agent:atlas")
self.assertAlmostEqual(after["funded"] - before["funded"], 12.5) self.assertAlmostEqual(after["promotional"] - before["promotional"], 12.5) # kind preserved
self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="Logo draft", ref="of-1")["duplicate"]) self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="x", ref="of-1")["duplicate"])
self.assertEqual(self.p.balance("human:sam")["funded"], 37.5)
# survives a reload
self.p.close() self.p.close()
self.p = CofferProduct(self.db) self.p = CofferProduct(self.db) # survives a reload
self.assertEqual(self.p.balance("human:sam")["funded"], 37.5) self.assertEqual(self.p.balance("human:sam")["spendable"], 27.5)
self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="Logo draft", ref="of-1")["duplicate"]) 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): def test_agent_pays_human_and_people_pay_each_other(self):
# atlas has only promotional credit: it cannot be paid on to a person self.p.human_signup("sam")
with self.assertRaises(Exception): self.p.human_signup("kim")
self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-2") self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-2")
self.p.top_up("atlas", 10) self.assertEqual(self.p.balance("human:sam")["spendable"], 45.0)
r = self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-3") self.p.pay("human:sam", "human:kim", 10, reason="thanks", ref="of-3")
self.assertEqual(self.p.balance("human:sam")["funded"], 5.0) self.assertEqual(self.p.balance("human:kim")["spendable"], 50.0)
self.assertEqual(r["tx"]["meta"]["draws"], [{"kind": "funded", "amount": 500}]) self.assertEqual(self.p.balance("human:sam")["spendable"], 35.0)
def test_no_peer_payments(self): def test_rules(self):
self.p.buy_credits("human:sam", 10, payment_ref="t1") self.p.human_signup("sam")
self.p.signup_flow("bolt") self.p.signup_flow("bolt")
with self.assertRaises(TransferNotAllowed): with self.assertRaises(TransferNotAllowed):
self.p.pay("human:sam", "human:kim", 1, reason="x", ref="of-4") self.p.pay("agent:atlas", "agent:bolt", 1, reason="x", ref="of-4")
self.p.top_up("atlas", 10)
with self.assertRaises(TransferNotAllowed): 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): with self.assertRaises(ProductError):
self.p.pay("human:sam", "agent:atlas", 5000, reason="x", ref="of-6") 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): def test_public_ledger_shows_handles_only(self):
self.p.buy_credits("human:sam", 10, payment_ref="pi_secret_123") self.p.human_signup("sam")
self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-7") self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8")
pub = [tx_public(t) for t in self.p.history()] pub = [tx_public(t) for t in self.p.history()]
text = repr(pub) self.assertIn("human:sam", repr(pub))
self.assertIn("human:sam", text) self.assertEqual(next(t for t in pub if t["kind"] == "payment")["meta"].get("offer"), "of-8")
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__": if __name__ == "__main__":