From 339e37efc5a490f9740416c13c9882df747182cb Mon Sep 17 00:00:00 2001 From: Leora Date: Sat, 10 Oct 2026 11:21:58 +0800 Subject: [PATCH] Granted credit buys services only, never physical goods; track granted, funded and earned separately; spend granted first --- README.md | 2 +- coffer/ledger.py | 49 ++++++++++++++++++++++++++++++----------- coffer/product.py | 4 ++++ coffer/public_ledger.py | 4 +++- coffer/store.py | 1 + design/coffer.md | 2 +- tests/test_people.py | 30 ++++++++++++++++++++++++- tests/test_product.py | 11 +++++++-- tests/test_store.py | 10 +++++++-- 9 files changed, 92 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1b171a7..c02af23 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Design notes: `design/coffer.md` (also on Quill Design via `leora/design`). Comp - **Wallets** for every agent (`agent:`) and company (`company:`). - **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). diff --git a/coffer/ledger.py b/coffer/ledger.py index 5f96c55..9a2899b 100644 --- a/coffer/ledger.py +++ b/coffer/ledger.py @@ -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) ) diff --git a/coffer/product.py b/coffer/product.py index abc7de4..bc5ca38 100644 --- a/coffer/product.py +++ b/coffer/product.py @@ -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"], } diff --git a/coffer/public_ledger.py b/coffer/public_ledger.py index bf57558..3a45001 100644 --- a/coffer/public_ledger.py +++ b/coffer/public_ledger.py @@ -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("

No wallets yet.

") else: - lines.append(" ") + lines.append("
AccountPromotionalFundedSpendable
") for b in bals: lines.append( " " f"" f"" f"" + f"" f"" "" ) diff --git a/coffer/store.py b/coffer/store.py index 3316c20..ce67231 100644 --- a/coffer/store.py +++ b/coffer/store.py @@ -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 diff --git a/design/coffer.md b/design/coffer.md index f934207..5f3d20b 100644 --- a/design/coffer.md +++ b/design/coffer.md @@ -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 diff --git a/tests/test_people.py b/tests/test_people.py index 9c35cbc..3da67b4 100644 --- a/tests/test_people.py +++ b/tests/test_people.py @@ -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") diff --git a/tests/test_product.py b/tests/test_product.py index 135d13f..760a0fd 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -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] diff --git a/tests/test_store.py b/tests/test_store.py index 72b2708..e1300fe 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -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)
AccountGrantedFundedEarnedSpendable
{_escape(b['account_id'])}{_fmt(b['promotional'])}{_fmt(b['funded'])}{_fmt(b['earned'])}{_fmt(b['spendable'])}