259 lines
10 KiB
Python
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
|