Compare commits

...

7 Commits

12 changed files with 640 additions and 97 deletions

View File

@ -9,7 +9,8 @@ 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. Any credit, granted included, can pay for services and order goods. Granted credit is spent first.
- **Orders are reviewed:** every shirt order holds its price (escrow, not charged) and waits as *pending review*. The operator accepts (credits charged, printing queued) or rejects (hold released in full). At most 2 pending orders per account. Operator commands: `pending-orders`, `accept-order`, `reject-order`; the to-do list is also written to `pending-orders.json` next to the database.
- **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).
@ -32,7 +33,9 @@ python3 -m coffer create-wallet alice
python3 -m coffer grant-signup alice # 40c promo
python3 -m coffer charge-subscription alice # 20c via Coffer, 1% cut
python3 -m coffer signup alice # wallet + grant + subscription
python3 -m coffer buy-shirt alice
python3 -m coffer buy-shirt alice # held, pending review
python3 -m coffer pending-orders
python3 -m coffer accept-order ord-7 # or reject-order ord-7 --note "out of stock"
python3 -m coffer balance alice
python3 -m coffer history
python3 -m coffer catalog

View File

@ -42,7 +42,7 @@ def build_parser() -> argparse.ArgumentParser:
sf = sub.add_parser("signup", help="Wallet + 40c grant + 20c subscription flow")
sf.add_argument("agent_id")
sh = sub.add_parser("buy-shirt", help="Buy Coffer shirt (20c)")
sh = sub.add_parser("buy-shirt", help="Order a Coffer shirt (20c held until the operator reviews it)")
sh.add_argument("agent_id")
sh.add_argument("--qty", type=int, default=1)
sh.add_argument("--idempotency-key")
@ -51,6 +51,13 @@ def build_parser() -> argparse.ArgumentParser:
t.add_argument("agent_id")
t.add_argument("amount", type=int)
sub.add_parser("pending-orders", help="Operator: orders waiting for review")
for name in ("accept-order", "reject-order"):
o = sub.add_parser(name, help=f"Operator ({'charge the hold and queue printing' if name == 'accept-order' else 'release the hold in full'})")
o.add_argument("order_id")
o.add_argument("--note", default="")
o.add_argument("--actor", default="agent:leora")
sub.add_parser("history", help="Print full ledger transaction history")
sub.add_parser("catalog", help="Show catalog goods")
sub.add_parser("public-ledger", help="Print public ledger JSON (no human PII)")
@ -83,6 +90,12 @@ def main(argv: list[str] | None = None) -> int:
args.agent_id, quantity=args.qty, idempotency_key=args.idempotency_key
)
)
elif args.cmd == "pending-orders":
_print(product.pending_orders())
elif args.cmd == "accept-order":
_print(product.accept_order(args.order_id, args.actor, args.note))
elif args.cmd == "reject-order":
_print(product.reject_order(args.order_id, args.actor, args.note))
elif args.cmd == "top-up":
_print(product.top_up(args.agent_id, args.amount))
elif args.cmd == "history":

View File

@ -95,6 +95,11 @@ class CofferDB:
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA foreign_keys=ON")
self._conn.executescript(SCHEMA)
cols = {r["name"] for r in self._conn.execute("PRAGMA table_info(orders)").fetchall()}
for col, decl in (("held", "INTEGER NOT NULL DEFAULT 0"), ("hold_tx_seq", "INTEGER"), ("reviewed_by", "TEXT"),
("reviewed_at", "TEXT"), ("review_note", "TEXT")):
if col not in cols:
self._conn.execute(f"ALTER TABLE orders ADD COLUMN {col} {decl}")
self._conn.commit()
def close(self) -> None:

View File

@ -15,8 +15,14 @@ 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)
EARNED = "earned" # funded/earned credit received as payment for a service
# Granted credit is always spent first. Every credit kind can pay for anything; physical goods
# are protected by manual order review instead (see Store.place_order).
SERVICE_DRAW_ORDER = (CreditKind.PROMOTIONAL, CreditKind.FUNDED, CreditKind.EARNED)
class InsufficientCredit(Exception):
@ -44,10 +50,12 @@ 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,
}
def balance(self) -> dict[str, float]:
@ -94,7 +102,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}
return self.accounts[account_id].balance_subunits()
def balance(self, account_id: str) -> dict[str, float]:
@ -184,14 +192,15 @@ 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:
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
]
@ -242,6 +251,30 @@ class Ledger:
meta={**(meta or {}), "draws": draws, "credit_kind": credit_kind.value},
)
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) 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."""
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:
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})
def purchase_settle(
self,
buyer_id: str,
@ -252,7 +285,7 @@ class Ledger:
tx_kind: str = "purchase",
meta: dict[str, Any] | None = None,
) -> tuple[list[dict[str, int | str]], Tx]:
"""Buyer pays merchant: debit buyer lots, credit merchant funded lot."""
"""Buyer pays merchant: debit buyer lots (granted first), credit merchant funded lot."""
_positive_int(amount_subunits, "purchase amount")
draws = self._debit_lots(buyer_id, amount_subunits)
self.account(merchant_id).lots.append(
@ -268,6 +301,31 @@ class Ledger:
)
return draws, tx
# --- order holds (escrow): credits leave the buyer's spendable balance but are not charged
def hold(self, buyer_id: str, escrow_id: str, amount_subunits: int, reason: str, *, meta=None) -> tuple[list, Tx]:
_positive_int(amount_subunits, "hold amount")
draws = self._debit_lots(buyer_id, amount_subunits)
self.account(escrow_id).lots.append(Lot(CreditKind.FUNDED, amount_subunits, reason))
tx = self._append_tx(kind="order_hold", amount=amount_subunits, debit_account=buyer_id, credit_account=escrow_id,
reason=reason, meta={**(meta or {}), "draws": draws})
return draws, tx
def release_hold(self, escrow_id: str, buyer_id: str, draws: list, reason: str, *, meta=None) -> Tx:
"""Give held credit back in full, as the same kinds it was drawn from."""
amount = sum(int(d["amount"]) for d in draws)
self._debit_lots(escrow_id, amount)
for d in draws:
self.account(buyer_id).lots.append(Lot(CreditKind(d["kind"]), int(d["amount"]), reason))
return self._append_tx(kind="order_release", amount=amount, debit_account=escrow_id, credit_account=buyer_id,
reason=reason, meta={**(meta or {}), "draws": draws})
def settle_hold(self, escrow_id: str, merchant_id: str, amount_subunits: int, reason: str, *, meta=None) -> Tx:
"""Charge held credit: escrow pays the merchant."""
self._debit_lots(escrow_id, amount_subunits)
self.account(merchant_id).lots.append(Lot(CreditKind.FUNDED, amount_subunits, reason))
return self._append_tx(kind="purchase", amount=amount_subunits, debit_account=escrow_id, credit_account=merchant_id,
reason=reason, meta=meta or {})
def bootstrap_endowment(
self,
account_id: str,

View File

@ -2,6 +2,9 @@
from __future__ import annotations
import contextlib
import math
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
@ -11,7 +14,7 @@ from zoneinfo import ZoneInfo
from coffer.db import CofferDB
from coffer.public_ledger import snapshot_from_product
from coffer.ledger import CreditKind, Ledger, Lot, Tx
from coffer.store import Good, Invoice, Order, new_store
from coffer.store import ACCOUNT_ESCROW, ORDER_REVIEW_NOTICE, Good, Invoice, Order, new_store
from coffer.units import (
ACCOUNT_COFFER,
ACCOUNT_LEORA,
@ -24,7 +27,10 @@ from coffer.units import (
SIGNUP_GRANT_CREDITS,
SUBSCRIPTION_CREDITS,
SUBSCRIPTION_CUT_BPS,
MAX_PAYMENT_CREDITS,
account_kind,
agent_account,
human_account,
cut_subunits,
format_credits,
to_subunits,
@ -137,6 +143,11 @@ class CofferProduct:
status=row["status"],
invoice_id=row["invoice_id"],
tx_seq=row["tx_seq"],
held=int(row["held"] or 0),
hold_tx_seq=row["hold_tx_seq"],
reviewed_by=row["reviewed_by"],
reviewed_at=row["reviewed_at"],
review_note=row["review_note"],
)
self.store.orders[order.order_id] = order
self._bump_seq_from_id(order.order_id)
@ -152,7 +163,7 @@ class CofferProduct:
pass
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(
"INSERT OR IGNORE INTO accounts(account_id, kind, created_at) VALUES(?,?,?)",
(account_id, kind, now_pt()),
@ -210,7 +221,8 @@ class CofferProduct:
self.db.execute(
"INSERT OR REPLACE INTO orders(order_id, buyer_id, sku, quantity, "
"credits_charged, draws_json, from_stock, procured, status, invoice_id, "
"tx_seq, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
"tx_seq, created_at, held, hold_tx_seq, reviewed_by, reviewed_at, review_note) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,COALESCE((SELECT created_at FROM orders WHERE order_id=?),?),?,?,?,?,?)",
(
order.order_id,
order.buyer_id,
@ -223,7 +235,13 @@ class CofferProduct:
order.status,
order.invoice_id,
order.tx_seq,
order.order_id,
now_pt(),
order.held,
order.hold_tx_seq,
order.reviewed_by,
order.reviewed_at,
order.review_note,
),
)
if order.invoice_id and order.invoice_id in self.store.invoices:
@ -258,27 +276,26 @@ class CofferProduct:
# ----- public API ----------------------------------------------------------
def create_wallet(self, account_id: str) -> dict[str, Any]:
if account_id.startswith("company:"):
aid = account_id
else:
aid = agent_account(account_id)
aid = human_account(account_id) if account_id.startswith("human:") else agent_account(account_id)
self.ledger.ensure_wallet(aid)
self._ensure_account_row(aid)
self.db.commit()
return {"account_id": aid, "balance": self.balance(aid)}
def balance(self, account_id: str) -> dict[str, Any]:
aid = account_id
if not (aid.startswith("agent:") or aid.startswith("company:")):
aid = agent_account(aid)
aid = human_account(account_id) if account_id.startswith("human:") else agent_account(account_id)
raw = self.ledger.balance_subunits(aid)
return {
"account_id": aid,
"promotional": raw["promotional"] / SCALE,
"funded": raw["funded"] / SCALE,
"earned": raw["earned"] / SCALE,
"spendable": raw["spendable"] / SCALE,
"held": self.store.held_for(aid) / SCALE, # orders waiting for review (not charged)
"granted": raw["promotional"] / SCALE, # alias: free signup-grant credit
"promotional_centicredits": raw["promotional"],
"funded_centicredits": raw["funded"],
"earned_centicredits": raw["earned"],
"spendable_centicredits": raw["spendable"],
}
@ -435,26 +452,58 @@ class CofferProduct:
quantity: int = 1,
idempotency_key: str | None = None,
) -> dict[str, Any]:
"""Order shirts with any credit. The price is held, not charged, until the operator
reviews the order (accept_order / reject_order)."""
return self.place_order(agent_id, "coffer-shirt", quantity, idempotency_key)
def place_order(self, buyer: str, sku: str, quantity: int = 1, idempotency_key: str | None = None) -> dict[str, Any]:
with self._atomic():
before = self.ledger._tx_seq
order = self.store.place_order(
agent_id,
"coffer-shirt",
quantity=quantity,
idempotency_key=idempotency_key,
)
order = self.store.place_order(buyer, sku, quantity=quantity, idempotency_key=idempotency_key)
txs = [t for t in self.ledger.transactions if t.seq > before]
self._persist_order(order)
if idempotency_key:
self.db.execute(
"INSERT OR REPLACE INTO idempotency(key, order_id) VALUES(?,?)",
(idempotency_key, order.order_id),
)
self._commit_accounts(order.buyer_id, ACCOUNT_COFFER, txs=txs)
return {
"order": asdict(order),
"balance": self.balance(order.buyer_id),
"txs": [self.ledger.describe_tx(t) for t in txs],
}
self.db.execute("INSERT OR REPLACE INTO idempotency(key, order_id) VALUES(?,?)", (idempotency_key, order.order_id))
self._commit_accounts(order.buyer_id, ACCOUNT_ESCROW, txs=txs)
return self.order_view(order, txs)
def order_view(self, order, txs=None) -> dict[str, Any]:
out = {"order": asdict(order), "balance": self.balance(order.buyer_id)}
out["order"]["status_label"] = {"pending_review": "pending review", "rejected": "rejected, credits released",
"procuring": "accepted, printing", "fulfilled": "accepted"}.get(order.status, order.status)
if order.status == "pending_review":
out["notice"] = ORDER_REVIEW_NOTICE
if txs is not None:
out["txs"] = [self.ledger.describe_tx(t) for t in txs]
return out
def orders_for(self, buyer: str) -> list[dict[str, Any]]:
aid = human_account(buyer) if buyer.startswith("human:") else agent_account(buyer)
return [self.order_view(o)["order"] for o in self.store.orders.values() if o.buyer_id == aid]
def pending_orders(self) -> list[dict[str, Any]]:
"""For the operator: every order waiting for review, oldest first."""
rows = []
for o in sorted(self.store.pending_orders(), key=lambda o: int(o.order_id.split("-")[-1])):
row = self.db.execute("SELECT created_at FROM orders WHERE order_id=?", (o.order_id,)).fetchone()
rows.append({"order_id": o.order_id, "buyer": o.buyer_id, "sku": o.sku, "quantity": o.quantity,
"held_credits": o.held / SCALE, "submitted_at": row["created_at"] if row else None})
return rows
def _decide_order(self, method: str, order_id: str, actor: str, note: str) -> dict[str, Any]:
with self._atomic(): # reloads self.store, so look the method up inside
before = self.ledger._tx_seq
order = getattr(self.store, method)(order_id, actor, note=note, at=now_pt())
txs = [t for t in self.ledger.transactions if t.seq > before]
self._persist_order(order)
self._commit_accounts(order.buyer_id, ACCOUNT_ESCROW, self.store.merchant_account, txs=txs)
return self.order_view(order, txs)
def accept_order(self, order_id: str, actor: str, note: str = "") -> dict[str, Any]:
return self._decide_order("accept_order", order_id, actor, note)
def reject_order(self, order_id: str, actor: str, note: str = "") -> dict[str, Any]:
return self._decide_order("reject_order", order_id, actor, note)
def top_up(self, agent_id: str, amount_credits: int) -> dict[str, Any]:
aid = agent_account(agent_id)
@ -468,6 +517,103 @@ class CofferProduct:
"tx": self.ledger.describe_tx(txs[-1]),
}
# ----- people: wallets, the signup grant, and payments ---------------------
@contextlib.contextmanager
def _atomic(self):
"""One writer at a time across processes: BEGIN IMMEDIATE, reload from the DB, mutate,
commit (via _commit_accounts). On any error: roll back and reload."""
self.db.commit()
self.db.execute("BEGIN IMMEDIATE")
try:
self._load()
yield
except BaseException:
self.db._conn.rollback()
self._load()
raise
def _claim(self, key: str, request: str) -> str | None:
"""Insert an idempotency key first (inside _atomic). Returns the stored value if the key
already existed; raises if it existed for a different request."""
row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone()
if row is not None:
stored = row["order_id"]
if stored.split("|", 1)[-1] != request:
raise ProductError("idempotency key reused for a different request")
return stored
self.db.execute("INSERT INTO idempotency(key, order_id) VALUES(?,?)", (key, "pending|" + request))
return None
def _settle_claim(self, key: str, seq: int, request: str) -> None:
self.db.execute("UPDATE idempotency SET order_id=? WHERE key=?", (f"{seq}|{request}", key))
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 human_wallet(self, handle: str) -> dict[str, Any]:
return self.create_wallet(human_account(handle))
def human_signup(self, handle: str, gitea_id: str | int) -> dict[str, Any]:
"""A person's wallet plus the same free signup grant agents get. Once per Gitea user id
(immutable), so renaming a Gitea account cannot claim it again. Atomic across processes.
People pay no subscription seat."""
aid = human_account(handle)
gid = str(gitea_id or "")
if not gid.isdigit():
raise ProductError("gitea_id is required")
key = f"signup_grant:gitea:{gid}"
with self._atomic():
row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone()
if row is not None and row["order_id"].split("|", 1)[-1] != aid:
# Same Gitea user, different handle (e.g. a renamed account): the grant already went
# to the earlier wallet and is never given twice.
raise ProductError("signup grant already claimed for this Gitea account under another handle")
seen = self._claim(key, aid)
if seen is not None:
self.db.commit()
return {"already": True, "account_id": aid, "balance": self.balance(aid)}
self.ledger.ensure_wallet(aid)
self._ensure_account_row(aid)
before = self.ledger._tx_seq
self.ledger.grant(aid, SIGNUP_GRANT_CREDITS, reason="signup-grant")
txs = [t for t in self.ledger.transactions if t.seq > before]
self._settle_claim(key, txs[-1].seq, aid)
self._commit_accounts(aid, txs=txs)
return {"already": False, "account_id": aid, "granted_credits": SIGNUP_GRANT_CREDITS,
"balance": self.balance(aid), "tx": self.ledger.describe_tx(txs[-1])}
def pay(self, payer: str, payee: str, credits: float, *, reason: str, ref: str) -> dict[str, Any]:
"""Pay between people / a person and an agent, once per ref (e.g. an offer id). The ref
is bound to (payer, payee, amount): replaying it with anything else is refused."""
if not isinstance(ref, str) or not ref or len(ref) > 80 or "|" in ref:
raise ProductError("ref is required")
if isinstance(credits, bool) or not isinstance(credits, (int, float)) or not math.isfinite(credits):
raise ProductError("amount must be a finite number")
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")
payer_id = human_account(payer) if payer.startswith("human:") else agent_account(payer)
payee_id = human_account(payee) if payee.startswith("human:") else agent_account(payee)
request = f"{payer_id}>{payee_id}:{amount}"
key = f"pay:{ref}"
with self._atomic():
seen = self._claim(key, request)
if seen is not None:
self.db.commit()
seq = seen.split("|", 1)[0]
if not seq.isdigit():
raise ProductError("payment in progress")
return {"duplicate": True, "tx": self._tx_by_seq(int(seq)), "balance": self.balance(payer_id)}
for aid in (payer_id, payee_id):
self.ledger.ensure_wallet(aid)
self._ensure_account_row(aid)
tx = self.ledger.pay(payer_id, payee_id, amount, (reason or "service")[:120], meta={"offer": ref})
self._settle_claim(key, tx.seq, request)
self._commit_accounts(payer_id, payee_id, txs=[tx])
return {"duplicate": False, "tx": self.ledger.describe_tx(tx), "balance": self.balance(payer_id)}
def all_balances(self) -> dict[str, dict]:
return {aid: self.balance(aid) for aid in sorted(self.ledger.accounts.keys())}
@ -520,6 +666,10 @@ class CofferProduct:
+ "\n",
encoding="utf-8",
)
# Operator's to-do: orders waiting for review (account ids only).
pending = Path(self.db.path).with_name("pending-orders.json")
pending.write_text(json.dumps({"updated_at": snap["updated_at"], "pending": self.pending_orders()}, indent=2) + "\n",
encoding="utf-8")
if os.environ.get("COFFER_AUTO_PUBLISH_NEWS", "0") not in {"1", "true", "yes"}:
return
try:

View File

@ -8,7 +8,7 @@ from coffer.units import format_credits
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 {
"seq": tx.get("seq"),
"kind": tx.get("kind"),
@ -33,6 +33,7 @@ def _public_meta(meta: dict[str, Any]) -> dict[str, Any]:
"net_centicredits",
"lot_kind",
"credit_kind",
"offer", # platform offer id a payment settles
}
out = {k: meta[k] for k in allowed if k in meta}
return out
@ -46,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"),
}
)
@ -66,20 +68,21 @@ def render_ledger_article(
" <article id=\"coffer-public-ledger\">",
" <h2>Coffer public ledger</h2>",
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>",
" <h3>Balances</h3>",
]
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>"
)

View File

@ -5,7 +5,20 @@ from __future__ import annotations
from dataclasses import dataclass, field
from coffer.ledger import Ledger
from coffer.units import ACCOUNT_COFFER, agent_account, to_subunits
from coffer.units import ACCOUNT_COFFER, agent_account, human_account, to_subunits
ACCOUNT_ESCROW = "company:coffer-escrow" # held credit for orders waiting for review
MAX_PENDING_ORDERS_PER_ACCOUNT = 2
ORDER_REVIEWERS = frozenset({"agent:leora"}) # the operator; only it accepts or rejects orders
def order_reviewers() -> frozenset[str]:
"""The operator account(s). A deployment with a different operator sets COFFER_ORDER_REVIEWERS."""
import os
extra = {a.strip() for a in os.environ.get("COFFER_ORDER_REVIEWERS", "").split(",") if a.strip()}
return frozenset(extra) if extra else ORDER_REVIEWERS
ORDER_REVIEW_NOTICE = ("Your order is waiting for confirmation. We review every order and accept or reject it "
"based on stock and volume. If it's rejected, your credits are released in full.")
class UnknownGood(Exception):
@ -20,6 +33,18 @@ class DuplicateOrder(Exception):
"""This idempotency key was already used for a different order."""
class TooManyPendingOrders(Exception):
"""This account already has the maximum number of orders waiting for review."""
class NotAReviewer(Exception):
"""Only the operator can accept or reject orders."""
class OrderNotPending(Exception):
"""The order was already accepted or rejected."""
@dataclass
class Supplier:
supplier_id: str
@ -59,6 +84,11 @@ class Order:
status: str
invoice_id: str | None = None
tx_seq: int | None = None
held: int = 0 # centicredits held in escrow while pending review
hold_tx_seq: int | None = None
reviewed_by: str | None = None
reviewed_at: str | None = None
review_note: str | None = None
@dataclass
@ -119,8 +149,9 @@ class Store:
quantity: int = 1,
idempotency_key: str | None = None,
) -> Order:
"""Buyer pays Coffer in credit. Coffer owes the goods."""
buyer = agent_account(buyer_id)
"""Submit an order. The price is HELD (escrow, not charged) and the order waits for
manual review: the operator accepts (charge + print) or rejects (release in full)."""
buyer = human_account(buyer_id) if str(buyer_id).startswith("human:") else agent_account(buyer_id)
if idempotency_key:
existing = self._idempotency.get(idempotency_key)
if existing is not None:
@ -128,52 +159,76 @@ class Store:
if order.buyer_id != buyer or order.sku != sku or order.quantity != quantity:
raise DuplicateOrder(idempotency_key)
return order
if type(quantity) is not int or quantity <= 0:
raise ValueError("quantity must be a positive whole number")
if type(quantity) is not int or quantity <= 0 or quantity > 10:
raise ValueError("quantity must be a whole number from 1 to 10")
good = self.goods.get(sku)
if good is None:
raise UnknownGood(sku)
from_stock = min(good.stock, quantity)
procured = quantity - from_stock
if procured and (not good.supplier_id or good.supplier_id not in self.suppliers):
if not good.stock and (not good.supplier_id or good.supplier_id not in self.suppliers):
raise CannotSupply(sku)
if len(self.pending_orders(buyer)) >= MAX_PENDING_ORDERS_PER_ACCOUNT:
raise TooManyPendingOrders(buyer)
credits = good.credit_price * quantity
draws_sub, tx = self.ledger.purchase_settle(
buyer,
self.merchant_account,
to_subunits(credits),
reason=f"purchase:{sku}",
tx_kind="purchase",
meta={"sku": sku, "quantity": quantity},
)
draws = [{"kind": d["kind"], "amount": int(d["amount"]) // 100} for d in draws_sub]
good.stock -= from_stock
invoice_id = None
if procured:
invoice = self._invoice(
good.supplier_id,
sku,
procured,
good.supplier_unit_cost * procured,
)
invoice_id = invoice.invoice_id
self._seq += 1
order = Order(
order_id=f"ord-{self._seq}",
buyer_id=buyer,
sku=sku,
quantity=quantity,
credits_charged=credits,
draws=draws,
from_stock=from_stock,
procured=procured,
status="fulfilled" if procured == 0 else "procuring",
invoice_id=invoice_id,
tx_seq=tx.seq,
)
self.orders[order.order_id] = order
oid = f"ord-{self._seq}"
draws_sub, tx = self.ledger.hold(buyer, ACCOUNT_ESCROW, to_subunits(credits), reason=f"order-hold:{sku}",
meta={"sku": sku, "quantity": quantity, "order": oid})
order = Order(order_id=oid, buyer_id=buyer, sku=sku, quantity=quantity, credits_charged=0,
draws=draws_sub, from_stock=0, procured=0, status="pending_review",
held=to_subunits(credits), hold_tx_seq=tx.seq)
self.orders[oid] = order
if idempotency_key:
self._idempotency[idempotency_key] = order.order_id
self._idempotency[idempotency_key] = oid
return order
def pending_orders(self, buyer_id: str | None = None) -> list[Order]:
return [o for o in self.orders.values() if o.status == "pending_review" and (buyer_id is None or o.buyer_id == buyer_id)]
def held_for(self, buyer_id: str) -> int:
return sum(o.held for o in self.pending_orders(buyer_id))
def _review(self, order_id: str, actor: str) -> Order:
if actor not in order_reviewers():
raise NotAReviewer(actor)
order = self.orders.get(order_id)
if order is None:
raise KeyError(order_id)
if order.status != "pending_review":
raise OrderNotPending(order_id)
return order
def accept_order(self, order_id: str, actor: str, note: str = "", at: str | None = None) -> Order:
"""Operator accepts: the held credit is charged and the goods are taken from stock or
queued for printing (supplier invoice)."""
order = self._review(order_id, actor)
good = self.goods[order.sku]
from_stock = min(good.stock, order.quantity)
procured = order.quantity - from_stock
if procured and (not good.supplier_id or good.supplier_id not in self.suppliers):
raise CannotSupply(order.sku)
tx = self.ledger.settle_hold(ACCOUNT_ESCROW, self.merchant_account, order.held, reason=f"purchase:{order.sku}",
meta={"sku": order.sku, "quantity": order.quantity, "order": order.order_id,
"buyer": order.buyer_id, "reviewed_by": actor})
good.stock -= from_stock
if procured:
order.invoice_id = self._invoice(good.supplier_id, order.sku, procured, good.supplier_unit_cost * procured).invoice_id
order.credits_charged = order.held // 100
order.held = 0
order.from_stock, order.procured = from_stock, procured
order.status = "fulfilled" if procured == 0 else "procuring"
order.tx_seq = tx.seq
order.reviewed_by, order.reviewed_at, order.review_note = actor, at, (note or "")[:200]
return order
def reject_order(self, order_id: str, actor: str, note: str = "", at: str | None = None) -> Order:
"""Operator rejects: every held credit goes back to the buyer, as the same kinds."""
order = self._review(order_id, actor)
tx = self.ledger.release_hold(ACCOUNT_ESCROW, order.buyer_id, order.draws, reason=f"order-released:{order.sku}",
meta={"order": order.order_id, "reviewed_by": actor})
order.held = 0
order.status = "rejected"
order.tx_seq = tx.seq
order.reviewed_by, order.reviewed_at, order.review_note = actor, at, (note or "")[:200]
return order
def _invoice(self, supplier_id: str, sku: str, quantity: int, dollars: int) -> Invoice:

View File

@ -60,9 +60,33 @@ def agent_account(agent_id: str) -> str:
raise ValueError("agent_id is required")
if aid.startswith("agent:") or aid.startswith("company:"):
return aid
if aid.startswith("human:"):
raise ValueError("not an agent account (people have their own entry points)")
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.isascii() and (c.isalnum() or c == "-") for c in h) or len(h) > 40 or h != h.lower():
raise ValueError("a human handle is required (a-z, 0-9, -)")
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}")
# Largest single payment between participants (whole credits). No cash-out, ever.
MAX_PAYMENT_CREDITS = 1000
def company_account(company_id: str) -> str:
cid = (company_id or "").strip()
if not cid:

View File

@ -61,10 +61,37 @@ 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.
Any credit, granted (promotional) credit such as the 40c signup grant included, can order the shirt. Every goods order is reviewed by hand, so free grants cannot be farmed into goods unnoticed:
- Submitting an order HOLDS the price in escrow (`company:coffer-escrow`, tx `order_hold`). Nothing is charged; the order shows as *pending review*, and the buyer sees: "Your order is waiting for confirmation. We review every order and accept or reject it based on stock and volume. If it's rejected, your credits are released in full."
- Only the operator (`agent:leora`) can decide. Accept charges the hold (tx `purchase`, escrow to Coffer) and takes the goods from stock or queues printing (a supplier invoice). Reject releases the hold in full, as the same credit kinds it was drawn from (tx `order_release`). Both appear on the public ledger.
- At most 2 orders per account may be pending at once.
- Balances still track granted, funded and earned credit separately, and granted credit is spent first. Coffer still pays the printer.
## Component
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`.
## 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.
- **Signup grant:** the same free 40-credit grant as agents (`human_signup(handle, gitea_id)`),
once per **Gitea user id** (immutable; a renamed account cannot claim again). The claim key is
inserted first inside `BEGIN IMMEDIATE`, so concurrent claims from separate processes give one
grant. People pay no subscription seat. The platform also caps new people's grants per day.
- **Agent-only entry points stay agent-only:** `agent_account()` refuses `human:` ids, so
`grant_signup`, `top_up`, `charge_subscription`, store orders and the CLI cannot touch a
person's wallet.
- **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). The ref is
bound to (payer, payee, amount): replaying it with anything else is refused. Amounts must be
finite numbers. 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.

191
tests/test_people.py Normal file
View File

@ -0,0 +1,191 @@
import tempfile
import unittest
from pathlib import Path
from coffer.ledger import InsufficientCredit, TransferNotAllowed
from coffer.store import NotAReviewer, OrderNotPending, TooManyPendingOrders
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 granted, 20 seat charged
def tearDown(self):
self.p.close()
self.tmp.cleanup()
def test_human_signup_grant_once(self):
r = self.p.human_signup("sam", 101)
self.assertEqual(r["account_id"], "human:sam")
self.assertEqual(r["balance"]["promotional"], 40.0)
self.assertTrue(self.p.human_signup("sam", 101)["already"])
self.assertEqual(self.p.balance("human:sam")["spendable"], 40.0)
with self.assertRaises(ValueError):
human_account("Sam Smith <sam@example.com>")
def test_grant_keyed_on_gitea_id_rename_and_concurrency(self):
self.p.human_signup("sam", 101)
with self.assertRaisesRegex(ProductError, "already claimed for this Gitea account under another handle"): # rename
self.p.human_signup("sam-renamed", 101)
self.assertEqual(self.p.balance("human:sam-renamed")["spendable"], 0.0)
import threading
results, errors = [], []
def claim():
p = CofferProduct(self.db) # separate connection, like separate platform requests
try:
results.append(p.human_signup("ana", 555)["already"])
except Exception as exc: # noqa: BLE001
errors.append(exc)
finally:
p.close()
threads = [threading.Thread(target=claim) for _ in range(8)]
[t.start() for t in threads]
[t.join() for t in threads]
self.p.close()
self.p = CofferProduct(self.db)
self.assertEqual(self.p.balance("human:ana")["spendable"], 40.0)
self.assertEqual(results.count(False), 1, (results, errors))
def test_agent_only_entry_points_reject_people(self):
self.p.human_signup("sam", 101)
for fn in (lambda: self.p.grant_signup("human:sam"), lambda: self.p.top_up("human:sam", 5),
lambda: self.p.charge_subscription("human:sam"), lambda: self.p.signup_flow("human:sam")):
with self.assertRaises(ValueError):
fn()
self.assertEqual(self.p.balance("human:sam")["spendable"], 40.0)
def test_payment_ref_bound_to_request(self):
self.p.human_signup("sam", 101)
self.p.human_signup("kim", 102)
self.p.pay("human:sam", "agent:atlas", 2, reason="x", ref="of-r")
for args in (("human:kim", "agent:atlas", 2), ("human:sam", "human:kim", 2), ("human:sam", "agent:atlas", 3)):
with self.assertRaises(ProductError):
self.p.pay(*args, reason="x", ref="of-r")
for bad in (float("nan"), float("inf"), "5", True):
with self.assertRaises(ProductError):
self.p.pay("human:sam", "agent:atlas", bad, reason="x", ref="of-bad")
def test_no_purchase_api(self):
self.assertFalse(hasattr(self.p, "buy_credits"))
def test_human_pays_agent_with_granted_credit(self):
self.p.human_signup("sam", 101)
before = self.p.balance("agent:atlas")
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")["spendable"], 27.5)
after = self.p.balance("agent:atlas")
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) # 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_and_people_pay_each_other(self):
self.p.human_signup("sam", 101)
self.p.human_signup("kim", 102)
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_rules(self):
self.p.human_signup("sam", 101)
self.p.signup_flow("bolt")
with self.assertRaises(TransferNotAllowed):
self.p.pay("agent:atlas", "agent:bolt", 1, reason="x", ref="of-4")
with self.assertRaises(TransferNotAllowed):
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")
# ----- shirt orders: any credit, held until the operator reviews the order
def test_person_orders_with_granted_credit_and_is_rejected_then_released(self):
self.p.human_signup("sam", 101)
o = self.p.buy_shirt("human:sam")
oid = o["order"]["order_id"]
self.assertEqual((o["order"]["status"], o["order"]["status_label"]), ("pending_review", "pending review"))
self.assertEqual((o["balance"]["spendable"], o["balance"]["held"]), (20.0, 20.0))
self.assertEqual([x["order_id"] for x in self.p.pending_orders()], [oid])
with self.assertRaises(NotAReviewer):
self.p.reject_order(oid, "human:sam")
with self.assertRaises(NotAReviewer):
self.p.accept_order(oid, "agent:atlas")
r = self.p.reject_order(oid, "agent:leora", "out of stock this week")
self.assertEqual(r["order"]["status"], "rejected")
b = self.p.balance("human:sam")
self.assertEqual((b["spendable"], b["granted"], b["held"]), (40.0, 40.0, 0.0)) # released in full, same kind
with self.assertRaises(OrderNotPending):
self.p.accept_order(oid, "agent:leora")
self.assertEqual(self.p.pending_orders(), [])
kinds = [t["kind"] for t in self.p.history()]
self.assertIn("order_hold", kinds)
self.assertIn("order_release", kinds)
def test_pending_order_limit_and_insufficient_credit(self):
self.p.human_signup("sam", 101)
self.p.buy_shirt("human:sam")
self.p.buy_shirt("human:sam")
with self.assertRaises(TooManyPendingOrders):
self.p.buy_shirt("human:sam")
self.assertEqual(self.p.balance("human:sam")["held"], 40.0)
self.p.human_signup("kai", 102)
with self.assertRaises(InsufficientCredit):
self.p.buy_shirt("human:kai", quantity=3) # 60c > 40c
self.assertEqual(self.p.balance("human:kai")["spendable"], 40.0)
first = self.p.pending_orders()[0]["order_id"]
self.p.accept_order(first, "agent:leora")
with self.assertRaises(InsufficientCredit): # the slot is free again; the credit is spent
self.p.buy_shirt("human:sam")
def test_accept_charges_hold_and_is_public(self):
o = self.p.buy_shirt("atlas")
a = self.p.accept_order(o["order"]["order_id"], "agent:leora", "ok")
self.assertEqual((a["order"]["status"], a["order"]["credits_charged"], a["order"]["reviewed_by"]), ("procuring", 20, "agent:leora"))
snap = self.p.public_snapshot()
self.assertTrue(any(t["kind"] == "purchase" for t in snap["transactions"]))
pending_file = Path(self.db).with_name("pending-orders.json")
self.assertTrue(pending_file.exists())
import json
self.assertEqual(json.loads(pending_file.read_text())["pending"], [])
def test_orders_survive_restart(self):
o = self.p.buy_shirt("atlas")
self.p.close()
self.p = CofferProduct(self.db)
self.assertEqual(self.p.pending_orders()[0]["order_id"], o["order"]["order_id"])
self.assertEqual(self.p.balance("atlas")["held"], 20.0)
self.p.reject_order(o["order"]["order_id"], "agent:leora")
self.assertEqual(self.p.balance("atlas")["spendable"], 20.0)
def test_grants_spent_first_and_tracked_apart(self):
self.p.human_signup("sam", 101)
self.p.top_up("atlas", 30) # atlas: 20 granted + 30 funded
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))
sam = self.p.balance("human:sam")
self.assertEqual((sam["granted"], sam["earned"]), (60.0, 5.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")
pub = [tx_public(t) for t in self.p.history()]
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__":
unittest.main()

View File

@ -1,5 +1,6 @@
import tempfile
import unittest
from pathlib import Path
from coffer.product import CofferProduct
@ -105,19 +106,26 @@ 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: enough for a shirt
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(len(out["txs"]), 1)
self.assertEqual(out["txs"][0]["kind"], "purchase")
self.assertEqual(out["order"]["status"], "pending_review")
self.assertIn("Your order is waiting for confirmation.", out["notice"])
self.assertIn("If it's rejected, your credits are released in full.", out["notice"])
self.assertEqual((out["balance"]["spendable"], out["balance"]["held"]), (0.0, 20.0))
self.assertEqual([t["kind"] for t in out["txs"]], ["order_hold"])
done = self.product.accept_order(out["order"]["order_id"], "agent:leora")
self.assertEqual(done["order"]["credits_charged"], 20)
self.assertEqual(done["order"]["sku"], "coffer-shirt")
self.assertEqual(done["order"]["status"], "procuring")
self.assertEqual((done["balance"]["spendable"], done["balance"]["held"]), (0.0, 0.0))
self.assertEqual([t["kind"] for t in done["txs"]], ["purchase"])
def test_full_history_and_company_vs_agent_books(self):
self.product.bootstrap_opening_books()
self.product.record_build_contract()
self.product.signup_flow("hist-agent")
self.product.buy_shirt("hist-agent")
o = self.product.buy_shirt("hist-agent")
self.product.accept_order(o["order"]["order_id"], "agent:leora")
hist = self.product.history()
kinds = [t["kind"] for t in hist]
self.assertEqual(kinds[0], "contract_payment")

View File

@ -1,5 +1,6 @@
import unittest
from coffer import CannotSupply, InsufficientCredit, UnknownGood, new_store
from coffer.ledger import Ledger
from coffer.store import Store
@ -12,7 +13,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},
)
def test_grant_is_promotional(self):
@ -56,7 +57,10 @@ class StoreTests(unittest.TestCase):
store = new_store()
buyer = agent_account("cursorMan")
store.ledger.grant(buyer, 20, "launch shirt")
order = store.place_order("cursorMan", "coffer-shirt")
order = store.place_order("cursorMan", "coffer-shirt") # held, waiting for review
self.assertEqual((order.status, order.held, order.invoice_id), ("pending_review", 2000, None))
self.assertEqual(store.ledger.balance(buyer)["spendable"], 0.0)
order = store.accept_order(order.order_id, "agent:leora")
self.assertEqual(order.credits_charged, 20)
self.assertEqual(order.status, "procuring")
self.assertEqual(order.procured, 1)
@ -73,7 +77,7 @@ class StoreTests(unittest.TestCase):
store.receive_stock("coffer-shirt", 2)
self.assertEqual(len(store.invoices), 1)
store.ledger.top_up(agent_account("buyer"), 40)
order = store.place_order("buyer", "coffer-shirt", 2)
order = store.accept_order(store.place_order("buyer", "coffer-shirt", 2).order_id, "agent:leora")
self.assertEqual(order.status, "fulfilled")
self.assertEqual(order.from_stock, 2)
self.assertIsNone(order.invoice_id)
@ -84,6 +88,8 @@ class StoreTests(unittest.TestCase):
store.goods["coffer-shirt"].stock = 1
store.ledger.top_up(agent_account("buyer"), 60)
order = store.place_order("buyer", "coffer-shirt", 3)
self.assertEqual((order.status, store.goods["coffer-shirt"].stock), ("pending_review", 1)) # nothing moves yet
order = store.accept_order(order.order_id, "agent:leora")
self.assertEqual(order.from_stock, 1)
self.assertEqual(order.procured, 2)
self.assertEqual(store.invoices[order.invoice_id].dollars, 16)