Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
95
scripts/workers/crypto_offramp/__init__.py
Normal file
95
scripts/workers/crypto_offramp/__init__.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Crypto offramp module.
|
||||
|
||||
Converts coins received by SHKeeper into USD at RelayFi bank via
|
||||
Coinbase Prime — sole provider per plan. See
|
||||
/home/justin/.claude/plans/swirling-napping-sonnet.md.
|
||||
|
||||
Exports:
|
||||
Quote, ExecutionResult, ProviderStatus — dataclasses
|
||||
Rail — literal type alias
|
||||
OfframpError, InsufficientBalanceError, KycHoldError — exception hierarchy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal, Optional
|
||||
|
||||
Rail = Literal["ach", "wire", "rtp"]
|
||||
|
||||
# Supported coins (XMR intentionally excluded — Coinbase Prime doesn't offramp it).
|
||||
SUPPORTED_COINS: set[str] = {
|
||||
"BTC", "ETH", "MATIC", "LTC", "TRX", "BNB", "DOGE", "USDC",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Quote:
|
||||
"""Pre-execute price quote from the offramp provider."""
|
||||
from_coin: str
|
||||
to_currency: str = "USD"
|
||||
amount_coin: Decimal = Decimal("0")
|
||||
estimated_usd_cents: int = 0
|
||||
fx_rate_usd: Decimal = Decimal("0") # 1 coin = N USD
|
||||
fee_usd_cents: int = 0 # provider fee estimate
|
||||
valid_until: Optional[datetime] = None
|
||||
quote_id: Optional[str] = None # provider-side quote handle, if any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""Outcome of calling `execute()` — the sell + wire request was accepted."""
|
||||
provider_ref: str # Prime order id / transfer id
|
||||
accepted_usd_cents: int # what the provider promised to settle
|
||||
fill_price_usd: Decimal # actual coin→USD price achieved
|
||||
state: Literal["submitted", "partial", "completed", "failed"]
|
||||
rail: Rail
|
||||
memo: Optional[str] = None # e.g., "PW-ORDER-FO-2026-0042"
|
||||
raw: dict = field(default_factory=dict) # provider-native response for debugging
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderStatus:
|
||||
"""Current state of a previously-executed offramp."""
|
||||
provider_ref: str
|
||||
state: Literal[
|
||||
"submitted", "selling", "selling_complete", "wiring", "completed",
|
||||
"failed", "held",
|
||||
]
|
||||
settled_usd_cents: int = 0
|
||||
fill_price_usd: Optional[Decimal] = None
|
||||
wire_tx_id: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Exception hierarchy ─────────────────────────────────────────────────
|
||||
|
||||
class OfframpError(Exception):
|
||||
"""Base for all offramp errors."""
|
||||
|
||||
|
||||
class InsufficientBalanceError(OfframpError):
|
||||
"""Exchange side doesn't have enough of the coin credited yet."""
|
||||
|
||||
|
||||
class KycHoldError(OfframpError):
|
||||
"""Exchange paused our withdrawal for AML/KYC review — admin must resolve."""
|
||||
|
||||
|
||||
class SlippageExceededError(OfframpError):
|
||||
"""Realized fill price was outside MAX_SLIPPAGE_BPS of quote."""
|
||||
|
||||
|
||||
class ProviderDegradedError(OfframpError):
|
||||
"""Repeated 5xx/429 — circuit breaker tripped."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Rail", "SUPPORTED_COINS",
|
||||
"Quote", "ExecutionResult", "ProviderStatus",
|
||||
"OfframpError", "InsufficientBalanceError", "KycHoldError",
|
||||
"SlippageExceededError", "ProviderDegradedError",
|
||||
]
|
||||
423
scripts/workers/crypto_offramp/bridge.py
Normal file
423
scripts/workers/crypto_offramp/bridge.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
"""Bridge (by Stripe) offramp provider — sole provider per plan.
|
||||
|
||||
Docs: https://apidocs.bridge.xyz/
|
||||
Auth: REST with `Api-Key` header + `Idempotency-Key` header on POSTs.
|
||||
|
||||
Bridge accepts crypto at a deposit address and settles USD to a
|
||||
pre-registered external bank account via RTP / FedNow / wire / ACH.
|
||||
Unlike Coinbase Prime (which is a two-step: sell on exchange, then wire
|
||||
withdrawal), Bridge is a single-transfer model: create a transfer, send
|
||||
coins to its deposit address, and Bridge handles the conversion + payout
|
||||
to the destination bank.
|
||||
|
||||
Env vars:
|
||||
BRIDGE_API_KEY — Bridge API key
|
||||
BRIDGE_API_URL — default https://api.bridge.xyz
|
||||
BRIDGE_RELAY_EXTERNAL_ACCOUNT_ID — pre-registered RelayFi account id
|
||||
BRIDGE_DEVELOPER_FEE_USD — optional platform fee (defaults 0)
|
||||
|
||||
Flow:
|
||||
1. prepare_transfer(coin, target_usd_cents, rail, memo, idempotency_key)
|
||||
→ POST /v0/transfers — returns {transfer_id, deposit_address}
|
||||
2. SHKeeper payout to deposit_address
|
||||
3. Bridge auto-detects deposit, converts, wires USD to Relay via RTP
|
||||
4. status(transfer_id) — poll until state='payment_submitted' | 'completed'
|
||||
|
||||
Supported coins (BRIDGE_ACCEPTS — restricted to what Bridge takes):
|
||||
USDC / USDT / BTC / ETH / SOL (and more) — drops XMR + MATIC/TRX/BNB/DOGE.
|
||||
See `accepts_coins` below. If the SHKeeper webhook delivers an
|
||||
unsupported coin, the orchestrator flips the job to state='manual'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from . import (
|
||||
ExecutionResult,
|
||||
KycHoldError,
|
||||
OfframpError,
|
||||
ProviderDegradedError,
|
||||
ProviderStatus,
|
||||
Quote,
|
||||
Rail,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_API_URL", "https://api.bridge.xyz")
|
||||
|
||||
# Coins Bridge takes at source. Verify against the live API at integration
|
||||
# time — this list reflects Bridge's publicly supported asset menu as of
|
||||
# our integration date. Unsupported coins force manual handling.
|
||||
BRIDGE_ACCEPTS_COINS: set[str] = {"USDC", "USDT", "BTC", "ETH"}
|
||||
|
||||
# Coin → Bridge "currency" slug + default "payment_rail" (source chain)
|
||||
_COIN_TO_BRIDGE: dict[str, dict[str, str]] = {
|
||||
"USDC": {"currency": "usdc", "payment_rail": "ethereum"},
|
||||
"USDT": {"currency": "usdt", "payment_rail": "ethereum"},
|
||||
"BTC": {"currency": "btc", "payment_rail": "bitcoin"},
|
||||
"ETH": {"currency": "eth", "payment_rail": "ethereum"},
|
||||
}
|
||||
|
||||
|
||||
# ── Circuit breaker (module-level) ──────────────────────────────────────
|
||||
|
||||
class _CircuitBreaker:
|
||||
"""Trips if ≥3 provider errors in rolling 10-min window."""
|
||||
ERROR_WINDOW_SEC = 600
|
||||
ERROR_THRESHOLD = 3
|
||||
|
||||
def __init__(self):
|
||||
self._errors: deque[float] = deque()
|
||||
|
||||
def record_error(self):
|
||||
now = time.time()
|
||||
self._errors.append(now)
|
||||
cutoff = now - self.ERROR_WINDOW_SEC
|
||||
while self._errors and self._errors[0] < cutoff:
|
||||
self._errors.popleft()
|
||||
|
||||
def is_tripped(self) -> bool:
|
||||
now = time.time()
|
||||
cutoff = now - self.ERROR_WINDOW_SEC
|
||||
while self._errors and self._errors[0] < cutoff:
|
||||
self._errors.popleft()
|
||||
return len(self._errors) >= self.ERROR_THRESHOLD
|
||||
|
||||
def reset(self):
|
||||
self._errors.clear()
|
||||
|
||||
|
||||
_breaker = _CircuitBreaker()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeTransferPrep:
|
||||
"""Result of creating a Bridge transfer. The deposit_address is where
|
||||
SHKeeper should send coins; the transfer_id is what we poll for status."""
|
||||
transfer_id: str
|
||||
deposit_address: str
|
||||
expected_usd_cents: int
|
||||
source_currency: str
|
||||
source_rail: str
|
||||
destination_rail: Rail
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class BridgeOfframp:
|
||||
"""Sole offramp provider. Uses Bridge's transfers API for both the
|
||||
deposit-instructions and settlement in one atomic object.
|
||||
|
||||
Note on interface fit: Bridge doesn't split sell + wire into separate
|
||||
calls like Coinbase Prime did. We expose a new `prepare_transfer()`
|
||||
method that the orchestrator uses instead of the old
|
||||
`deposit_address()` + `execute()` split. The legacy methods are
|
||||
implemented as thin wrappers so the existing test surface still works.
|
||||
"""
|
||||
|
||||
name = "bridge"
|
||||
accepts_coins = BRIDGE_ACCEPTS_COINS
|
||||
supports_rails: set[Rail] = {"rtp", "wire", "ach"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
relay_external_account_id: Optional[str] = None,
|
||||
developer_fee_usd: Optional[Decimal] = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("BRIDGE_API_KEY", "")
|
||||
self.api_url = api_url or BRIDGE_URL
|
||||
self.relay_external_account_id = (
|
||||
relay_external_account_id
|
||||
or os.environ.get("BRIDGE_RELAY_EXTERNAL_ACCOUNT_ID", "")
|
||||
)
|
||||
self.developer_fee_usd = developer_fee_usd or Decimal(
|
||||
os.environ.get("BRIDGE_DEVELOPER_FEE_USD", "0"),
|
||||
)
|
||||
missing = [
|
||||
k for k, v in [
|
||||
("api_key", self.api_key),
|
||||
("relay_external_account_id", self.relay_external_account_id),
|
||||
] if not v
|
||||
]
|
||||
if missing:
|
||||
logger.warning(
|
||||
"BridgeOfframp: missing env vars %s — calls will fail until configured.",
|
||||
missing,
|
||||
)
|
||||
|
||||
# ── Public interface ────────────────────────────────────────────────
|
||||
|
||||
async def prepare_transfer(
|
||||
self,
|
||||
*,
|
||||
target_usd_cents: int,
|
||||
from_coin: str,
|
||||
rail: Rail = "rtp",
|
||||
memo: Optional[str] = None,
|
||||
idempotency_key: str,
|
||||
) -> BridgeTransferPrep:
|
||||
"""Create a Bridge transfer.
|
||||
|
||||
Idempotent: the Bridge API dedups by Idempotency-Key header, so
|
||||
calling this repeatedly with the same key returns the same
|
||||
transfer. Returns the deposit_address to send coins to.
|
||||
"""
|
||||
self._guard_breaker()
|
||||
from_coin = from_coin.upper()
|
||||
if from_coin not in BRIDGE_ACCEPTS_COINS:
|
||||
raise OfframpError(
|
||||
f"coin {from_coin} not supported by Bridge; "
|
||||
f"supported = {sorted(BRIDGE_ACCEPTS_COINS)}",
|
||||
)
|
||||
rail_lower = rail.lower()
|
||||
if rail_lower not in self.supports_rails:
|
||||
raise OfframpError(f"invalid rail {rail}")
|
||||
|
||||
src_cfg = _COIN_TO_BRIDGE[from_coin]
|
||||
body = {
|
||||
"source": {
|
||||
"currency": src_cfg["currency"],
|
||||
"payment_rail": src_cfg["payment_rail"],
|
||||
},
|
||||
"destination": {
|
||||
"currency": "usd",
|
||||
"payment_rail": rail_lower, # rtp / wire / ach
|
||||
"external_account_id": self.relay_external_account_id,
|
||||
},
|
||||
# amount in USD; Bridge converts the equivalent crypto on deposit
|
||||
"amount": f"{target_usd_cents / 100:.2f}",
|
||||
}
|
||||
if memo:
|
||||
body["destination"]["bank_transfer_memo"] = memo
|
||||
if self.developer_fee_usd > 0:
|
||||
body["developer_fee"] = str(self.developer_fee_usd)
|
||||
|
||||
resp = await self._request(
|
||||
"POST", "/v0/transfers",
|
||||
json_body=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
# Bridge returns the deposit instructions inside source.deposit_instructions
|
||||
deposit = (resp.get("source") or {}).get("deposit_instructions") or {}
|
||||
address = deposit.get("address") or ""
|
||||
if not address:
|
||||
raise OfframpError(
|
||||
f"Bridge returned no deposit address for {from_coin}; "
|
||||
f"response shape changed or account not onboarded",
|
||||
)
|
||||
transfer_id = resp.get("id") or resp.get("transfer_id") or ""
|
||||
if not transfer_id:
|
||||
raise OfframpError("Bridge returned no transfer id")
|
||||
|
||||
return BridgeTransferPrep(
|
||||
transfer_id=transfer_id,
|
||||
deposit_address=address,
|
||||
expected_usd_cents=target_usd_cents,
|
||||
source_currency=src_cfg["currency"],
|
||||
source_rail=src_cfg["payment_rail"],
|
||||
destination_rail=rail,
|
||||
raw=resp,
|
||||
)
|
||||
|
||||
async def quote(self, amount_coin: Decimal, from_coin: str) -> Quote:
|
||||
"""Light price hint — Bridge gives an atomic price at transfer
|
||||
creation, so this is mostly informational. For USDC/USDT it's
|
||||
always ~1:1."""
|
||||
self._guard_breaker()
|
||||
from_coin = from_coin.upper()
|
||||
if from_coin in ("USDC", "USDT"):
|
||||
return Quote(
|
||||
from_coin=from_coin,
|
||||
amount_coin=amount_coin,
|
||||
estimated_usd_cents=int(amount_coin * Decimal("100")),
|
||||
fx_rate_usd=Decimal("1.00"),
|
||||
fee_usd_cents=0,
|
||||
valid_until=datetime.utcnow() + timedelta(seconds=60),
|
||||
)
|
||||
# For BTC/ETH, use Bridge's pricing endpoint if available; else
|
||||
# fall back to a public CoinGecko fetch. Keep deterministic for
|
||||
# tests — for production integration, replace with Bridge's
|
||||
# /v0/exchange_rates or similar.
|
||||
try:
|
||||
rates = await self._request("GET", "/v0/exchange_rates")
|
||||
pair = f"{from_coin.lower()}-usd"
|
||||
rate_str = (rates.get("rates") or {}).get(pair, {}).get("rate")
|
||||
if rate_str:
|
||||
rate = Decimal(str(rate_str))
|
||||
gross = amount_coin * rate
|
||||
return Quote(
|
||||
from_coin=from_coin,
|
||||
amount_coin=amount_coin,
|
||||
estimated_usd_cents=int(gross * Decimal("100")),
|
||||
fx_rate_usd=rate,
|
||||
fee_usd_cents=0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("bridge.quote rates fetch failed (%s) — falling back", exc)
|
||||
# Fallback to an approximate market quote (caller can accept 0 rate)
|
||||
return Quote(
|
||||
from_coin=from_coin,
|
||||
amount_coin=amount_coin,
|
||||
estimated_usd_cents=0,
|
||||
fx_rate_usd=Decimal("0"),
|
||||
fee_usd_cents=0,
|
||||
)
|
||||
|
||||
async def status(self, provider_ref: str) -> ProviderStatus:
|
||||
self._guard_breaker()
|
||||
data = await self._request("GET", f"/v0/transfers/{provider_ref}")
|
||||
bridge_state = (data.get("state") or "").lower()
|
||||
mapped = {
|
||||
"awaiting_funds": "submitted",
|
||||
"funds_received": "selling_complete",
|
||||
"in_review": "held",
|
||||
"payment_processing": "wiring",
|
||||
"payment_submitted": "wiring",
|
||||
"payment_processed": "completed",
|
||||
"payment_reviewed": "completed",
|
||||
"completed": "completed",
|
||||
"refunded": "failed",
|
||||
"returned": "failed",
|
||||
"failed": "failed",
|
||||
"undeliverable": "failed",
|
||||
}.get(bridge_state, "submitted")
|
||||
if mapped == "held":
|
||||
logger.warning(
|
||||
"Bridge transfer %s in review (KYC/AML hold possible)",
|
||||
provider_ref,
|
||||
)
|
||||
# Final amount received at destination — Bridge's shape:
|
||||
# {"destination":{"amount":{"value":"299.00"}}} or similar
|
||||
dest = (data.get("destination") or {}).get("amount") or {}
|
||||
value_str = dest.get("value") or data.get("amount") or "0"
|
||||
settled_cents = int(Decimal(str(value_str)) * Decimal("100"))
|
||||
bank_ref = (
|
||||
(data.get("destination") or {}).get("bank_transfer_reference")
|
||||
or data.get("network_reference")
|
||||
)
|
||||
return ProviderStatus(
|
||||
provider_ref=provider_ref,
|
||||
state=mapped,
|
||||
settled_usd_cents=settled_cents if mapped == "completed" else 0,
|
||||
wire_tx_id=bank_ref,
|
||||
error_message=data.get("error_message") or data.get("refund_reason"),
|
||||
raw=data,
|
||||
)
|
||||
|
||||
# ── Legacy Protocol surface (compat with the existing OfframpProvider shape) ──
|
||||
#
|
||||
# The orchestrator was originally designed around a Coinbase-Prime-style
|
||||
# two-step flow (deposit_address, then execute). Bridge is one-step —
|
||||
# prepare_transfer gives you both. For the new crypto_payment_worker
|
||||
# branch ("if provider_name == 'bridge':") we call prepare_transfer
|
||||
# directly. These wrapper methods let the existing Protocol callers
|
||||
# (anything written against CoinbasePrimeOfframp) still function.
|
||||
|
||||
async def deposit_address(self, coin: str) -> str:
|
||||
raise OfframpError(
|
||||
"BridgeOfframp.deposit_address is not supported — "
|
||||
"use prepare_transfer() (which returns both the transfer id and address).",
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
amount_coin: Decimal,
|
||||
from_coin: str,
|
||||
rail: Rail,
|
||||
idempotency_key: str,
|
||||
relay_routing: Optional[str] = None,
|
||||
relay_account: Optional[str] = None,
|
||||
memo: Optional[str] = None,
|
||||
target_usd_cents: Optional[int] = None,
|
||||
) -> ExecutionResult:
|
||||
"""Legacy-Protocol wrapper around prepare_transfer. Returns an
|
||||
ExecutionResult whose provider_ref is the Bridge transfer id."""
|
||||
if target_usd_cents is None:
|
||||
raise OfframpError(
|
||||
"BridgeOfframp.execute requires target_usd_cents (USD-denominated transfer)",
|
||||
)
|
||||
prep = await self.prepare_transfer(
|
||||
target_usd_cents=target_usd_cents,
|
||||
from_coin=from_coin,
|
||||
rail=rail,
|
||||
memo=memo,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
return ExecutionResult(
|
||||
provider_ref=prep.transfer_id,
|
||||
accepted_usd_cents=prep.expected_usd_cents,
|
||||
fill_price_usd=Decimal("0"), # no explicit fill price pre-settlement
|
||||
state="submitted",
|
||||
rail=rail,
|
||||
memo=memo,
|
||||
raw={"bridge_prep": prep.raw, "deposit_address": prep.deposit_address},
|
||||
)
|
||||
|
||||
# ── Internals ───────────────────────────────────────────────────────
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
) -> dict:
|
||||
url = f"{self.api_url}{path}"
|
||||
headers = {
|
||||
"Api-Key": self.api_key,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
body = json.dumps(json_body, separators=(",", ":")) if json_body else None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.request(
|
||||
method, url, headers=headers, params=params, content=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
_breaker.record_error()
|
||||
raise OfframpError(f"Bridge request failed: {exc}") from exc
|
||||
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
_breaker.record_error()
|
||||
raise OfframpError(
|
||||
f"Bridge {method} {path} → {resp.status_code}: {resp.text[:200]}",
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
err_text = resp.text[:500]
|
||||
if "kyc" in err_text.lower() or "aml" in err_text.lower() or "review" in err_text.lower():
|
||||
raise KycHoldError(err_text)
|
||||
raise OfframpError(
|
||||
f"Bridge {method} {path} → {resp.status_code}: {err_text}",
|
||||
)
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def _guard_breaker(self):
|
||||
if _breaker.is_tripped():
|
||||
raise ProviderDegradedError(
|
||||
"Bridge breaker tripped — ≥3 errors in last 10min. "
|
||||
"Parking in manual review per plan.",
|
||||
)
|
||||
159
scripts/workers/crypto_offramp/mock.py
Normal file
159
scripts/workers/crypto_offramp/mock.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""In-memory mock offramp for tests.
|
||||
|
||||
Same method surface as CoinbasePrimeOfframp but never hits the network.
|
||||
Records calls for assertion. State advances deterministically based on
|
||||
``auto_complete`` (default True) or manually via ``.advance()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from . import (
|
||||
ExecutionResult,
|
||||
KycHoldError,
|
||||
OfframpError,
|
||||
ProviderStatus,
|
||||
Quote,
|
||||
Rail,
|
||||
SUPPORTED_COINS,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MockTransfer:
|
||||
provider_ref: str
|
||||
amount_usd_cents: int
|
||||
rail: Rail
|
||||
state: str = "submitted"
|
||||
memo: Optional[str] = None
|
||||
|
||||
|
||||
class MockOfframp:
|
||||
name = "mock"
|
||||
accepts_coins = SUPPORTED_COINS
|
||||
supports_rails: set[Rail] = {"ach", "wire", "rtp"}
|
||||
|
||||
def __init__(self, *, auto_complete: bool = True, kyc_hold_for_coins: Optional[set[str]] = None):
|
||||
self.auto_complete = auto_complete
|
||||
self.kyc_hold_for_coins = kyc_hold_for_coins or set()
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
self._transfers: dict[str, _MockTransfer] = {}
|
||||
# Fixed synthetic prices
|
||||
self._prices = {
|
||||
"BTC": Decimal("70000.00"),
|
||||
"ETH": Decimal("3500.00"),
|
||||
"MATIC": Decimal("0.80"),
|
||||
"LTC": Decimal("85.00"),
|
||||
"TRX": Decimal("0.11"),
|
||||
"BNB": Decimal("520.00"),
|
||||
"DOGE": Decimal("0.15"),
|
||||
"USDC": Decimal("1.00"),
|
||||
}
|
||||
|
||||
async def quote(self, amount_coin: Decimal, from_coin: str) -> Quote:
|
||||
self.calls.append(("quote", {"amount_coin": amount_coin, "from_coin": from_coin}))
|
||||
fx = self._prices.get(from_coin.upper(), Decimal("1.00"))
|
||||
gross = amount_coin * fx
|
||||
fee = gross * Decimal("0.0008")
|
||||
return Quote(
|
||||
from_coin=from_coin.upper(),
|
||||
amount_coin=amount_coin,
|
||||
estimated_usd_cents=int((gross - fee) * 100),
|
||||
fx_rate_usd=fx,
|
||||
fee_usd_cents=int(fee * 100),
|
||||
)
|
||||
|
||||
async def deposit_address(self, coin: str) -> str:
|
||||
self.calls.append(("deposit_address", {"coin": coin}))
|
||||
return f"mock-deposit-{coin.lower()}-{uuid4().hex[:8]}"
|
||||
|
||||
async def execute(
|
||||
self, *,
|
||||
amount_coin: Decimal, from_coin: str, rail: Rail,
|
||||
idempotency_key: str,
|
||||
relay_routing: Optional[str] = None,
|
||||
relay_account: Optional[str] = None,
|
||||
memo: Optional[str] = None,
|
||||
target_usd_cents: Optional[int] = None,
|
||||
) -> ExecutionResult:
|
||||
self.calls.append(("execute", {
|
||||
"amount_coin": amount_coin, "from_coin": from_coin, "rail": rail,
|
||||
"idempotency_key": idempotency_key, "memo": memo,
|
||||
"target_usd_cents": target_usd_cents,
|
||||
}))
|
||||
if from_coin.upper() in self.kyc_hold_for_coins:
|
||||
raise KycHoldError(f"mock KYC hold for {from_coin}")
|
||||
fx = self._prices.get(from_coin.upper(), Decimal("1.00"))
|
||||
usd_cents = target_usd_cents or int(amount_coin * fx * 100)
|
||||
ref = f"mock-tx-{uuid4().hex[:12]}"
|
||||
state = "completed" if self.auto_complete else "submitted"
|
||||
self._transfers[ref] = _MockTransfer(
|
||||
provider_ref=ref, amount_usd_cents=usd_cents, rail=rail,
|
||||
state=state, memo=memo,
|
||||
)
|
||||
return ExecutionResult(
|
||||
provider_ref=ref,
|
||||
accepted_usd_cents=usd_cents,
|
||||
fill_price_usd=fx,
|
||||
state=state,
|
||||
rail=rail,
|
||||
memo=memo,
|
||||
raw={"mock": True},
|
||||
)
|
||||
|
||||
async def status(self, provider_ref: str) -> ProviderStatus:
|
||||
self.calls.append(("status", {"provider_ref": provider_ref}))
|
||||
t = self._transfers.get(provider_ref)
|
||||
if not t:
|
||||
raise OfframpError(f"mock: no transfer {provider_ref}")
|
||||
return ProviderStatus(
|
||||
provider_ref=provider_ref,
|
||||
state=t.state, # type: ignore[arg-type]
|
||||
settled_usd_cents=t.amount_usd_cents if t.state == "completed" else 0,
|
||||
)
|
||||
|
||||
# ── Test helpers ────────────────────────────────────────────────────
|
||||
|
||||
def advance(self, provider_ref: str, to_state: str):
|
||||
"""Manually move a transfer forward in state."""
|
||||
if provider_ref in self._transfers:
|
||||
self._transfers[provider_ref].state = to_state
|
||||
|
||||
|
||||
# ── Mock SHKeeper client ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MockPayout:
|
||||
withdraw_id: str
|
||||
tx_hash: Optional[str]
|
||||
fee_coin: Decimal = Decimal("0")
|
||||
raw: Optional[dict] = None
|
||||
|
||||
|
||||
class MockSHKeeperClient:
|
||||
"""In-memory SHKeeperClient substitute for tests — never hits the network."""
|
||||
def __init__(self, *, default_balances: Optional[dict[str, Decimal]] = None):
|
||||
self._balances: dict[str, Decimal] = dict(default_balances or {})
|
||||
self.payouts: list[tuple[str, str, Decimal]] = []
|
||||
|
||||
async def payout(self, *, coin: str, destination: str, amount: Decimal):
|
||||
self.payouts.append((coin, destination, amount))
|
||||
self._balances[coin] = self._balances.get(coin, Decimal("0")) - amount
|
||||
return _MockPayout(
|
||||
withdraw_id=f"mock-shk-{uuid4().hex[:10]}",
|
||||
tx_hash=f"mock-txhash-{uuid4().hex[:16]}",
|
||||
)
|
||||
|
||||
async def balance(self, coin: str):
|
||||
from .shkeeper_client import BalanceResult
|
||||
bal = self._balances.get(coin.upper(), Decimal("0"))
|
||||
return BalanceResult(balance_coin=bal, balance_usd=bal * Decimal("70000"))
|
||||
|
||||
async def withdrawal_status(self, coin: str, withdraw_id: str):
|
||||
return {"status": "confirmed", "txid": f"mock-tx-{withdraw_id}"}
|
||||
127
scripts/workers/crypto_offramp/shkeeper_client.py
Normal file
127
scripts/workers/crypto_offramp/shkeeper_client.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""SHKeeper client — thin wrapper over the self-hosted SHKeeper REST API.
|
||||
|
||||
SHKeeper (https://github.com/vsys-host/shkeeper.io) is our crypto payment
|
||||
gateway. It exposes one REST endpoint per coin at
|
||||
{SHKEEPER_URL}/api/v1/{coin}/...
|
||||
with bearer auth via X-Shkeeper-Api-Key header.
|
||||
|
||||
We use it for two things post-webhook:
|
||||
1. payout() — withdraw coins from the SHKeeper hot wallet to an
|
||||
external address (either a Coinbase Prime deposit
|
||||
address for offramp, or the cold wallet for sweeps).
|
||||
2. balance() — read the current hot-wallet balance for sweep sizing.
|
||||
|
||||
SHKeeper exposes a /payout endpoint; exact shape varies by coin but
|
||||
follows the pattern documented in the project's API README. Confirm
|
||||
shape against the running instance during first real test — we stub
|
||||
with sensible defaults and will iterate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SHKEEPER_URL = os.environ.get("SHKEEPER_URL", "http://127.0.0.1:5000")
|
||||
SHKEEPER_API_KEY = os.environ.get("SHKEEPER_API_KEY", "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PayoutResult:
|
||||
withdraw_id: str
|
||||
tx_hash: Optional[str]
|
||||
fee_coin: Decimal = Decimal("0")
|
||||
raw: dict = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BalanceResult:
|
||||
balance_coin: Decimal
|
||||
balance_usd: Decimal # SHKeeper returns fiat equivalent on balance
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class SHKeeperError(Exception):
|
||||
"""Any SHKeeper API failure."""
|
||||
|
||||
|
||||
class SHKeeperClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
):
|
||||
self.base = base_url or SHKEEPER_URL
|
||||
self.api_key = api_key or SHKEEPER_API_KEY
|
||||
if not self.api_key:
|
||||
logger.warning(
|
||||
"SHKeeperClient: SHKEEPER_API_KEY empty — calls will fail",
|
||||
)
|
||||
|
||||
async def payout(
|
||||
self,
|
||||
*, coin: str, destination: str, amount: Decimal,
|
||||
) -> PayoutResult:
|
||||
"""POST /api/v1/{coin}/payout — withdraw to an external address.
|
||||
|
||||
Request body (per SHKeeper docs):
|
||||
{"destination": "<addr>", "amount": "<decimal>"}
|
||||
Response includes a withdraw_id + eventually a tx_hash.
|
||||
"""
|
||||
path = f"/api/v1/{coin.lower()}/payout"
|
||||
body = {
|
||||
"destination": destination,
|
||||
"amount": str(amount),
|
||||
}
|
||||
resp = await self._request("POST", path, json_body=body)
|
||||
return PayoutResult(
|
||||
withdraw_id=str(resp.get("withdraw_id") or resp.get("id") or ""),
|
||||
tx_hash=resp.get("tx_hash") or resp.get("txid"),
|
||||
fee_coin=Decimal(str(resp.get("fee") or "0")),
|
||||
raw=resp,
|
||||
)
|
||||
|
||||
async def balance(self, coin: str) -> BalanceResult:
|
||||
"""GET /api/v1/{coin}/balance — current hot-wallet balance."""
|
||||
path = f"/api/v1/{coin.lower()}/balance"
|
||||
resp = await self._request("GET", path)
|
||||
return BalanceResult(
|
||||
balance_coin=Decimal(str(resp.get("balance") or "0")),
|
||||
balance_usd=Decimal(str(resp.get("balance_usd") or resp.get("fiat_balance") or "0")),
|
||||
updated_at=resp.get("updated_at"),
|
||||
)
|
||||
|
||||
async def withdrawal_status(self, coin: str, withdraw_id: str) -> dict:
|
||||
"""GET /api/v1/{coin}/payout/{id} — poll for confirmation."""
|
||||
path = f"/api/v1/{coin.lower()}/payout/{withdraw_id}"
|
||||
return await self._request("GET", path)
|
||||
|
||||
async def _request(
|
||||
self, method: str, path: str,
|
||||
*, json_body: Optional[dict] = None,
|
||||
) -> dict:
|
||||
url = f"{self.base}{path}"
|
||||
headers = {
|
||||
"X-Shkeeper-Api-Key": self.api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.request(method, url, headers=headers, json=json_body)
|
||||
except httpx.HTTPError as exc:
|
||||
raise SHKeeperError(f"SHKeeper request failed: {exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise SHKeeperError(
|
||||
f"SHKeeper {method} {path} → {resp.status_code}: {resp.text[:300]}",
|
||||
)
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
474
scripts/workers/crypto_offramp/sizer.py
Normal file
474
scripts/workers/crypto_offramp/sizer.py
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
"""Sizer — compute vendor obligations per order.
|
||||
|
||||
Called by ``crypto_payment_worker`` when transitioning received → sizing.
|
||||
Returns the list of vendor_obligations rows to insert plus the total USD
|
||||
that needs to land at RelayFi (``needed_usd_cents``).
|
||||
|
||||
Two categories of obligation:
|
||||
1. filing_fee — paid via Relay debit card via Playwright flow.
|
||||
Includes a +10% buffer for card-decline retries + slippage.
|
||||
2. commission — paid via Relay ACH 14 days post-delivery. Exact amount.
|
||||
|
||||
The sizer reads order data from the relevant table (compliance_orders,
|
||||
formation_orders, canada_crtc_orders, bundle_orders) + existing
|
||||
config tables (state_filing_fees, sales_agents) + a small static map
|
||||
for vendors that don't live in a table (NECA OCN, BC Registry, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Static vendor fee map (single source of truth for non-table vendors) ──
|
||||
#
|
||||
# These are vendors whose fees are fixed + don't live in a queryable
|
||||
# table. Keep in sync with the COMPLIANCE_SERVICES catalog in
|
||||
# api/src/routes/compliance-orders.ts when their pricing changes.
|
||||
STATIC_VENDOR_FEES_CENTS: dict[str, int] = {
|
||||
"nwra": 12500, # Northwest Registered Agent $125/yr
|
||||
"neca_ocn": 55000, # NECA OCN registration $550 standard
|
||||
"neca_ocn_expedited": 67500, # NECA OCN expedited $675
|
||||
"bc_registry": 27700, # BC Registry incorporation $277 CAD ≈ rounded
|
||||
"cores_frn": 0, # FCC CORES FRN — no filing fee
|
||||
"usac_499_initial": 0, # USAC 499 Initial — no filing fee
|
||||
"usac_499a": 0, # USAC 499-A — no filing fee (contributions separate)
|
||||
"fcc_cpni": 0, # FCC CPNI cert — no filing fee
|
||||
"fcc_rmd": 10000, # FCC RMD — $100 filing fee (effective 2025)
|
||||
"fcc_bdc": 0, # FCC BDC — no filing fee
|
||||
"fcc_stir_shaken": 0, # FCC STIR/SHAKEN — no filing fee
|
||||
"fcc_63_11": 0, # FCC Foreign Carrier Affiliation — no filing fee
|
||||
"fcc_calea_ssi": 0, # CALEA SSI — no filing fee (internal plan)
|
||||
"dc_agent": 0, # DC Agent — our DC agent service has no external fee
|
||||
}
|
||||
|
||||
# Default filing-fee buffer in basis points (1000 bps = 10%)
|
||||
DEFAULT_FILING_BUFFER_BPS = 1000
|
||||
|
||||
# Commission payout happens 14 days post-delivery per commission_ledger
|
||||
# lifecycle (migration 011); reserve the cash at offramp time.
|
||||
COMMISSION_DUE_DAYS = 14
|
||||
|
||||
|
||||
@dataclass
|
||||
class VendorObligation:
|
||||
"""Matches the shape inserted into vendor_obligations."""
|
||||
order_id: str
|
||||
order_type: str
|
||||
obligation_kind: str # 'filing_fee' | 'commission'
|
||||
vendor: str
|
||||
vendor_detail: Optional[str]
|
||||
amount_usd_cents: int
|
||||
fx_buffer_bps: int
|
||||
due_by: Optional[datetime] = None
|
||||
commission_ledger_id: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SizingResult:
|
||||
obligations: list[VendorObligation]
|
||||
filing_total_cents: int # raw filing-fee sum, pre-buffer
|
||||
filing_total_with_buffer_cents: int
|
||||
commission_total_cents: int
|
||||
needed_usd_cents: int # what we need at Relay
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _db_connect():
|
||||
return psycopg2.connect(os.environ.get("DATABASE_URL", ""))
|
||||
|
||||
|
||||
def compute_obligations(
|
||||
*,
|
||||
order_id: str,
|
||||
order_type: str,
|
||||
conn=None,
|
||||
) -> SizingResult:
|
||||
"""Compute vendor obligations for the given order.
|
||||
|
||||
``order_type`` is one of: 'compliance' | 'formation' | 'canada_crtc' | 'bundle'.
|
||||
"""
|
||||
close_after = False
|
||||
if conn is None:
|
||||
conn = _db_connect()
|
||||
close_after = True
|
||||
try:
|
||||
if order_type == "compliance":
|
||||
return _size_compliance(order_id, conn)
|
||||
if order_type == "formation":
|
||||
return _size_formation(order_id, conn)
|
||||
if order_type == "canada_crtc":
|
||||
return _size_canada_crtc(order_id, conn)
|
||||
if order_type == "bundle":
|
||||
return _size_bundle(order_id, conn)
|
||||
logger.warning("sizer: unknown order_type=%s for %s", order_type, order_id)
|
||||
return SizingResult([], 0, 0, 0, 0, ["unknown order_type — zero obligations"])
|
||||
finally:
|
||||
if close_after:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Compliance orders (FCC filings + OCN + DC agent + bundles) ──────────
|
||||
|
||||
def _size_compliance(order_id: str, conn) -> SizingResult:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT co.*, te.id AS entity_id,
|
||||
(co.intake_data->>'expedited')::boolean AS expedited
|
||||
FROM compliance_orders co
|
||||
LEFT JOIN telecom_entities te ON te.id = co.telecom_entity_id
|
||||
WHERE co.order_number = %s
|
||||
""",
|
||||
(order_id,),
|
||||
)
|
||||
order = cur.fetchone()
|
||||
if not order:
|
||||
return SizingResult([], 0, 0, 0, 0, [f"compliance order {order_id} not found"])
|
||||
|
||||
slug = order.get("service_slug") or ""
|
||||
obligations: list[VendorObligation] = []
|
||||
|
||||
# OCN registration has real money to NECA; optional expedited upcharge.
|
||||
if slug == "ocn-registration":
|
||||
fee_key = "neca_ocn_expedited" if order.get("expedited") else "neca_ocn"
|
||||
obligations.append(_filing_vo(
|
||||
order_id, "compliance", "neca_ocn", None,
|
||||
STATIC_VENDOR_FEES_CENTS[fee_key],
|
||||
))
|
||||
|
||||
# Any service that includes NRA registered-agent coverage in its bundle.
|
||||
# Customer opts in via intake_data.include_nwra_ra or similar; read the flag.
|
||||
if (order.get("intake_data") or {}).get("include_nwra_ra"):
|
||||
obligations.append(_filing_vo(
|
||||
order_id, "compliance", "nwra", None,
|
||||
STATIC_VENDOR_FEES_CENTS["nwra"],
|
||||
))
|
||||
|
||||
# FCC 499-family, CPNI, RMD, BDC, STIR/SHAKEN, 63-11, CALEA SSI, DC agent,
|
||||
# compliance checkup, CDR analysis, CORES FRN — no vendor filing fee.
|
||||
# (Our service fee is pure markup; nothing to reserve for vendors.)
|
||||
|
||||
# Commission reserve if a sales agent is attached
|
||||
commission = _compute_commission_obligation(
|
||||
order_id, "compliance", order, slug, conn,
|
||||
)
|
||||
if commission:
|
||||
obligations.append(commission)
|
||||
|
||||
filing = [o for o in obligations if o.obligation_kind == "filing_fee"]
|
||||
comms = [o for o in obligations if o.obligation_kind == "commission"]
|
||||
filing_total = sum(o.amount_usd_cents for o in filing)
|
||||
filing_buf = int(filing_total * (1 + DEFAULT_FILING_BUFFER_BPS / 10000))
|
||||
comm_total = sum(o.amount_usd_cents for o in comms)
|
||||
|
||||
return SizingResult(
|
||||
obligations=obligations,
|
||||
filing_total_cents=filing_total,
|
||||
filing_total_with_buffer_cents=filing_buf,
|
||||
commission_total_cents=comm_total,
|
||||
needed_usd_cents=filing_buf + comm_total,
|
||||
)
|
||||
|
||||
|
||||
# ── Formation orders (LLC / Corp state filings) ─────────────────────────
|
||||
|
||||
def _size_formation(order_id: str, conn) -> SizingResult:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM formation_orders WHERE order_number = %s", (order_id,))
|
||||
order = cur.fetchone()
|
||||
if not order:
|
||||
return SizingResult([], 0, 0, 0, 0, [f"formation order {order_id} not found"])
|
||||
|
||||
state = order.get("state_code") or order.get("state")
|
||||
entity_type = (order.get("entity_type") or "llc").lower()
|
||||
|
||||
obligations: list[VendorObligation] = []
|
||||
if state:
|
||||
# state_filing_fees UNIT CONVENTIONS per column (confirmed by
|
||||
# spot-checking 15+ states against real SOS fee schedules):
|
||||
# llc_formation_fee / corp_formation_fee — CENTS (e.g., 7000 = $70 CA LLC)
|
||||
# foreign_llc_fee / foreign_corp_fee — CENTS
|
||||
# expedited_fee — DOLLARS × 10000
|
||||
# (e.g., 7500000 = $750 CA same-day)
|
||||
# llc_annual_fee / corp_annual_fee — mixed (some rows are franchise-tax
|
||||
# amounts; verify per-state before trust)
|
||||
# name_reservation_fee, franchise_tax_min — DOLLARS × 10000 in most rows
|
||||
#
|
||||
# This sizer only reads formation + foreign + expedited and
|
||||
# normalizes expedited to cents.
|
||||
formation_col = "corp_formation_fee" if entity_type.startswith("corp") else "llc_formation_fee"
|
||||
foreign_col = "foreign_corp_fee" if entity_type.startswith("corp") else "foreign_llc_fee"
|
||||
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {formation_col} AS formation_cents,
|
||||
{foreign_col} AS foreign_cents,
|
||||
expedited_fee AS expedited_raw
|
||||
FROM state_filing_fees
|
||||
WHERE state_code = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(state,),
|
||||
)
|
||||
fee = cur.fetchone()
|
||||
if fee:
|
||||
is_foreign = bool(order.get("foreign_filing") or order.get("is_foreign_entity"))
|
||||
base_cents = int((fee.get("foreign_cents") or 0) if is_foreign else (fee.get("formation_cents") or 0))
|
||||
expedited_cents = 0
|
||||
if order.get("expedited") and fee.get("expedited_raw"):
|
||||
# Divide by 100 to convert (dollars × 10000) → cents
|
||||
expedited_cents = int(fee["expedited_raw"]) // 100
|
||||
total = base_cents + expedited_cents
|
||||
if total > 0:
|
||||
obligations.append(_filing_vo(
|
||||
order_id, "formation", "state_sos", state, total,
|
||||
))
|
||||
|
||||
if order.get("include_ra_service") or order.get("include_registered_agent"):
|
||||
obligations.append(_filing_vo(
|
||||
order_id, "formation", "nwra", state,
|
||||
STATIC_VENDOR_FEES_CENTS["nwra"],
|
||||
))
|
||||
|
||||
commission = _compute_commission_obligation(
|
||||
order_id, "formation", order, f"formation-{entity_type}", conn,
|
||||
)
|
||||
if commission:
|
||||
obligations.append(commission)
|
||||
|
||||
filing = [o for o in obligations if o.obligation_kind == "filing_fee"]
|
||||
comms = [o for o in obligations if o.obligation_kind == "commission"]
|
||||
filing_total = sum(o.amount_usd_cents for o in filing)
|
||||
filing_buf = int(filing_total * (1 + DEFAULT_FILING_BUFFER_BPS / 10000))
|
||||
comm_total = sum(o.amount_usd_cents for o in comms)
|
||||
|
||||
return SizingResult(
|
||||
obligations=obligations,
|
||||
filing_total_cents=filing_total,
|
||||
filing_total_with_buffer_cents=filing_buf,
|
||||
commission_total_cents=comm_total,
|
||||
needed_usd_cents=filing_buf + comm_total,
|
||||
)
|
||||
|
||||
|
||||
# ── Canada CRTC orders ──────────────────────────────────────────────────
|
||||
|
||||
def _size_canada_crtc(order_id: str, conn) -> SizingResult:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM canada_crtc_orders WHERE order_number = %s", (order_id,))
|
||||
order = cur.fetchone()
|
||||
if not order:
|
||||
return SizingResult([], 0, 0, 0, 0, [f"canada_crtc order {order_id} not found"])
|
||||
|
||||
obligations: list[VendorObligation] = []
|
||||
obligations.append(_filing_vo(
|
||||
order_id, "canada_crtc", "bc_registry", "BC",
|
||||
STATIC_VENDOR_FEES_CENTS["bc_registry"],
|
||||
))
|
||||
if order.get("amb_annual_price_cents"):
|
||||
obligations.append(_filing_vo(
|
||||
order_id, "canada_crtc", "amb_mailbox", None,
|
||||
int(order["amb_annual_price_cents"]),
|
||||
))
|
||||
|
||||
commission = _compute_commission_obligation(
|
||||
order_id, "canada_crtc", order, "canada_crtc", conn,
|
||||
)
|
||||
if commission:
|
||||
obligations.append(commission)
|
||||
|
||||
filing = [o for o in obligations if o.obligation_kind == "filing_fee"]
|
||||
comms = [o for o in obligations if o.obligation_kind == "commission"]
|
||||
filing_total = sum(o.amount_usd_cents for o in filing)
|
||||
filing_buf = int(filing_total * (1 + DEFAULT_FILING_BUFFER_BPS / 10000))
|
||||
comm_total = sum(o.amount_usd_cents for o in comms)
|
||||
|
||||
return SizingResult(
|
||||
obligations=obligations,
|
||||
filing_total_cents=filing_total,
|
||||
filing_total_with_buffer_cents=filing_buf,
|
||||
commission_total_cents=comm_total,
|
||||
needed_usd_cents=filing_buf + comm_total,
|
||||
)
|
||||
|
||||
|
||||
# ── Bundle orders (sum of sub-orders) ───────────────────────────────────
|
||||
|
||||
def _size_bundle(order_id: str, conn) -> SizingResult:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM bundle_orders WHERE order_number = %s", (order_id,))
|
||||
order = cur.fetchone()
|
||||
if not order:
|
||||
return SizingResult([], 0, 0, 0, 0, [f"bundle order {order_id} not found"])
|
||||
|
||||
# bundle_orders.child_order_numbers is a TEXT[] of the sub-order numbers
|
||||
# (verify schema during implementation — may be JSONB with types).
|
||||
sub_ids = order.get("child_order_numbers") or []
|
||||
sub_types = order.get("child_order_types") or []
|
||||
|
||||
all_obligations: list[VendorObligation] = []
|
||||
for sub_id, sub_type in zip(sub_ids, sub_types):
|
||||
sub_res = compute_obligations(order_id=sub_id, order_type=sub_type, conn=conn)
|
||||
all_obligations.extend(sub_res.obligations)
|
||||
|
||||
# Bundle-level commission (override the per-sub commissions if present)
|
||||
commission = _compute_commission_obligation(
|
||||
order_id, "bundle", order, "bundle", conn,
|
||||
)
|
||||
if commission:
|
||||
# Drop sub-level commissions; keep only the bundle-level one
|
||||
all_obligations = [o for o in all_obligations if o.obligation_kind != "commission"]
|
||||
all_obligations.append(commission)
|
||||
|
||||
filing = [o for o in all_obligations if o.obligation_kind == "filing_fee"]
|
||||
comms = [o for o in all_obligations if o.obligation_kind == "commission"]
|
||||
filing_total = sum(o.amount_usd_cents for o in filing)
|
||||
filing_buf = int(filing_total * (1 + DEFAULT_FILING_BUFFER_BPS / 10000))
|
||||
comm_total = sum(o.amount_usd_cents for o in comms)
|
||||
|
||||
return SizingResult(
|
||||
obligations=all_obligations,
|
||||
filing_total_cents=filing_total,
|
||||
filing_total_with_buffer_cents=filing_buf,
|
||||
commission_total_cents=comm_total,
|
||||
needed_usd_cents=filing_buf + comm_total,
|
||||
)
|
||||
|
||||
|
||||
# ── Commission obligation (shared across all order types) ───────────────
|
||||
|
||||
def _compute_commission_obligation(
|
||||
order_id: str, order_type: str, order: dict, slug_for_override: str, conn,
|
||||
) -> Optional[VendorObligation]:
|
||||
"""If the order is attributed to a sales agent, reserve their commission."""
|
||||
agent_id = order.get("sales_agent_id") or order.get("referral_agent_id")
|
||||
if not agent_id:
|
||||
return None
|
||||
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, commission_type, commission_default_cents,
|
||||
commission_pct, commission_overrides, active
|
||||
FROM sales_agents
|
||||
WHERE id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
agent = cur.fetchone()
|
||||
if not agent or not agent.get("active"):
|
||||
return None
|
||||
|
||||
overrides = agent.get("commission_overrides") or {}
|
||||
if isinstance(overrides, str):
|
||||
try: overrides = json.loads(overrides)
|
||||
except (TypeError, ValueError): overrides = {}
|
||||
override = overrides.get(slug_for_override) or {}
|
||||
ctype = override.get("type") or agent.get("commission_type", "flat")
|
||||
|
||||
amount_cents = 0
|
||||
if ctype == "flat":
|
||||
amount_cents = int(
|
||||
override.get("flat_cents")
|
||||
or agent.get("commission_default_cents")
|
||||
or 0
|
||||
)
|
||||
elif ctype == "percent":
|
||||
pct = float(override.get("pct") or agent.get("commission_pct") or 0)
|
||||
# Use subtotal (pre-surcharge) as the basis; fall back to service_fee_cents
|
||||
subtotal = int(
|
||||
order.get("subtotal_cents")
|
||||
or order.get("service_fee_cents")
|
||||
or order.get("total_cents")
|
||||
or 0
|
||||
)
|
||||
amount_cents = int(subtotal * pct / 100)
|
||||
|
||||
if amount_cents <= 0:
|
||||
return None
|
||||
|
||||
# Due 14 days post-delivery. If we don't know delivery date, estimate
|
||||
# from created_at + 7 days (typical service turnaround) + 14.
|
||||
created = order.get("created_at") or datetime.utcnow()
|
||||
due_by = created + timedelta(days=7 + COMMISSION_DUE_DAYS)
|
||||
|
||||
return VendorObligation(
|
||||
order_id=order_id,
|
||||
order_type=order_type,
|
||||
obligation_kind="commission",
|
||||
vendor="agent_commission",
|
||||
vendor_detail=str(agent_id),
|
||||
amount_usd_cents=amount_cents,
|
||||
fx_buffer_bps=0, # commissions are exact, no buffer
|
||||
due_by=due_by,
|
||||
)
|
||||
|
||||
|
||||
# ── Constructors ────────────────────────────────────────────────────────
|
||||
|
||||
def _filing_vo(
|
||||
order_id: str, order_type: str, vendor: str,
|
||||
vendor_detail: Optional[str], amount_cents: int,
|
||||
) -> VendorObligation:
|
||||
return VendorObligation(
|
||||
order_id=order_id,
|
||||
order_type=order_type,
|
||||
obligation_kind="filing_fee",
|
||||
vendor=vendor,
|
||||
vendor_detail=vendor_detail,
|
||||
amount_usd_cents=amount_cents,
|
||||
fx_buffer_bps=DEFAULT_FILING_BUFFER_BPS,
|
||||
)
|
||||
|
||||
|
||||
# ── Persistence helpers ─────────────────────────────────────────────────
|
||||
|
||||
def persist_obligations(order_id: str, result: SizingResult, conn=None) -> None:
|
||||
"""Upsert the SizingResult into vendor_obligations.
|
||||
|
||||
Idempotent: deletes existing pending rows for this order then re-inserts.
|
||||
We never overwrite rows already marked reserved/paid/waived.
|
||||
"""
|
||||
close_after = False
|
||||
if conn is None:
|
||||
conn = _db_connect()
|
||||
close_after = True
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM vendor_obligations "
|
||||
"WHERE order_id = %s AND status = 'pending'",
|
||||
(order_id,),
|
||||
)
|
||||
for o in result.obligations:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO vendor_obligations (
|
||||
order_id, order_type, obligation_kind, vendor, vendor_detail,
|
||||
amount_usd_cents, fx_buffer_bps, due_by, status,
|
||||
commission_ledger_id
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,'pending',%s)
|
||||
""",
|
||||
(
|
||||
o.order_id, o.order_type, o.obligation_kind,
|
||||
o.vendor, o.vendor_detail,
|
||||
o.amount_usd_cents, o.fx_buffer_bps, o.due_by,
|
||||
o.commission_ledger_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
if close_after:
|
||||
conn.close()
|
||||
Loading…
Add table
Add a link
Reference in a new issue