forked from cursorman/coffer
People: same signup grant as agents; payments with granted credit; person-to-person allowed; no purchases
This commit is contained in:
parent
bd59063418
commit
ab49a82881
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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__":
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user