People: human wallets, signup grant, payments between people and agents #7

Open
leora wants to merge 7 commits from leora/human-wallets into main
6 changed files with 237 additions and 10 deletions
Showing only changes of commit bd59063418 - Show all commits

View File

@ -184,14 +184,20 @@ class Ledger:
) )
return lot 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") _positive_int(amount, "debit amount")
account = self.accounts.get(account_id) 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) raise InsufficientCredit(account_id)
ordered = [ ordered = [
lot lot
for kind in (CreditKind.PROMOTIONAL, CreditKind.FUNDED) for kind in kinds
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
] ]
@ -217,6 +223,7 @@ 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")
@ -230,7 +237,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) draws = self._debit_lots(debit_account, amount_subunits, _debit_kinds)
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(
@ -242,6 +249,29 @@ 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:
"""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( def purchase_settle(
self, self,
buyer_id: str, buyer_id: str,

View File

@ -24,7 +24,11 @@ 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,
account_kind,
agent_account, agent_account,
human_account,
cut_subunits, cut_subunits,
format_credits, format_credits,
to_subunits, to_subunits,
@ -152,7 +156,7 @@ class CofferProduct:
pass pass
def _ensure_account_row(self, account_id: str) -> None: 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( self.db.execute(
"INSERT OR IGNORE INTO accounts(account_id, kind, created_at) VALUES(?,?,?)", "INSERT OR IGNORE INTO accounts(account_id, kind, created_at) VALUES(?,?,?)",
(account_id, kind, now_pt()), (account_id, kind, now_pt()),
@ -258,7 +262,7 @@ class CofferProduct:
# ----- public API ---------------------------------------------------------- # ----- public API ----------------------------------------------------------
def create_wallet(self, account_id: str) -> dict[str, Any]: def create_wallet(self, account_id: str) -> dict[str, Any]:
if account_id.startswith("company:"): if account_id.startswith(("company:", "human:")):
aid = account_id aid = account_id
else: else:
aid = agent_account(account_id) aid = agent_account(account_id)
@ -269,7 +273,7 @@ class CofferProduct:
def balance(self, account_id: str) -> dict[str, Any]: def balance(self, account_id: str) -> dict[str, Any]:
aid = account_id aid = account_id
if not (aid.startswith("agent:") or aid.startswith("company:")): if not aid.startswith(("agent:", "company:", "human:")):
aid = agent_account(aid) aid = agent_account(aid)
raw = self.ledger.balance_subunits(aid) raw = self.ledger.balance_subunits(aid)
return { return {
@ -468,6 +472,60 @@ class CofferProduct:
"tx": self.ledger.describe_tx(txs[-1]), "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]: def all_balances(self) -> dict[str, dict]:
return {aid: self.balance(aid) for aid in sorted(self.ledger.accounts.keys())} return {aid: self.balance(aid) for aid in sorted(self.ledger.accounts.keys())}

View File

@ -8,7 +8,7 @@ from coffer.units import format_credits
def tx_public(tx: dict[str, Any]) -> dict[str, Any]: 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 { return {
"seq": tx.get("seq"), "seq": tx.get("seq"),
"kind": tx.get("kind"), "kind": tx.get("kind"),
@ -33,6 +33,8 @@ 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
} }
out = {k: meta[k] for k in allowed if k in meta} out = {k: meta[k] for k in allowed if k in meta}
return out return out
@ -66,7 +68,7 @@ def render_ledger_article(
" <article id=\"coffer-public-ledger\">", " <article id=\"coffer-public-ledger\">",
" <h2>Coffer public ledger</h2>", " <h2>Coffer public ledger</h2>",
f" <p>{_escape(updated_at)}. Maintained by {_escape(author)}. Every credit movement on Coffer is public.</p>", f" <p>{_escape(updated_at)}. Maintained by {_escape(author)}. Every credit movement on Coffer is public.</p>",
" <p>Accounts are agent and company ids only. No human names or emails appear here. " " <p>Accounts are agent and company ids and people's public handles (human:&lt;handle&gt;) 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.</p>", "One agent credit is one US dollar. Amounts may show centicredits (1/100 credit) when a reseller cut needs precision.</p>",
" <h3>Balances</h3>", " <h3>Balances</h3>",
] ]

View File

@ -58,11 +58,34 @@ def agent_account(agent_id: str) -> str:
aid = (agent_id or "").strip() aid = (agent_id or "").strip()
if not aid: if not aid:
raise ValueError("agent_id is required") raise ValueError("agent_id is required")
if aid.startswith("agent:") or aid.startswith("company:"): if aid.startswith(("agent:", "company:", "human:")):
return aid return aid
return f"agent:{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: def company_account(company_id: str) -> str:
cid = (company_id or "").strip() cid = (company_id or "").strip()
if not cid: if not cid:

View File

@ -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`. 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`. 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:<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.

97
tests/test_people.py Normal file
View File

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