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
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,

View File

@ -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)

View File

@ -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}

View File

@ -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

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.
- **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.
- **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.

View File

@ -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 <sam@example.com>")
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__":