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).
193 lines
5.9 KiB
Python
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
|