forked from cursorman/coffer
676 lines
28 KiB
Python
676 lines
28 KiB
Python
"""Durable Coffer product: wallets, ledger txs, signup, subscription, catalog."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import math
|
|
|
|
from dataclasses import asdict
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
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 ACCOUNT_ESCROW, ORDER_REVIEW_NOTICE, Good, Invoice, Order, new_store
|
|
from coffer.units import (
|
|
ACCOUNT_COFFER,
|
|
ACCOUNT_LEORA,
|
|
ACCOUNT_NEW_INTERNET,
|
|
COFFER_INVESTMENT_CREDITS,
|
|
CONTRACT_CREDITS,
|
|
LEORA_FUNDED_AFTER_GRANTS_CREDITS,
|
|
SCALE,
|
|
SHIRT_CREDITS,
|
|
SIGNUP_GRANT_CREDITS,
|
|
SUBSCRIPTION_CREDITS,
|
|
SUBSCRIPTION_CUT_BPS,
|
|
MAX_PAYMENT_CREDITS,
|
|
account_kind,
|
|
agent_account,
|
|
human_account,
|
|
cut_subunits,
|
|
format_credits,
|
|
to_subunits,
|
|
)
|
|
|
|
PT = ZoneInfo("Australia/Perth")
|
|
|
|
|
|
def now_pt() -> str:
|
|
return datetime.now(tz=PT).isoformat(timespec="seconds")
|
|
|
|
|
|
class ProductError(Exception):
|
|
"""Product-level error."""
|
|
|
|
|
|
class CofferProduct:
|
|
"""High-level API the platform and CLI call."""
|
|
|
|
def __init__(self, db_path: str | Path) -> None:
|
|
self.db = CofferDB(db_path)
|
|
self.ledger = Ledger()
|
|
self.store = new_store(self.ledger)
|
|
self._load()
|
|
|
|
def close(self) -> None:
|
|
self.db.close()
|
|
|
|
def _load(self) -> None:
|
|
self.ledger = Ledger()
|
|
self.store = new_store(self.ledger)
|
|
self.store.suppliers.clear()
|
|
self.store.goods.clear()
|
|
|
|
for row in self.db.execute("SELECT account_id FROM accounts").fetchall():
|
|
self.ledger.ensure_wallet(row["account_id"])
|
|
for row in self.db.execute(
|
|
"SELECT account_id, kind, remaining, reason FROM lots "
|
|
"WHERE remaining > 0 ORDER BY lot_id"
|
|
).fetchall():
|
|
self.ledger.account(row["account_id"]).lots.append(
|
|
Lot(CreditKind(row["kind"]), int(row["remaining"]), row["reason"])
|
|
)
|
|
|
|
self.ledger.transactions.clear()
|
|
self.ledger._tx_seq = 0
|
|
for row in self.db.execute(
|
|
"SELECT seq, kind, amount, debit_account, credit_account, reason, meta_json "
|
|
"FROM transactions ORDER BY seq"
|
|
).fetchall():
|
|
tx = Tx(
|
|
seq=int(row["seq"]),
|
|
kind=row["kind"],
|
|
amount=int(row["amount"]),
|
|
debit_account=row["debit_account"],
|
|
credit_account=row["credit_account"],
|
|
reason=row["reason"],
|
|
meta=self.db.loads(row["meta_json"] or "{}"),
|
|
)
|
|
self.ledger.transactions.append(tx)
|
|
self.ledger._tx_seq = max(self.ledger._tx_seq, tx.seq)
|
|
|
|
for row in self.db.execute("SELECT supplier_id, name FROM suppliers").fetchall():
|
|
self.store.add_supplier(row["supplier_id"], row["name"])
|
|
for row in self.db.execute(
|
|
"SELECT sku, name, credit_price, supplier_id, supplier_unit_cost, stock FROM goods"
|
|
).fetchall():
|
|
self.store.goods[row["sku"]] = Good(
|
|
sku=row["sku"],
|
|
name=row["name"],
|
|
credit_price=int(row["credit_price"]),
|
|
supplier_id=row["supplier_id"],
|
|
supplier_unit_cost=int(row["supplier_unit_cost"]),
|
|
stock=int(row["stock"]),
|
|
)
|
|
if "coffer-shirt" not in self.store.goods:
|
|
if "printer" not in self.store.suppliers:
|
|
self.store.add_supplier("printer", "Printer")
|
|
self.store.add_good(
|
|
sku="coffer-shirt",
|
|
name="Coffer shirt",
|
|
credit_price=SHIRT_CREDITS,
|
|
supplier_id="printer",
|
|
supplier_unit_cost=8,
|
|
)
|
|
self._persist_catalog()
|
|
self.db.commit()
|
|
|
|
for row in self.db.execute("SELECT * FROM invoices").fetchall():
|
|
inv = Invoice(
|
|
invoice_id=row["invoice_id"],
|
|
supplier_id=row["supplier_id"],
|
|
sku=row["sku"],
|
|
quantity=int(row["quantity"]),
|
|
dollars=int(row["dollars"]),
|
|
status=row["status"],
|
|
)
|
|
self.store.invoices[inv.invoice_id] = inv
|
|
self._bump_seq_from_id(inv.invoice_id)
|
|
for row in self.db.execute("SELECT * FROM orders").fetchall():
|
|
order = Order(
|
|
order_id=row["order_id"],
|
|
buyer_id=row["buyer_id"],
|
|
sku=row["sku"],
|
|
quantity=int(row["quantity"]),
|
|
credits_charged=int(row["credits_charged"]),
|
|
draws=self.db.loads(row["draws_json"]),
|
|
from_stock=int(row["from_stock"]),
|
|
procured=int(row["procured"]),
|
|
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)
|
|
for row in self.db.execute("SELECT key, order_id FROM idempotency").fetchall():
|
|
self.store._idempotency[row["key"]] = row["order_id"]
|
|
|
|
def _bump_seq_from_id(self, oid: str) -> None:
|
|
try:
|
|
n = int(str(oid).rsplit("-", 1)[-1])
|
|
if n > self.store._seq:
|
|
self.store._seq = n
|
|
except ValueError:
|
|
pass
|
|
|
|
def _ensure_account_row(self, account_id: str) -> None:
|
|
kind = account_kind(account_id)
|
|
self.db.execute(
|
|
"INSERT OR IGNORE INTO accounts(account_id, kind, created_at) VALUES(?,?,?)",
|
|
(account_id, kind, now_pt()),
|
|
)
|
|
|
|
def _rewrite_lots(self, account_id: str) -> None:
|
|
self._ensure_account_row(account_id)
|
|
self.db.execute("DELETE FROM lots WHERE account_id=?", (account_id,))
|
|
for lot in self.ledger.account(account_id).lots:
|
|
if lot.remaining <= 0:
|
|
continue
|
|
self.db.execute(
|
|
"INSERT INTO lots(account_id, kind, remaining, reason, created_at) "
|
|
"VALUES(?,?,?,?,?)",
|
|
(account_id, lot.kind.value, lot.remaining, lot.reason, now_pt()),
|
|
)
|
|
|
|
def _persist_tx(self, tx: Tx) -> None:
|
|
self.db.execute(
|
|
"INSERT INTO transactions(seq, kind, amount, debit_account, credit_account, "
|
|
"reason, meta_json, created_at) VALUES(?,?,?,?,?,?,?,?)",
|
|
(
|
|
tx.seq,
|
|
tx.kind,
|
|
tx.amount,
|
|
tx.debit_account,
|
|
tx.credit_account,
|
|
tx.reason,
|
|
self.db.dumps(tx.meta),
|
|
now_pt(),
|
|
),
|
|
)
|
|
|
|
def _persist_catalog(self) -> None:
|
|
for s in self.store.suppliers.values():
|
|
self.db.execute(
|
|
"INSERT OR REPLACE INTO suppliers(supplier_id, name) VALUES(?,?)",
|
|
(s.supplier_id, s.name),
|
|
)
|
|
for g in self.store.goods.values():
|
|
self.db.execute(
|
|
"INSERT OR REPLACE INTO goods(sku, name, credit_price, supplier_id, "
|
|
"supplier_unit_cost, stock) VALUES(?,?,?,?,?,?)",
|
|
(
|
|
g.sku,
|
|
g.name,
|
|
g.credit_price,
|
|
g.supplier_id,
|
|
g.supplier_unit_cost,
|
|
g.stock,
|
|
),
|
|
)
|
|
|
|
def _persist_order(self, order: Order) -> None:
|
|
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, 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,
|
|
order.sku,
|
|
order.quantity,
|
|
order.credits_charged,
|
|
self.db.dumps(order.draws),
|
|
order.from_stock,
|
|
order.procured,
|
|
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:
|
|
inv = self.store.invoices[order.invoice_id]
|
|
self.db.execute(
|
|
"INSERT OR REPLACE INTO invoices(invoice_id, supplier_id, sku, quantity, "
|
|
"dollars, status, created_at) VALUES(?,?,?,?,?,?,?)",
|
|
(
|
|
inv.invoice_id,
|
|
inv.supplier_id,
|
|
inv.sku,
|
|
inv.quantity,
|
|
inv.dollars,
|
|
inv.status,
|
|
now_pt(),
|
|
),
|
|
)
|
|
self._persist_catalog()
|
|
|
|
def _commit_accounts(self, *account_ids: str, txs: list[Tx] | None = None) -> None:
|
|
seen: set[str] = set()
|
|
for aid in account_ids:
|
|
if aid and aid not in seen:
|
|
self._rewrite_lots(aid)
|
|
seen.add(aid)
|
|
for tx in txs or []:
|
|
self._persist_tx(tx)
|
|
self.db.commit()
|
|
if txs:
|
|
self._after_mutation()
|
|
|
|
# ----- public API ----------------------------------------------------------
|
|
|
|
def create_wallet(self, account_id: str) -> dict[str, Any]:
|
|
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 = 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"],
|
|
}
|
|
|
|
def history(self) -> list[dict[str, Any]]:
|
|
return [self.ledger.describe_tx(tx) for tx in self.ledger.history()]
|
|
|
|
def bootstrap_opening_books(self) -> dict[str, Any]:
|
|
"""Seed opening balances without business txs (so contract can be first tx)."""
|
|
if self.db.get_meta("bootstrapped") == "1":
|
|
return {"bootstrapped": True, "already": True}
|
|
before = len(self.ledger.transactions)
|
|
self.ledger.bootstrap_endowment(
|
|
ACCOUNT_COFFER,
|
|
COFFER_INVESTMENT_CREDITS,
|
|
CreditKind.FUNDED,
|
|
"leora-investment",
|
|
)
|
|
self.ledger.bootstrap_endowment(
|
|
ACCOUNT_LEORA,
|
|
LEORA_FUNDED_AFTER_GRANTS_CREDITS,
|
|
CreditKind.FUNDED,
|
|
"funded-after-grants",
|
|
)
|
|
self.ledger.ensure_wallet(ACCOUNT_NEW_INTERNET)
|
|
self._rewrite_lots(ACCOUNT_COFFER)
|
|
self._rewrite_lots(ACCOUNT_LEORA)
|
|
self._ensure_account_row(ACCOUNT_NEW_INTERNET)
|
|
self.db.set_meta("bootstrapped", "1")
|
|
self.db.commit()
|
|
assert len(self.ledger.transactions) == before
|
|
return {
|
|
"bootstrapped": True,
|
|
"already": False,
|
|
"coffer": self.balance(ACCOUNT_COFFER),
|
|
"leora": self.balance(ACCOUNT_LEORA),
|
|
}
|
|
|
|
def record_build_contract(
|
|
self,
|
|
amount_credits: int = CONTRACT_CREDITS,
|
|
*,
|
|
payer: str = ACCOUNT_COFFER,
|
|
payee: str = ACCOUNT_LEORA,
|
|
) -> dict[str, Any]:
|
|
"""First business ledger tx: Coffer pays Leora for the product build."""
|
|
if self.db.get_meta("contract_recorded") == "1":
|
|
for tx in self.ledger.history():
|
|
if tx.kind == "contract_payment":
|
|
return {"already": True, "tx": self.ledger.describe_tx(tx)}
|
|
raise ProductError("contract_recorded meta set but tx missing")
|
|
if self.db.get_meta("bootstrapped") != "1":
|
|
self.bootstrap_opening_books()
|
|
tx = self.ledger.transfer(
|
|
payer,
|
|
payee,
|
|
to_subunits(amount_credits),
|
|
reason="build-contract:leora-coffer-product",
|
|
tx_kind="contract_payment",
|
|
credit_kind=CreditKind.FUNDED,
|
|
meta={"contract": "coffer-product-build", "credits": amount_credits},
|
|
)
|
|
self._commit_accounts(payer, payee, txs=[tx])
|
|
self.db.set_meta("contract_recorded", "1")
|
|
self.db.commit()
|
|
return {
|
|
"already": False,
|
|
"tx": self.ledger.describe_tx(tx),
|
|
"payer": self.balance(payer),
|
|
"payee": self.balance(payee),
|
|
}
|
|
|
|
def grant_signup(
|
|
self,
|
|
agent_id: str,
|
|
amount_credits: int = SIGNUP_GRANT_CREDITS,
|
|
) -> dict[str, Any]:
|
|
aid = agent_account(agent_id)
|
|
marker = f"signup_grant:{aid}"
|
|
if self.db.get_meta(marker) == "1":
|
|
return {"already": True, "account_id": aid, "balance": self.balance(aid)}
|
|
self.ledger.ensure_wallet(aid)
|
|
before = self.ledger._tx_seq
|
|
self.ledger.grant(aid, amount_credits, reason="signup-grant")
|
|
txs = [t for t in self.ledger.transactions if t.seq > before]
|
|
self._commit_accounts(aid, txs=txs)
|
|
self.db.set_meta(marker, "1")
|
|
self.db.commit()
|
|
return {
|
|
"already": False,
|
|
"account_id": aid,
|
|
"granted_credits": amount_credits,
|
|
"balance": self.balance(aid),
|
|
"tx": self.ledger.describe_tx(txs[-1]) if txs else None,
|
|
}
|
|
|
|
def charge_subscription(
|
|
self,
|
|
agent_id: str,
|
|
amount_credits: int = SUBSCRIPTION_CREDITS,
|
|
*,
|
|
cut_bps: int = SUBSCRIPTION_CUT_BPS,
|
|
) -> dict[str, Any]:
|
|
aid = agent_account(agent_id)
|
|
gross = to_subunits(amount_credits)
|
|
cut = cut_subunits(gross, cut_bps)
|
|
net = gross - cut
|
|
draws, tx_pay = self.ledger.purchase_settle(
|
|
aid,
|
|
ACCOUNT_COFFER,
|
|
gross,
|
|
reason="subscription:new-internet-seat",
|
|
tx_kind="subscription_charge",
|
|
meta={"agent_id": aid, "credits": amount_credits},
|
|
)
|
|
tx_fwd = self.ledger.transfer(
|
|
ACCOUNT_COFFER,
|
|
ACCOUNT_NEW_INTERNET,
|
|
net,
|
|
reason="subscription:forward-to-new-internet-co",
|
|
tx_kind="subscription_forward",
|
|
credit_kind=CreditKind.FUNDED,
|
|
meta={"agent_id": aid, "cut_centicredits": cut, "net_centicredits": net},
|
|
)
|
|
self._commit_accounts(
|
|
aid, ACCOUNT_COFFER, ACCOUNT_NEW_INTERNET, txs=[tx_pay, tx_fwd]
|
|
)
|
|
return {
|
|
"agent_id": aid,
|
|
"gross_credits": amount_credits,
|
|
"coffer_cut_credits": format_credits(cut),
|
|
"coffer_cut_centicredits": cut,
|
|
"leora_net_credits": format_credits(net),
|
|
"leora_net_centicredits": net,
|
|
"draws": [
|
|
{"kind": d["kind"], "amount_centicredits": d["amount"]} for d in draws
|
|
],
|
|
"txs": [self.ledger.describe_tx(tx_pay), self.ledger.describe_tx(tx_fwd)],
|
|
"balances": {
|
|
"agent": self.balance(aid),
|
|
"coffer": self.balance(ACCOUNT_COFFER),
|
|
"new_internet_co": self.balance(ACCOUNT_NEW_INTERNET),
|
|
},
|
|
}
|
|
|
|
def signup_flow(self, agent_id: str) -> dict[str, Any]:
|
|
wallet = self.create_wallet(agent_id)
|
|
grant = self.grant_signup(agent_id)
|
|
sub = self.charge_subscription(agent_id)
|
|
return {"wallet": wallet, "grant": grant, "subscription": sub}
|
|
|
|
def buy_shirt(
|
|
self,
|
|
agent_id: str,
|
|
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(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_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)
|
|
before = self.ledger._tx_seq
|
|
self.ledger.top_up(aid, amount_credits)
|
|
txs = [t for t in self.ledger.transactions if t.seq > before]
|
|
self._commit_accounts(aid, txs=txs)
|
|
return {
|
|
"account_id": aid,
|
|
"balance": self.balance(aid),
|
|
"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():
|
|
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())}
|
|
|
|
def public_snapshot(self) -> dict:
|
|
"""Full public ledger view (no human PII). All txs are public."""
|
|
return snapshot_from_product(self, now_pt())
|
|
|
|
def publish_public_ledger(self) -> dict:
|
|
"""Write public snapshot and publish to News (branch → PR → merge → surge)."""
|
|
snap = self.public_snapshot()
|
|
pub = Path(self.db.path).with_name("public-ledger.json")
|
|
import json
|
|
|
|
pub.write_text(
|
|
json.dumps(
|
|
{
|
|
"updated_at": snap["updated_at"],
|
|
"balances": snap["balances"],
|
|
"transactions": snap["transactions"],
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
from coffer.news_publish import try_auto_publish
|
|
|
|
result = try_auto_publish(snap["article_html"], updated_at=snap["updated_at"])
|
|
result["snapshot_path"] = str(pub)
|
|
return result
|
|
|
|
def _after_mutation(self) -> None:
|
|
"""Ledger changes are public; refresh local snapshot and News when enabled."""
|
|
import json
|
|
import os
|
|
|
|
snap = self.public_snapshot()
|
|
pub = Path(self.db.path).with_name("public-ledger.json")
|
|
pub.write_text(
|
|
json.dumps(
|
|
{
|
|
"updated_at": snap["updated_at"],
|
|
"balances": snap["balances"],
|
|
"transactions": snap["transactions"],
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\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:
|
|
from coffer.news_publish import try_auto_publish
|
|
|
|
try_auto_publish(snap["article_html"], updated_at=snap["updated_at"])
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"coffer news publish skipped: {exc}", flush=True)
|