Files
periscope/backend/services/billing_hook.py
Siddharth Kothari 6672d2be57 Pinscope open-source core
Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
2026-07-16 21:29:45 -07:00

193 lines
5.9 KiB
Python

"""Open-core billing seam.
Everything outside the billing modules (``credits``, ``credit_grants``,
``stripe_billing``, ``stripe_customer_map``, ``auto_topup`` and the
``billing``/``credits`` routers) talks to billing exclusively through
:func:`get_billing`. With ``BILLING_ENABLED=false`` the returned
:class:`NullBilling` makes every pipeline run free — the same shape as the
existing admin ``free=True`` path — so the core can run with no credits
ledger, no Stripe, and no billing routes mounted.
This module must stay a leaf: no billing module is imported at module
level (``CreditsBilling`` lazy-imports inside each method), so the core
never touches the Stripe SDK when billing is disabled.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
from backend.config import settings
if TYPE_CHECKING:
from backend.services.storage import StorageBackend
class InsufficientCredits(RuntimeError):
"""Raised when a charge would drop the balance below zero."""
def __init__(self, required: float, available: float) -> None:
super().__init__(
f"Insufficient credits: required {required}, available {available}"
)
self.required = required
self.available = available
class BillingHook(Protocol):
"""The full billing surface the core is allowed to depend on."""
def credits_for_api_cost(self, cost_usd: float) -> float: ...
def get_balance(self, storage: "StorageBackend", user_id: str) -> float: ...
def charge(
self,
storage: "StorageBackend",
user_id: str,
amount: float,
*,
reason: str = "pipeline_charge",
run_id: str | None = None,
unit_id: str | None = None,
allow_overdraft: bool = False,
) -> None: ...
def ensure_trial_grant(self, storage: "StorageBackend", user_id: str) -> bool: ...
def list_user_ids(self, storage: "StorageBackend") -> list[str]: ...
async def maybe_auto_topup(
self, storage: "StorageBackend", user_id: str
) -> dict | None: ...
class NullBilling:
"""Billing disabled: everything is free and nothing is written.
``credits_for_api_cost`` returning 0.0 is the linchpin — every
``ApiLogger`` entry gets ``credits_charged=0``, so the pipeline's
charge path early-returns and the credit gate always allows.
"""
def credits_for_api_cost(self, cost_usd: float) -> float:
return 0.0
def get_balance(self, storage: "StorageBackend", user_id: str) -> float:
return 0.0
def charge(
self,
storage: "StorageBackend",
user_id: str,
amount: float,
*,
reason: str = "pipeline_charge",
run_id: str | None = None,
unit_id: str | None = None,
allow_overdraft: bool = False,
) -> None:
return None
def ensure_trial_grant(self, storage: "StorageBackend", user_id: str) -> bool:
return False
def list_user_ids(self, storage: "StorageBackend") -> list[str]:
return []
async def maybe_auto_topup(
self, storage: "StorageBackend", user_id: str
) -> dict | None:
return None
class CreditsBilling:
"""Production billing: delegates to the credits ledger + auto top-up."""
def credits_for_api_cost(self, cost_usd: float) -> float:
from backend.services import credits as credits_svc
return credits_svc.credits_for_api_cost(cost_usd)
def get_balance(self, storage: "StorageBackend", user_id: str) -> float:
from backend.services import credits as credits_svc
return credits_svc.get_balance(storage, user_id)
def charge(
self,
storage: "StorageBackend",
user_id: str,
amount: float,
*,
reason: str = "pipeline_charge",
run_id: str | None = None,
unit_id: str | None = None,
allow_overdraft: bool = False,
) -> None:
from backend.services import credits as credits_svc
credits_svc.charge(
storage, user_id, amount,
reason=reason,
run_id=run_id,
unit_id=unit_id,
allow_overdraft=allow_overdraft,
)
def ensure_trial_grant(self, storage: "StorageBackend", user_id: str) -> bool:
from backend.services import credits as credits_svc
return credits_svc.ensure_trial_grant(storage, user_id)
def list_user_ids(self, storage: "StorageBackend") -> list[str]:
from backend.services import credits as credits_svc
return credits_svc.list_user_ids(storage)
async def maybe_auto_topup(
self, storage: "StorageBackend", user_id: str
) -> dict | None:
"""Run an auto top-up attempt if configured.
Returns ``{"reason", "amount_usd"}`` when this call produced a NEW
failed attempt (so the caller can notify the user), else None.
"""
from backend.services.auto_topup import get_config, maybe_trigger
before = get_config(storage, user_id).last_attempt_ts
try:
await maybe_trigger(storage, user_id)
except Exception:
return None
after = get_config(storage, user_id)
if (
after.last_attempt_status == "failed"
and after.last_attempt_ts
and after.last_attempt_ts != before
):
return {
"reason": after.last_failure_reason or "unknown",
"amount_usd": after.amount_usd,
}
return None
_NULL = NullBilling()
_credits_billing: CreditsBilling | None = None
def get_billing() -> BillingHook:
"""Return the active billing implementation.
Selected per call (not at import) so the ``billing_enabled`` setting
can be monkeypatched in tests and so importing this module never pulls
in billing code.
"""
if not settings.billing_enabled:
return _NULL
global _credits_billing
if _credits_billing is None:
_credits_billing = CreditsBilling()
return _credits_billing