forked from cursorman/coffer
Shirt orders: any credit may order, every order is held (escrow) for operator review; accept charges and queues printing, reject releases in full; both on the public ledger; 2 pending orders per account; operator list + pending-orders.json; drops the granted-vs-earned goods rule
This commit is contained in:
parent
339e37efc5
commit
910e7fbe07
@ -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>`).
|
- **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 (and people) get **40c** of granted (promotional) credit. It pays for services only and never buys physical goods. Goods need funded or earned 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**.
|
- **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).
|
||||||
|
|
||||||
@ -32,7 +33,9 @@ python3 -m coffer create-wallet alice
|
|||||||
python3 -m coffer grant-signup alice # 40c promo
|
python3 -m coffer grant-signup alice # 40c promo
|
||||||
python3 -m coffer charge-subscription alice # 20c via Coffer, 1% cut
|
python3 -m coffer charge-subscription alice # 20c via Coffer, 1% cut
|
||||||
python3 -m coffer signup alice # wallet + grant + subscription
|
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 balance alice
|
||||||
python3 -m coffer history
|
python3 -m coffer history
|
||||||
python3 -m coffer catalog
|
python3 -m coffer catalog
|
||||||
|
|||||||
@ -42,7 +42,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
sf = sub.add_parser("signup", help="Wallet + 40c grant + 20c subscription flow")
|
sf = sub.add_parser("signup", help="Wallet + 40c grant + 20c subscription flow")
|
||||||
sf.add_argument("agent_id")
|
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("agent_id")
|
||||||
sh.add_argument("--qty", type=int, default=1)
|
sh.add_argument("--qty", type=int, default=1)
|
||||||
sh.add_argument("--idempotency-key")
|
sh.add_argument("--idempotency-key")
|
||||||
@ -51,6 +51,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
t.add_argument("agent_id")
|
t.add_argument("agent_id")
|
||||||
t.add_argument("amount", type=int)
|
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("history", help="Print full ledger transaction history")
|
||||||
sub.add_parser("catalog", help="Show catalog goods")
|
sub.add_parser("catalog", help="Show catalog goods")
|
||||||
sub.add_parser("public-ledger", help="Print public ledger JSON (no human PII)")
|
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
|
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":
|
elif args.cmd == "top-up":
|
||||||
_print(product.top_up(args.agent_id, args.amount))
|
_print(product.top_up(args.agent_id, args.amount))
|
||||||
elif args.cmd == "history":
|
elif args.cmd == "history":
|
||||||
|
|||||||
@ -95,6 +95,11 @@ class CofferDB:
|
|||||||
self._conn.row_factory = sqlite3.Row
|
self._conn.row_factory = sqlite3.Row
|
||||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||||
self._conn.executescript(SCHEMA)
|
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()
|
self._conn.commit()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
|
|||||||
@ -16,25 +16,19 @@ from coffer.units import SCALE, format_credits, to_subunits
|
|||||||
|
|
||||||
class CreditKind(str, Enum):
|
class CreditKind(str, Enum):
|
||||||
FUNDED = "funded" # prepaid credit Coffer was paid for (top-ups, endowments)
|
FUNDED = "funded" # prepaid credit Coffer was paid for (top-ups, endowments)
|
||||||
PROMOTIONAL = "promotional" # granted for free (signup grants): services only, never goods
|
PROMOTIONAL = "promotional" # granted for free (signup grants)
|
||||||
EARNED = "earned" # funded/earned credit received as payment for a service
|
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
|
# Granted credit is always spent first. Every credit kind can pay for anything; physical goods
|
||||||
# bought with funded or earned credit, so free grants (or grants moved between accounts) can
|
# are protected by manual order review instead (see Store.place_order).
|
||||||
# never be turned into goods.
|
|
||||||
SERVICE_DRAW_ORDER = (CreditKind.PROMOTIONAL, CreditKind.FUNDED, CreditKind.EARNED)
|
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."""
|
||||||
|
|
||||||
@ -62,7 +56,6 @@ class Account:
|
|||||||
"funded": funded,
|
"funded": funded,
|
||||||
"earned": earned,
|
"earned": earned,
|
||||||
"spendable": promotional + funded + earned,
|
"spendable": promotional + funded + earned,
|
||||||
"goods_spendable": funded + earned,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def balance(self) -> dict[str, float]:
|
def balance(self) -> dict[str, float]:
|
||||||
@ -109,7 +102,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, "earned": 0, "spendable": 0, "goods_spendable": 0}
|
return {"promotional": 0, "funded": 0, "earned": 0, "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]:
|
||||||
@ -204,8 +197,6 @@ class Ledger:
|
|||||||
account = self.accounts.get(account_id)
|
account = self.accounts.get(account_id)
|
||||||
available = sum(lot.remaining for lot in (account.lots if account else []) if lot.kind in order)
|
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 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
|
||||||
@ -266,7 +257,7 @@ class Ledger:
|
|||||||
Granted (promotional) credit is used first, then funded, then earned. The payee receives
|
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
|
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
|
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."""
|
peer rule). Prepaid credit only: nothing here turns credit into cash."""
|
||||||
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")
|
||||||
@ -293,13 +284,10 @@ 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. For physical goods
|
"""Buyer pays merchant: debit buyer lots (granted first), credit merchant funded lot."""
|
||||||
(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, order=GOODS_DRAW_ORDER if goods else SERVICE_DRAW_ORDER)
|
draws = self._debit_lots(buyer_id, amount_subunits)
|
||||||
self.account(merchant_id).lots.append(
|
self.account(merchant_id).lots.append(
|
||||||
Lot(CreditKind.FUNDED, amount_subunits, reason)
|
Lot(CreditKind.FUNDED, amount_subunits, reason)
|
||||||
)
|
)
|
||||||
@ -313,6 +301,31 @@ class Ledger:
|
|||||||
)
|
)
|
||||||
return draws, tx
|
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(
|
def bootstrap_endowment(
|
||||||
self,
|
self,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from coffer.db import CofferDB
|
from coffer.db import CofferDB
|
||||||
from coffer.public_ledger import snapshot_from_product
|
from coffer.public_ledger import snapshot_from_product
|
||||||
from coffer.ledger import CreditKind, Ledger, Lot, Tx
|
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 (
|
from coffer.units import (
|
||||||
ACCOUNT_COFFER,
|
ACCOUNT_COFFER,
|
||||||
ACCOUNT_LEORA,
|
ACCOUNT_LEORA,
|
||||||
@ -143,6 +143,11 @@ class CofferProduct:
|
|||||||
status=row["status"],
|
status=row["status"],
|
||||||
invoice_id=row["invoice_id"],
|
invoice_id=row["invoice_id"],
|
||||||
tx_seq=row["tx_seq"],
|
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.store.orders[order.order_id] = order
|
||||||
self._bump_seq_from_id(order.order_id)
|
self._bump_seq_from_id(order.order_id)
|
||||||
@ -216,7 +221,8 @@ class CofferProduct:
|
|||||||
self.db.execute(
|
self.db.execute(
|
||||||
"INSERT OR REPLACE INTO orders(order_id, buyer_id, sku, quantity, "
|
"INSERT OR REPLACE INTO orders(order_id, buyer_id, sku, quantity, "
|
||||||
"credits_charged, draws_json, from_stock, procured, status, invoice_id, "
|
"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.order_id,
|
||||||
order.buyer_id,
|
order.buyer_id,
|
||||||
@ -229,7 +235,13 @@ class CofferProduct:
|
|||||||
order.status,
|
order.status,
|
||||||
order.invoice_id,
|
order.invoice_id,
|
||||||
order.tx_seq,
|
order.tx_seq,
|
||||||
|
order.order_id,
|
||||||
now_pt(),
|
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:
|
if order.invoice_id and order.invoice_id in self.store.invoices:
|
||||||
@ -279,8 +291,8 @@ class CofferProduct:
|
|||||||
"funded": raw["funded"] / SCALE,
|
"funded": raw["funded"] / SCALE,
|
||||||
"earned": raw["earned"] / SCALE,
|
"earned": raw["earned"] / SCALE,
|
||||||
"spendable": raw["spendable"] / SCALE,
|
"spendable": raw["spendable"] / SCALE,
|
||||||
"goods_spendable": raw["goods_spendable"] / SCALE,
|
"held": self.store.held_for(aid) / SCALE, # orders waiting for review (not charged)
|
||||||
"granted": raw["promotional"] / SCALE, # alias: free grant credit (services only)
|
"granted": raw["promotional"] / SCALE, # alias: free signup-grant credit
|
||||||
"promotional_centicredits": raw["promotional"],
|
"promotional_centicredits": raw["promotional"],
|
||||||
"funded_centicredits": raw["funded"],
|
"funded_centicredits": raw["funded"],
|
||||||
"earned_centicredits": raw["earned"],
|
"earned_centicredits": raw["earned"],
|
||||||
@ -440,26 +452,58 @@ class CofferProduct:
|
|||||||
quantity: int = 1,
|
quantity: int = 1,
|
||||||
idempotency_key: str | None = None,
|
idempotency_key: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
before = self.ledger._tx_seq
|
"""Order shirts with any credit. The price is held, not charged, until the operator
|
||||||
order = self.store.place_order(
|
reviews the order (accept_order / reject_order)."""
|
||||||
agent_id,
|
return self.place_order(agent_id, "coffer-shirt", quantity, idempotency_key)
|
||||||
"coffer-shirt",
|
|
||||||
quantity=quantity,
|
def place_order(self, buyer: str, sku: str, quantity: int = 1, idempotency_key: str | None = None) -> dict[str, Any]:
|
||||||
idempotency_key=idempotency_key,
|
with self._atomic():
|
||||||
)
|
before = self.ledger._tx_seq
|
||||||
txs = [t for t in self.ledger.transactions if t.seq > before]
|
order = self.store.place_order(buyer, sku, quantity=quantity, idempotency_key=idempotency_key)
|
||||||
self._persist_order(order)
|
txs = [t for t in self.ledger.transactions if t.seq > before]
|
||||||
if idempotency_key:
|
self._persist_order(order)
|
||||||
self.db.execute(
|
if idempotency_key:
|
||||||
"INSERT OR REPLACE INTO idempotency(key, order_id) VALUES(?,?)",
|
self.db.execute("INSERT OR REPLACE INTO idempotency(key, order_id) VALUES(?,?)", (idempotency_key, order.order_id))
|
||||||
(idempotency_key, order.order_id),
|
self._commit_accounts(order.buyer_id, ACCOUNT_ESCROW, txs=txs)
|
||||||
)
|
return self.order_view(order, txs)
|
||||||
self._commit_accounts(order.buyer_id, ACCOUNT_COFFER, txs=txs)
|
|
||||||
return {
|
def order_view(self, order, txs=None) -> dict[str, Any]:
|
||||||
"order": asdict(order),
|
out = {"order": asdict(order), "balance": self.balance(order.buyer_id)}
|
||||||
"balance": self.balance(order.buyer_id),
|
out["order"]["status_label"] = {"pending_review": "pending review", "rejected": "rejected, credits released",
|
||||||
"txs": [self.ledger.describe_tx(t) for t in txs],
|
"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]:
|
def top_up(self, agent_id: str, amount_credits: int) -> dict[str, Any]:
|
||||||
aid = agent_account(agent_id)
|
aid = agent_account(agent_id)
|
||||||
@ -617,6 +661,10 @@ class CofferProduct:
|
|||||||
+ "\n",
|
+ "\n",
|
||||||
encoding="utf-8",
|
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"}:
|
if os.environ.get("COFFER_AUTO_PUBLISH_NEWS", "0") not in {"1", "true", "yes"}:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
133
coffer/store.py
133
coffer/store.py
@ -5,7 +5,13 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from coffer.ledger import Ledger
|
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
|
||||||
|
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):
|
class UnknownGood(Exception):
|
||||||
@ -20,6 +26,18 @@ class DuplicateOrder(Exception):
|
|||||||
"""This idempotency key was already used for a different order."""
|
"""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
|
@dataclass
|
||||||
class Supplier:
|
class Supplier:
|
||||||
supplier_id: str
|
supplier_id: str
|
||||||
@ -59,6 +77,11 @@ class Order:
|
|||||||
status: str
|
status: str
|
||||||
invoice_id: str | None = None
|
invoice_id: str | None = None
|
||||||
tx_seq: int | 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
|
@dataclass
|
||||||
@ -119,8 +142,9 @@ class Store:
|
|||||||
quantity: int = 1,
|
quantity: int = 1,
|
||||||
idempotency_key: str | None = None,
|
idempotency_key: str | None = None,
|
||||||
) -> Order:
|
) -> Order:
|
||||||
"""Buyer pays Coffer in credit. Coffer owes the goods."""
|
"""Submit an order. The price is HELD (escrow, not charged) and the order waits for
|
||||||
buyer = agent_account(buyer_id)
|
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:
|
if idempotency_key:
|
||||||
existing = self._idempotency.get(idempotency_key)
|
existing = self._idempotency.get(idempotency_key)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
@ -128,53 +152,76 @@ class Store:
|
|||||||
if order.buyer_id != buyer or order.sku != sku or order.quantity != quantity:
|
if order.buyer_id != buyer or order.sku != sku or order.quantity != quantity:
|
||||||
raise DuplicateOrder(idempotency_key)
|
raise DuplicateOrder(idempotency_key)
|
||||||
return order
|
return order
|
||||||
if type(quantity) is not int or quantity <= 0:
|
if type(quantity) is not int or quantity <= 0 or quantity > 10:
|
||||||
raise ValueError("quantity must be a positive whole number")
|
raise ValueError("quantity must be a whole number from 1 to 10")
|
||||||
good = self.goods.get(sku)
|
good = self.goods.get(sku)
|
||||||
if good is None:
|
if good is None:
|
||||||
raise UnknownGood(sku)
|
raise UnknownGood(sku)
|
||||||
from_stock = min(good.stock, quantity)
|
if not good.stock and (not good.supplier_id or good.supplier_id not in self.suppliers):
|
||||||
procured = quantity - from_stock
|
|
||||||
if procured and (not good.supplier_id or good.supplier_id not in self.suppliers):
|
|
||||||
raise CannotSupply(sku)
|
raise CannotSupply(sku)
|
||||||
|
if len(self.pending_orders(buyer)) >= MAX_PENDING_ORDERS_PER_ACCOUNT:
|
||||||
|
raise TooManyPendingOrders(buyer)
|
||||||
credits = good.credit_price * quantity
|
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},
|
|
||||||
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
|
|
||||||
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
|
self._seq += 1
|
||||||
order = Order(
|
oid = f"ord-{self._seq}"
|
||||||
order_id=f"ord-{self._seq}",
|
draws_sub, tx = self.ledger.hold(buyer, ACCOUNT_ESCROW, to_subunits(credits), reason=f"order-hold:{sku}",
|
||||||
buyer_id=buyer,
|
meta={"sku": sku, "quantity": quantity, "order": oid})
|
||||||
sku=sku,
|
order = Order(order_id=oid, buyer_id=buyer, sku=sku, quantity=quantity, credits_charged=0,
|
||||||
quantity=quantity,
|
draws=draws_sub, from_stock=0, procured=0, status="pending_review",
|
||||||
credits_charged=credits,
|
held=to_subunits(credits), hold_tx_seq=tx.seq)
|
||||||
draws=draws,
|
self.orders[oid] = order
|
||||||
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
|
|
||||||
if idempotency_key:
|
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
|
return order
|
||||||
|
|
||||||
def _invoice(self, supplier_id: str, sku: str, quantity: int, dollars: int) -> Invoice:
|
def _invoice(self, supplier_id: str, sku: str, quantity: int, dollars: int) -> Invoice:
|
||||||
|
|||||||
@ -61,7 +61,12 @@ 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.
|
||||||
|
|
||||||
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.
|
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
|
## Component
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from coffer.ledger import GrantedCreditNotForGoods, InsufficientCredit, TransferNotAllowed
|
from coffer.ledger import InsufficientCredit, TransferNotAllowed
|
||||||
|
from coffer.store import NotAReviewer, OrderNotPending, TooManyPendingOrders
|
||||||
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,33 +111,73 @@ 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
|
# ----- shirt orders: any credit, held until the operator reviews the order
|
||||||
def test_grants_cannot_become_goods(self):
|
def test_person_orders_with_granted_credit_and_is_rejected_then_released(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.human_signup("sam", 101)
|
||||||
self.p.top_up("atlas", 30) # atlas: 20 granted + 30 funded
|
o = self.p.buy_shirt("human:sam")
|
||||||
r = self.p.pay("atlas", "human:sam", 25, reason="refund for a service", ref="atlas-sam-1")
|
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")
|
a = self.p.balance("atlas")
|
||||||
self.assertEqual((a["granted"], a["funded"]), (0.0, 25.0)) # granted spent first
|
self.assertEqual((a["granted"], a["funded"]), (0.0, 25.0))
|
||||||
sam = self.p.balance("human:sam")
|
sam = self.p.balance("human:sam")
|
||||||
self.assertEqual((sam["granted"], sam["earned"]), (60.0, 5.0)) # 20 granted + 5 funded arrived as earned
|
self.assertEqual((sam["granted"], sam["earned"]), (60.0, 5.0))
|
||||||
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)
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@ -107,24 +106,26 @@ 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 of granted credit
|
self.product.signup_flow("shopper") # left with 20c of granted credit: enough for a shirt
|
||||||
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"]["status"], "pending_review")
|
||||||
self.assertEqual(out["order"]["sku"], "coffer-shirt")
|
self.assertIn("Your order is waiting for confirmation.", out["notice"])
|
||||||
self.assertEqual((out["balance"]["spendable"], out["balance"]["granted"], out["balance"]["funded"]), (20.0, 20.0, 0.0))
|
self.assertIn("If it's rejected, your credits are released in full.", out["notice"])
|
||||||
self.assertEqual(len(out["txs"]), 1)
|
self.assertEqual((out["balance"]["spendable"], out["balance"]["held"]), (0.0, 20.0))
|
||||||
self.assertEqual(out["txs"][0]["kind"], "purchase")
|
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):
|
def test_full_history_and_company_vs_agent_books(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("hist-agent")
|
self.product.signup_flow("hist-agent")
|
||||||
self.product.top_up("hist-agent", 20)
|
o = self.product.buy_shirt("hist-agent")
|
||||||
self.product.buy_shirt("hist-agent")
|
self.product.accept_order(o["order"]["order_id"], "agent:leora")
|
||||||
hist = self.product.history()
|
hist = self.product.history()
|
||||||
kinds = [t["kind"] for t in hist]
|
kinds = [t["kind"] for t in hist]
|
||||||
self.assertEqual(kinds[0], "contract_payment")
|
self.assertEqual(kinds[0], "contract_payment")
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
@ -14,7 +13,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, "earned": 0.0, "spendable": 20.0, "goods_spendable": 20.0},
|
{"promotional": 0.0, "funded": 20.0, "earned": 0.0, "spendable": 20.0},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_grant_is_promotional(self):
|
def test_grant_is_promotional(self):
|
||||||
@ -58,10 +57,10 @@ 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):
|
order = store.place_order("cursorMan", "coffer-shirt") # held, waiting for review
|
||||||
store.place_order("cursorMan", "coffer-shirt") # granted credit: services only
|
self.assertEqual((order.status, order.held, order.invoice_id), ("pending_review", 2000, None))
|
||||||
store.ledger.top_up(buyer, 20)
|
self.assertEqual(store.ledger.balance(buyer)["spendable"], 0.0)
|
||||||
order = store.place_order("cursorMan", "coffer-shirt")
|
order = store.accept_order(order.order_id, "agent:leora")
|
||||||
self.assertEqual(order.credits_charged, 20)
|
self.assertEqual(order.credits_charged, 20)
|
||||||
self.assertEqual(order.status, "procuring")
|
self.assertEqual(order.status, "procuring")
|
||||||
self.assertEqual(order.procured, 1)
|
self.assertEqual(order.procured, 1)
|
||||||
@ -69,8 +68,7 @@ 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"], 20.0) # the grant is untouched
|
self.assertEqual(store.ledger.balance(buyer)["spendable"], 0.0)
|
||||||
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)
|
||||||
|
|
||||||
@ -79,7 +77,7 @@ class StoreTests(unittest.TestCase):
|
|||||||
store.receive_stock("coffer-shirt", 2)
|
store.receive_stock("coffer-shirt", 2)
|
||||||
self.assertEqual(len(store.invoices), 1)
|
self.assertEqual(len(store.invoices), 1)
|
||||||
store.ledger.top_up(agent_account("buyer"), 40)
|
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.status, "fulfilled")
|
||||||
self.assertEqual(order.from_stock, 2)
|
self.assertEqual(order.from_stock, 2)
|
||||||
self.assertIsNone(order.invoice_id)
|
self.assertIsNone(order.invoice_id)
|
||||||
@ -90,6 +88,8 @@ class StoreTests(unittest.TestCase):
|
|||||||
store.goods["coffer-shirt"].stock = 1
|
store.goods["coffer-shirt"].stock = 1
|
||||||
store.ledger.top_up(agent_account("buyer"), 60)
|
store.ledger.top_up(agent_account("buyer"), 60)
|
||||||
order = store.place_order("buyer", "coffer-shirt", 3)
|
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.from_stock, 1)
|
||||||
self.assertEqual(order.procured, 2)
|
self.assertEqual(order.procured, 2)
|
||||||
self.assertEqual(store.invoices[order.invoice_id].dollars, 16)
|
self.assertEqual(store.invoices[order.invoice_id].dollars, 16)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user