Granted credit buys services only, never physical goods; track granted, funded and earned separately; spend granted first

This commit is contained in:
Leora 2026-10-10 11:21:58 +08:00
parent a523b8a65a
commit 339e37efc5
9 changed files with 92 additions and 21 deletions

View File

@ -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>`). - **Wallets** for every agent (`agent:<id>`) and company (`company:<id>`).
- **Immutable ledger** of every credit movement (SQLite under `data/coffer.db`). - **Immutable ledger** of every credit movement (SQLite under `data/coffer.db`).
- **Opening / first business tx:** Coffer pays Leora **200c** for the product-build contract. - **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**. - **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). - **Catalog:** Coffer shirt at **20c** (printer cost $8, payable by Coffer).

View File

@ -15,14 +15,26 @@ from coffer.units import SCALE, format_credits, to_subunits
class CreditKind(str, Enum): class CreditKind(str, Enum):
FUNDED = "funded" FUNDED = "funded" # prepaid credit Coffer was paid for (top-ups, endowments)
PROMOTIONAL = "promotional" 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): class InsufficientCredit(Exception):
"""The account does not have enough spendable credit.""" """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): class TransferNotAllowed(Exception):
"""Peer agent-to-agent transfer is not allowed.""" """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 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) 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 { return {
"promotional": promotional, "promotional": promotional,
"funded": funded, "funded": funded,
"spendable": promotional + funded, "earned": earned,
"spendable": promotional + funded + earned,
"goods_spendable": funded + earned,
} }
def balance(self) -> dict[str, float]: def balance(self) -> dict[str, float]:
@ -94,7 +109,7 @@ class Ledger:
def balance_subunits(self, account_id: str) -> dict[str, int]: def balance_subunits(self, account_id: str) -> dict[str, int]:
if account_id not in self.accounts: 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() return self.accounts[account_id].balance_subunits()
def balance(self, account_id: str) -> dict[str, float]: def balance(self, account_id: str) -> dict[str, float]:
@ -184,14 +199,17 @@ 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, order=SERVICE_DRAW_ORDER) -> 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: 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) raise InsufficientCredit(account_id)
ordered = [ ordered = [
lot lot
for kind in (CreditKind.PROMOTIONAL, CreditKind.FUNDED) for kind in order
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
] ]
@ -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: 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. """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 Granted (promotional) credit is used first, then funded, then earned. The payee receives
the payee receives the same kinds that were drawn. Agent -> agent payments stay blocked granted credit as granted (so a grant moved between accounts still cannot buy goods) and
(the existing peer rule). Prepaid credit only: nothing here turns credit into cash.""" 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]) kinds = (payer.split(":", 1)[0], payee.split(":", 1)[0])
if "company" in kinds or set(kinds) - {"agent", "human"}: if "company" in kinds or set(kinds) - {"agent", "human"}:
raise TransferNotAllowed("payments are between people and agents") raise TransferNotAllowed("payments are between people and agents")
@ -260,7 +279,8 @@ class Ledger:
raise ValueError("cannot pay yourself") raise ValueError("cannot pay yourself")
draws = self._debit_lots(payer, amount_subunits) draws = self._debit_lots(payer, amount_subunits)
for d in draws: 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, return self._append_tx(kind="payment", amount=amount_subunits, debit_account=payer, credit_account=payee,
reason=reason, meta={**(meta or {}), "draws": draws}) reason=reason, meta={**(meta or {}), "draws": draws})
@ -273,10 +293,13 @@ class Ledger:
*, *,
tx_kind: str = "purchase", tx_kind: str = "purchase",
meta: dict[str, Any] | None = None, meta: dict[str, Any] | None = None,
goods: bool = False,
) -> tuple[list[dict[str, int | str]], Tx]: ) -> 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") _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( self.account(merchant_id).lots.append(
Lot(CreditKind.FUNDED, amount_subunits, reason) Lot(CreditKind.FUNDED, amount_subunits, reason)
) )

View File

@ -277,9 +277,13 @@ class CofferProduct:
"account_id": aid, "account_id": aid,
"promotional": raw["promotional"] / SCALE, "promotional": raw["promotional"] / SCALE,
"funded": raw["funded"] / SCALE, "funded": raw["funded"] / SCALE,
"earned": raw["earned"] / SCALE,
"spendable": raw["spendable"] / 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"], "promotional_centicredits": raw["promotional"],
"funded_centicredits": raw["funded"], "funded_centicredits": raw["funded"],
"earned_centicredits": raw["earned"],
"spendable_centicredits": raw["spendable"], "spendable_centicredits": raw["spendable"],
} }

View File

@ -47,6 +47,7 @@ def balances_public(balances: dict[str, dict[str, Any]]) -> list[dict[str, Any]]
"account_id": account_id, "account_id": account_id,
"promotional": bal.get("promotional"), "promotional": bal.get("promotional"),
"funded": bal.get("funded"), "funded": bal.get("funded"),
"earned": bal.get("earned", 0),
"spendable": bal.get("spendable"), "spendable": bal.get("spendable"),
} }
) )
@ -74,13 +75,14 @@ def render_ledger_article(
if not bals: if not bals:
lines.append(" <p>No wallets yet.</p>") lines.append(" <p>No wallets yet.</p>")
else: 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: for b in bals:
lines.append( lines.append(
" <tr>" " <tr>"
f"<td><code>{_escape(b['account_id'])}</code></td>" f"<td><code>{_escape(b['account_id'])}</code></td>"
f"<td>{_fmt(b['promotional'])}</td>" f"<td>{_fmt(b['promotional'])}</td>"
f"<td>{_fmt(b['funded'])}</td>" f"<td>{_fmt(b['funded'])}</td>"
f"<td>{_fmt(b['earned'])}</td>"
f"<td>{_fmt(b['spendable'])}</td>" f"<td>{_fmt(b['spendable'])}</td>"
"</tr>" "</tr>"
) )

View File

@ -145,6 +145,7 @@ class Store:
reason=f"purchase:{sku}", reason=f"purchase:{sku}",
tx_kind="purchase", tx_kind="purchase",
meta={"sku": sku, "quantity": quantity}, 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] draws = [{"kind": d["kind"], "amount": int(d["amount"]) // 100} for d in draws_sub]
good.stock -= from_stock good.stock -= from_stock

View File

@ -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. 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 ## Component

View File

@ -2,7 +2,7 @@ import tempfile
import unittest import unittest
from pathlib import Path 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.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
@ -110,6 +110,34 @@ class PeopleTests(unittest.TestCase):
with self.assertRaises(InsufficientCredit): with self.assertRaises(InsufficientCredit):
self.p.pay("human:sam", "agent:atlas", 41, reason="x", ref="of-7") 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): def test_public_ledger_shows_handles_only(self):
self.p.human_signup("sam", 101) self.p.human_signup("sam", 101)
self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8") self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8")

View File

@ -1,5 +1,7 @@
import tempfile import tempfile
import unittest import unittest
from coffer.ledger import GrantedCreditNotForGoods
from pathlib import Path from pathlib import Path
from coffer.product import CofferProduct from coffer.product import CofferProduct
@ -105,11 +107,15 @@ class ProductTests(unittest.TestCase):
def test_shirt_purchase_20c(self): def test_shirt_purchase_20c(self):
self.product.bootstrap_opening_books() self.product.bootstrap_opening_books()
self.product.record_build_contract() 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") out = self.product.buy_shirt("shopper")
self.assertEqual(out["order"]["credits_charged"], 20) self.assertEqual(out["order"]["credits_charged"], 20)
self.assertEqual(out["order"]["sku"], "coffer-shirt") 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(len(out["txs"]), 1)
self.assertEqual(out["txs"][0]["kind"], "purchase") self.assertEqual(out["txs"][0]["kind"], "purchase")
@ -117,6 +123,7 @@ class ProductTests(unittest.TestCase):
self.product.bootstrap_opening_books() self.product.bootstrap_opening_books()
self.product.record_build_contract() self.product.record_build_contract()
self.product.signup_flow("hist-agent") self.product.signup_flow("hist-agent")
self.product.top_up("hist-agent", 20)
self.product.buy_shirt("hist-agent") self.product.buy_shirt("hist-agent")
hist = self.product.history() hist = self.product.history()
kinds = [t["kind"] for t in hist] kinds = [t["kind"] for t in hist]

View File

@ -1,5 +1,7 @@
import unittest import unittest
from coffer.ledger import GrantedCreditNotForGoods
from coffer import CannotSupply, InsufficientCredit, UnknownGood, new_store from coffer import CannotSupply, InsufficientCredit, UnknownGood, new_store
from coffer.ledger import Ledger from coffer.ledger import Ledger
from coffer.store import Store from coffer.store import Store
@ -12,7 +14,7 @@ class LedgerTests(unittest.TestCase):
ledger.top_up("cursorMan", 20) ledger.top_up("cursorMan", 20)
self.assertEqual( self.assertEqual(
ledger.balance("cursorMan"), 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): def test_grant_is_promotional(self):
@ -56,6 +58,9 @@ class StoreTests(unittest.TestCase):
store = new_store() store = new_store()
buyer = agent_account("cursorMan") buyer = agent_account("cursorMan")
store.ledger.grant(buyer, 20, "launch shirt") 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") order = store.place_order("cursorMan", "coffer-shirt")
self.assertEqual(order.credits_charged, 20) self.assertEqual(order.credits_charged, 20)
self.assertEqual(order.status, "procuring") self.assertEqual(order.status, "procuring")
@ -64,7 +69,8 @@ class StoreTests(unittest.TestCase):
self.assertEqual(invoice.supplier_id, "printer") self.assertEqual(invoice.supplier_id, "printer")
self.assertEqual(invoice.dollars, 8) self.assertEqual(invoice.dollars, 8)
self.assertEqual(invoice.status, "payable") 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.assertEqual(store.ledger.balance(ACCOUNT_COFFER)["spendable"], 20.0)
self.assertNotIn("printer", store.ledger.accounts) self.assertNotIn("printer", store.ledger.accounts)