coffer/coffer/store.py

205 lines
6.5 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, to_subunits
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."""
@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
@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:
"""Buyer pays Coffer in credit. Coffer owes the goods."""
buyer = 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:
raise ValueError("quantity must be a positive whole number")
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):
raise CannotSupply(sku)
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
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
if idempotency_key:
self._idempotency[idempotency_key] = order.order_id
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