People: human wallets, signup grant, payments between people and agents #7
@ -9,7 +9,7 @@ Design notes: `design/coffer.md` (also on Quill Design via `leora/design`). Comp
|
||||
- **Wallets** for every agent (`agent:<id>`) and company (`company:<id>`).
|
||||
- **Immutable ledger** of every credit movement (SQLite under `data/coffer.db`).
|
||||
- **Opening / first business tx:** Coffer pays Leora **200c** for the product-build contract.
|
||||
- **Signup grant:** new agents get **40c** promotional credit.
|
||||
- **Signup grant:** new agents (and people) get **40c** of granted (promotional) credit. It pays for services only and never buys physical goods. Goods need funded or earned credit.
|
||||
- **Subscription via Coffer:** **20c** seat; Coffer keeps **1%** (0.20c); New Internet Co nets **19.80c**.
|
||||
- **Catalog:** Coffer shirt at **20c** (printer cost $8, payable by Coffer).
|
||||
|
||||
|
||||
@ -15,14 +15,26 @@ from coffer.units import SCALE, format_credits, to_subunits
|
||||
|
||||
|
||||
class CreditKind(str, Enum):
|
||||
FUNDED = "funded"
|
||||
PROMOTIONAL = "promotional"
|
||||
FUNDED = "funded" # prepaid credit Coffer was paid for (top-ups, endowments)
|
||||
PROMOTIONAL = "promotional" # granted for free (signup grants): services only, never goods
|
||||
EARNED = "earned" # funded/earned credit received as payment for a service
|
||||
|
||||
|
||||
# What may pay for what. Granted credit is spent first on services; physical goods can only be
|
||||
# bought with funded or earned credit, so free grants (or grants moved between accounts) can
|
||||
# never be turned into goods.
|
||||
SERVICE_DRAW_ORDER = (CreditKind.PROMOTIONAL, CreditKind.FUNDED, CreditKind.EARNED)
|
||||
GOODS_DRAW_ORDER = (CreditKind.FUNDED, CreditKind.EARNED)
|
||||
|
||||
|
||||
class InsufficientCredit(Exception):
|
||||
"""The account does not have enough spendable credit."""
|
||||
|
||||
|
||||
class GrantedCreditNotForGoods(InsufficientCredit):
|
||||
"""Physical goods need funded or earned credit; granted credit cannot buy them."""
|
||||
|
||||
|
||||
class TransferNotAllowed(Exception):
|
||||
"""Peer agent-to-agent transfer is not allowed."""
|
||||
|
||||
@ -44,10 +56,13 @@ class Account:
|
||||
lot.remaining for lot in self.lots if lot.kind is CreditKind.PROMOTIONAL
|
||||
)
|
||||
funded = sum(lot.remaining for lot in self.lots if lot.kind is CreditKind.FUNDED)
|
||||
earned = sum(lot.remaining for lot in self.lots if lot.kind is CreditKind.EARNED)
|
||||
return {
|
||||
"promotional": promotional,
|
||||
"funded": funded,
|
||||
"spendable": promotional + funded,
|
||||
"earned": earned,
|
||||
"spendable": promotional + funded + earned,
|
||||
"goods_spendable": funded + earned,
|
||||
}
|
||||
|
||||
def balance(self) -> dict[str, float]:
|
||||
@ -94,7 +109,7 @@ class Ledger:
|
||||
|
||||
def balance_subunits(self, account_id: str) -> dict[str, int]:
|
||||
if account_id not in self.accounts:
|
||||
return {"promotional": 0, "funded": 0, "spendable": 0}
|
||||
return {"promotional": 0, "funded": 0, "earned": 0, "spendable": 0, "goods_spendable": 0}
|
||||
return self.accounts[account_id].balance_subunits()
|
||||
|
||||
def balance(self, account_id: str) -> dict[str, float]:
|
||||
@ -184,14 +199,17 @@ class Ledger:
|
||||
)
|
||||
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, order=SERVICE_DRAW_ORDER) -> list[dict[str, int | str]]:
|
||||
_positive_int(amount, "debit amount")
|
||||
account = self.accounts.get(account_id)
|
||||
if account is None or account.balance_subunits()["spendable"] < amount:
|
||||
available = sum(lot.remaining for lot in (account.lots if account else []) if lot.kind in order)
|
||||
if account is None or available < amount:
|
||||
if account is not None and order == GOODS_DRAW_ORDER and account.balance_subunits()["spendable"] >= amount:
|
||||
raise GrantedCreditNotForGoods(account_id)
|
||||
raise InsufficientCredit(account_id)
|
||||
ordered = [
|
||||
lot
|
||||
for kind in (CreditKind.PROMOTIONAL, CreditKind.FUNDED)
|
||||
for kind in order
|
||||
for lot in account.lots
|
||||
if lot.kind is kind and lot.remaining
|
||||
]
|
||||
@ -245,9 +263,10 @@ class Ledger:
|
||||
def pay(self, payer: str, payee: str, amount_subunits: int, reason: str, *, meta: dict[str, Any] | None = None) -> Tx:
|
||||
"""Pay for something between people, or between a person and an agent.
|
||||
|
||||
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."""
|
||||
Granted (promotional) credit is used first, then funded, then earned. The payee receives
|
||||
granted credit as granted (so a grant moved between accounts still cannot buy goods) and
|
||||
funded or earned credit as earned. Agent -> agent payments stay blocked (the existing
|
||||
peer rule). Prepaid credit only: nothing here turns credit into cash or goods."""
|
||||
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")
|
||||
@ -260,7 +279,8 @@ class Ledger:
|
||||
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))
|
||||
kind = CreditKind.PROMOTIONAL if d["kind"] == CreditKind.PROMOTIONAL.value else CreditKind.EARNED
|
||||
self.account(payee).lots.append(Lot(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})
|
||||
|
||||
@ -273,10 +293,13 @@ class Ledger:
|
||||
*,
|
||||
tx_kind: str = "purchase",
|
||||
meta: dict[str, Any] | None = None,
|
||||
goods: bool = False,
|
||||
) -> tuple[list[dict[str, int | str]], Tx]:
|
||||
"""Buyer pays merchant: debit buyer lots, credit merchant funded lot."""
|
||||
"""Buyer pays merchant: debit buyer lots, credit merchant funded lot. For physical goods
|
||||
(goods=True) only funded or earned credit may be drawn, never granted credit; services
|
||||
(subscriptions) spend granted credit first."""
|
||||
_positive_int(amount_subunits, "purchase amount")
|
||||
draws = self._debit_lots(buyer_id, amount_subunits)
|
||||
draws = self._debit_lots(buyer_id, amount_subunits, order=GOODS_DRAW_ORDER if goods else SERVICE_DRAW_ORDER)
|
||||
self.account(merchant_id).lots.append(
|
||||
Lot(CreditKind.FUNDED, amount_subunits, reason)
|
||||
)
|
||||
|
||||
@ -277,9 +277,13 @@ class CofferProduct:
|
||||
"account_id": aid,
|
||||
"promotional": raw["promotional"] / SCALE,
|
||||
"funded": raw["funded"] / SCALE,
|
||||
"earned": raw["earned"] / SCALE,
|
||||
"spendable": raw["spendable"] / SCALE,
|
||||
"goods_spendable": raw["goods_spendable"] / SCALE,
|
||||
"granted": raw["promotional"] / SCALE, # alias: free grant credit (services only)
|
||||
"promotional_centicredits": raw["promotional"],
|
||||
"funded_centicredits": raw["funded"],
|
||||
"earned_centicredits": raw["earned"],
|
||||
"spendable_centicredits": raw["spendable"],
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ def balances_public(balances: dict[str, dict[str, Any]]) -> list[dict[str, Any]]
|
||||
"account_id": account_id,
|
||||
"promotional": bal.get("promotional"),
|
||||
"funded": bal.get("funded"),
|
||||
"earned": bal.get("earned", 0),
|
||||
"spendable": bal.get("spendable"),
|
||||
}
|
||||
)
|
||||
@ -74,13 +75,14 @@ def render_ledger_article(
|
||||
if not bals:
|
||||
lines.append(" <p>No wallets yet.</p>")
|
||||
else:
|
||||
lines.append(" <table><thead><tr><th>Account</th><th>Promotional</th><th>Funded</th><th>Spendable</th></tr></thead><tbody>")
|
||||
lines.append(" <table><thead><tr><th>Account</th><th>Granted</th><th>Funded</th><th>Earned</th><th>Spendable</th></tr></thead><tbody>")
|
||||
for b in bals:
|
||||
lines.append(
|
||||
" <tr>"
|
||||
f"<td><code>{_escape(b['account_id'])}</code></td>"
|
||||
f"<td>{_fmt(b['promotional'])}</td>"
|
||||
f"<td>{_fmt(b['funded'])}</td>"
|
||||
f"<td>{_fmt(b['earned'])}</td>"
|
||||
f"<td>{_fmt(b['spendable'])}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
|
||||
@ -145,6 +145,7 @@ class Store:
|
||||
reason=f"purchase:{sku}",
|
||||
tx_kind="purchase",
|
||||
meta={"sku": sku, "quantity": quantity},
|
||||
goods=True, # every catalog item is a physical good: granted credit cannot pay
|
||||
)
|
||||
draws = [{"kind": d["kind"], "amount": int(d["amount"]) // 100} for d in draws_sub]
|
||||
good.stock -= from_stock
|
||||
|
||||
@ -61,7 +61,7 @@ A supplier invoices Coffer in dollars and is paid from Coffer's own account, on
|
||||
|
||||
The Coffer shirt is 20 agent credits. The opening supplier cost recorded for the printer is 8 dollars. That cost is Coffer's payable, not the price the buyer pays.
|
||||
|
||||
Promotional credit granted for the launch spends on the shirt the same way funded credit does. Coffer still pays the printer.
|
||||
Granted (promotional) credit, such as the 40c signup grant, pays for services only: subscriptions and paid work between people and agents. It never buys the shirt or any other physical good, so free grants cannot be farmed into goods, which would amount to a cash-out. Goods need funded credit (prepaid top-ups) or earned credit (funded or earned credit received as payment for a service). Balances track granted, funded and earned credit separately. Services spend granted credit first. When credit is paid on, granted credit arrives as granted and funded or earned credit arrives as earned. Coffer still pays the printer.
|
||||
|
||||
## Component
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from coffer.ledger import InsufficientCredit, TransferNotAllowed
|
||||
from coffer.ledger import GrantedCreditNotForGoods, InsufficientCredit, TransferNotAllowed
|
||||
from coffer.product import CofferProduct, ProductError
|
||||
from coffer.public_ledger import tx_public
|
||||
from coffer.units import human_account
|
||||
@ -110,6 +110,34 @@ class PeopleTests(unittest.TestCase):
|
||||
with self.assertRaises(InsufficientCredit):
|
||||
self.p.pay("human:sam", "agent:atlas", 41, reason="x", ref="of-7")
|
||||
|
||||
# ----- granted vs earned: free grants buy services, never physical goods
|
||||
def test_grants_cannot_become_goods(self):
|
||||
# Sybil farming: many people each pay their 40c grant to one account, which tries to buy shirts
|
||||
for i in range(3):
|
||||
self.p.human_signup(f"sock{i}", 900 + i)
|
||||
self.p.pay(f"human:sock{i}", "human:collector" if i else "human:collector", 40, reason="gift", ref=f"farm-{i}")
|
||||
bal = self.p.balance("human:collector")
|
||||
self.assertEqual((bal["granted"], bal["earned"], bal["goods_spendable"]), (120.0, 0.0, 0.0))
|
||||
self.p.pay("human:collector", "atlas", 100, reason="service", ref="farm-to-agent")
|
||||
a = self.p.balance("atlas")
|
||||
self.assertEqual(a["goods_spendable"], 0.0) # still granted credit at the agent
|
||||
with self.assertRaises(GrantedCreditNotForGoods):
|
||||
self.p.buy_shirt("atlas")
|
||||
|
||||
def test_earned_credit_can_buy_goods_and_grants_spend_first(self):
|
||||
self.p.human_signup("sam", 101)
|
||||
self.p.top_up("atlas", 30) # atlas: 20 granted + 30 funded
|
||||
r = self.p.pay("atlas", "human:sam", 25, reason="refund for a service", ref="atlas-sam-1")
|
||||
a = self.p.balance("atlas")
|
||||
self.assertEqual((a["granted"], a["funded"]), (0.0, 25.0)) # granted spent first
|
||||
sam = self.p.balance("human:sam")
|
||||
self.assertEqual((sam["granted"], sam["earned"]), (60.0, 5.0)) # 20 granted + 5 funded arrived as earned
|
||||
self.p.pay("human:sam", "atlas", 45, reason="service", ref="sam-atlas-1") # 40+20 granted first
|
||||
self.assertEqual(self.p.balance("human:sam")["earned"], 5.0)
|
||||
out = self.p.buy_shirt("atlas") # 20c shirt from funded credit
|
||||
self.assertEqual(out["order"]["credits_charged"], 20)
|
||||
self.assertEqual(self.p.balance("atlas")["granted"], 45.0)
|
||||
|
||||
def test_public_ledger_shows_handles_only(self):
|
||||
self.p.human_signup("sam", 101)
|
||||
self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8")
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from coffer.ledger import GrantedCreditNotForGoods
|
||||
from pathlib import Path
|
||||
|
||||
from coffer.product import CofferProduct
|
||||
@ -105,11 +107,15 @@ class ProductTests(unittest.TestCase):
|
||||
def test_shirt_purchase_20c(self):
|
||||
self.product.bootstrap_opening_books()
|
||||
self.product.record_build_contract()
|
||||
self.product.signup_flow("shopper") # left with 20c
|
||||
self.product.signup_flow("shopper") # left with 20c of granted credit
|
||||
with self.assertRaises(GrantedCreditNotForGoods):
|
||||
self.product.buy_shirt("shopper") # free grant credit never buys physical goods
|
||||
self.assertEqual(self.product.balance("shopper")["spendable"], 20.0)
|
||||
self.product.top_up("shopper", 20) # funded credit can
|
||||
out = self.product.buy_shirt("shopper")
|
||||
self.assertEqual(out["order"]["credits_charged"], 20)
|
||||
self.assertEqual(out["order"]["sku"], "coffer-shirt")
|
||||
self.assertEqual(out["balance"]["spendable"], 0.0)
|
||||
self.assertEqual((out["balance"]["spendable"], out["balance"]["granted"], out["balance"]["funded"]), (20.0, 20.0, 0.0))
|
||||
self.assertEqual(len(out["txs"]), 1)
|
||||
self.assertEqual(out["txs"][0]["kind"], "purchase")
|
||||
|
||||
@ -117,6 +123,7 @@ class ProductTests(unittest.TestCase):
|
||||
self.product.bootstrap_opening_books()
|
||||
self.product.record_build_contract()
|
||||
self.product.signup_flow("hist-agent")
|
||||
self.product.top_up("hist-agent", 20)
|
||||
self.product.buy_shirt("hist-agent")
|
||||
hist = self.product.history()
|
||||
kinds = [t["kind"] for t in hist]
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import unittest
|
||||
|
||||
from coffer.ledger import GrantedCreditNotForGoods
|
||||
|
||||
from coffer import CannotSupply, InsufficientCredit, UnknownGood, new_store
|
||||
from coffer.ledger import Ledger
|
||||
from coffer.store import Store
|
||||
@ -12,7 +14,7 @@ class LedgerTests(unittest.TestCase):
|
||||
ledger.top_up("cursorMan", 20)
|
||||
self.assertEqual(
|
||||
ledger.balance("cursorMan"),
|
||||
{"promotional": 0.0, "funded": 20.0, "spendable": 20.0},
|
||||
{"promotional": 0.0, "funded": 20.0, "earned": 0.0, "spendable": 20.0, "goods_spendable": 20.0},
|
||||
)
|
||||
|
||||
def test_grant_is_promotional(self):
|
||||
@ -56,6 +58,9 @@ class StoreTests(unittest.TestCase):
|
||||
store = new_store()
|
||||
buyer = agent_account("cursorMan")
|
||||
store.ledger.grant(buyer, 20, "launch shirt")
|
||||
with self.assertRaises(GrantedCreditNotForGoods):
|
||||
store.place_order("cursorMan", "coffer-shirt") # granted credit: services only
|
||||
store.ledger.top_up(buyer, 20)
|
||||
order = store.place_order("cursorMan", "coffer-shirt")
|
||||
self.assertEqual(order.credits_charged, 20)
|
||||
self.assertEqual(order.status, "procuring")
|
||||
@ -64,7 +69,8 @@ class StoreTests(unittest.TestCase):
|
||||
self.assertEqual(invoice.supplier_id, "printer")
|
||||
self.assertEqual(invoice.dollars, 8)
|
||||
self.assertEqual(invoice.status, "payable")
|
||||
self.assertEqual(store.ledger.balance(buyer)["spendable"], 0.0)
|
||||
self.assertEqual(store.ledger.balance(buyer)["spendable"], 20.0) # the grant is untouched
|
||||
self.assertEqual(store.ledger.balance(buyer)["promotional"], 20.0)
|
||||
self.assertEqual(store.ledger.balance(ACCOUNT_COFFER)["spendable"], 20.0)
|
||||
self.assertNotIn("printer", store.ledger.accounts)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user