coffer/coffer/store.py

259 lines
10 KiB
Python

"""Coffer sells its own goods for prepaid credit and pays suppliers by invoice."""
from __future__ import annotations
from dataclasses import dataclass, field
from coffer.ledger import Ledger
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):
"""No good with that sku is sold by Coffer."""
class CannotSupply(Exception):
"""Coffer has no stock and no supplier it can order this good from."""
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
name: str
@dataclass
class Good:
sku: str
name: str
credit_price: int
supplier_id: str | None = None
supplier_unit_cost: int = 0
stock: int = 0
@dataclass
class Invoice:
invoice_id: str
supplier_id: str
sku: str
quantity: int
dollars: int
status: str = "payable"
@dataclass
class Order:
order_id: str
buyer_id: str
sku: str
quantity: int
credits_charged: int
draws: list[dict[str, int | str]]
from_stock: int
procured: int
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
class Store:
ledger: Ledger = field(default_factory=Ledger)
goods: dict[str, Good] = field(default_factory=dict)
suppliers: dict[str, Supplier] = field(default_factory=dict)
orders: dict[str, Order] = field(default_factory=dict)
invoices: dict[str, Invoice] = field(default_factory=dict)
merchant_account: str = ACCOUNT_COFFER
_seq: int = 0
_idempotency: dict[str, str] = field(default_factory=dict)
def add_supplier(self, supplier_id: str, name: str) -> Supplier:
if not supplier_id or not name:
raise ValueError("a supplier needs an id and a name")
supplier = Supplier(supplier_id, name)
self.suppliers[supplier_id] = supplier
return supplier
def add_good(
self,
sku: str,
name: str,
credit_price: int,
supplier_id: str | None = None,
supplier_unit_cost: int = 0,
stock: int = 0,
) -> Good:
if type(credit_price) is not int or credit_price <= 0:
raise ValueError("credit price must be a positive whole number of credits")
if type(supplier_unit_cost) is not int or supplier_unit_cost < 0:
raise ValueError("supplier cost must be a whole number of dollars")
if type(stock) is not int or stock < 0:
raise ValueError("stock must be a whole number")
if supplier_id is not None and supplier_id not in self.suppliers:
raise ValueError(f"unknown supplier {supplier_id}")
good = Good(sku, name, credit_price, supplier_id, supplier_unit_cost, stock)
self.goods[sku] = good
return good
def receive_stock(self, sku: str, quantity: int) -> Invoice | None:
"""Take goods into Coffer's own inventory. Pay the supplier by invoice, if there is one."""
if type(quantity) is not int or quantity <= 0:
raise ValueError("quantity must be a positive whole number")
good = self.goods.get(sku)
if good is None:
raise UnknownGood(sku)
good.stock += quantity
if good.supplier_id and good.supplier_unit_cost:
return self._invoice(good.supplier_id, sku, quantity, good.supplier_unit_cost * quantity)
return None
def place_order(
self,
buyer_id: str,
sku: str,
quantity: int = 1,
idempotency_key: str | None = None,
) -> Order:
"""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:
order = self.orders[existing]
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 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)
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
self._seq += 1
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] = 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:
self._seq += 1
invoice = Invoice(
invoice_id=f"inv-{self._seq}",
supplier_id=supplier_id,
sku=sku,
quantity=quantity,
dollars=dollars,
)
self.invoices[invoice.invoice_id] = invoice
return invoice
def new_store(ledger: Ledger | None = None) -> Store:
"""The opening catalog: a Coffer shirt at 20 credits."""
store = Store(ledger=ledger or Ledger())
store.add_supplier("printer", "Printer")
store.add_good(
sku="coffer-shirt",
name="Coffer shirt",
credit_price=20,
supplier_id="printer",
supplier_unit_cost=8,
)
return store