feat: integrate Commerce/Square billing (checkout, webhooks, unified API)

- Add checkout/capture endpoints via Commerce → Square payment flow
- Add order.completed/cancelled and payment.paid/failed/refunded webhook handlers
- Update Commerce API paths (/api/v1/customers → /api/v1/user, /subscribe, etc.)
- Migrate unified billing from Stripe references to Square (customer accounts)
- Add customer_id to usage sync worker for proper metered billing
- Export UnifiedBillingClient, UnifiedUser from billing module
- Add Commerce tier-to-plan slug mapping (bootnode-free/payg/growth/enterprise)
This commit is contained in:
2026-02-12 10:20:42 -08:00
parent 284eb6906c
commit 7acebc977d
7 changed files with 562 additions and 253 deletions
+160 -137
View File
@@ -1,16 +1,18 @@
"""Billing API - Usage tracking and subscription management."""
"""Billing API - Usage tracking, subscription management, and Commerce integration."""
import json
import uuid
from datetime import date, datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from bootnode.api.deps import DbDep, ProjectDep
from bootnode.config import get_settings
from bootnode.core.billing import (
TIER_LIMITS,
CommerceError,
Invoice,
PricingTier,
UsageSummary,
@@ -135,8 +137,41 @@ class LimitsCheckResponse(BaseModel):
rate_limit_per_second: int
class CheckoutRequest(BaseModel):
"""Commerce Checkout request (Square payment via Commerce)."""
tier: PricingTier
nonce: str | None = None # Square payment nonce
source_id: str | None = None # Square card on file
success_url: str | None = None
cancel_url: str | None = None
class CheckoutResponse(BaseModel):
"""Commerce Checkout response."""
order_id: str | None = None
checkout_url: str | None = None
authorization_id: str | None = None
class CaptureRequest(BaseModel):
"""Capture an authorized payment."""
pass
class SyncStatusResponse(BaseModel):
"""Usage sync status response."""
running: bool
interval_seconds: int
last_sync: str | None
lock_held: bool
# =============================================================================
# Endpoints
# Usage & Subscription Endpoints (unchanged — payment-agnostic)
# =============================================================================
@@ -197,7 +232,7 @@ async def upgrade_subscription(
) -> Subscription:
"""Upgrade subscription to a new tier.
For paid tiers, a hanzo_subscription_id from Hanzo Commerce is required.
For paid tiers, a hanzo_subscription_id from Commerce is required.
"""
current = await billing_service.get_or_create_subscription(project.id, db)
current_tier = PricingTier(current.tier)
@@ -321,56 +356,107 @@ async def check_limits(
# =============================================================================
# Hanzo Commerce Integration
# Commerce Checkout (Square payments via Commerce API)
# =============================================================================
class CommerceInvoiceResponse(BaseModel):
"""Commerce invoice response."""
id: str
customer_id: str
subscription_id: str | None
status: str
amount_due: int
amount_paid: int
currency: str
period_start: datetime | None
period_end: datetime | None
paid_at: datetime | None
hosted_invoice_url: str | None
pdf_url: str | None
created_at: datetime
TIER_TO_PLAN_SLUG: dict[PricingTier, str] = {
PricingTier.FREE: "bootnode-free",
PricingTier.PAY_AS_YOU_GO: "bootnode-payg",
PricingTier.GROWTH: "bootnode-growth",
PricingTier.ENTERPRISE: "bootnode-enterprise",
}
class SyncStatusResponse(BaseModel):
"""Usage sync status response."""
@router.post("/checkout", response_model=CheckoutResponse)
async def create_checkout(
request: CheckoutRequest,
project: ProjectDep,
db: DbDep,
) -> CheckoutResponse:
"""Create a Commerce checkout session (Square payment).
running: bool
interval_seconds: int
last_sync: str | None
lock_held: bool
Returns an order_id and checkout_url for the user to complete payment.
After payment, Commerce webhooks handle provisioning.
"""
settings = get_settings()
if request.tier in (PricingTier.FREE, PricingTier.ENTERPRISE):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Checkout not available for {request.tier.value} tier",
)
subscription = await billing_service.get_or_create_subscription(project.id, db)
customer_id = subscription.hanzo_customer_id
if not customer_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No billing account found. Use /billing/account to set up billing first.",
)
plan_slug = TIER_TO_PLAN_SLUG.get(request.tier, "bootnode-payg")
success_url = request.success_url or f"{settings.frontend_url}/billing?checkout=success"
cancel_url = request.cancel_url or f"{settings.frontend_url}/billing?checkout=cancelled"
try:
result = await commerce_client.create_checkout(
customer_id=customer_id,
plan_slug=plan_slug,
project_id=project.id,
nonce=request.nonce,
source_id=request.source_id,
success_url=success_url,
cancel_url=cancel_url,
)
return CheckoutResponse(
order_id=result.get("order_id"),
checkout_url=result.get("checkout_url"),
authorization_id=result.get("authorization_id"),
)
except CommerceError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message,
)
@router.post("/checkout/capture/{order_id}")
async def capture_checkout(
order_id: str,
) -> dict:
"""Capture an authorized Commerce payment."""
try:
result = await commerce_client.capture_checkout(order_id)
return result
except CommerceError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message,
)
# =============================================================================
# Commerce Webhooks
# =============================================================================
@router.post("/webhooks/commerce")
async def commerce_webhook(request: Request) -> dict:
"""
Handle Hanzo Commerce webhooks.
"""Handle Hanzo Commerce webhooks.
Processes subscription lifecycle events, invoice events, etc.
Verifies HMAC-SHA256 signature before processing.
Verifies HMAC signature and processes subscription/order/payment events.
"""
payload = await request.body()
signature = request.headers.get("X-Commerce-Signature", "")
# Verify signature
if not webhook_handler.verify_signature(payload, signature):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid webhook signature",
)
# Parse and handle event
try:
event = json.loads(payload)
except json.JSONDecodeError:
@@ -383,66 +469,25 @@ async def commerce_webhook(request: Request) -> dict:
return {"received": True, **result}
@router.get("/commerce/invoices", response_model=list[CommerceInvoiceResponse])
async def get_commerce_invoices(
project: ProjectDep,
db: DbDep,
limit: int = 10,
) -> list[CommerceInvoiceResponse]:
"""
Get invoices from Hanzo Commerce.
Returns invoices for the project's Commerce customer.
"""
subscription = await billing_service.get_subscription(project.id, db)
if not subscription or not subscription.hanzo_customer_id:
return []
try:
invoices = await commerce_client.get_customer_invoices(
subscription.hanzo_customer_id,
limit=limit,
)
return [
CommerceInvoiceResponse(
id=inv.id,
customer_id=inv.customer_id,
subscription_id=inv.subscription_id,
status=inv.status.value,
amount_due=inv.amount_due,
amount_paid=inv.amount_paid,
currency=inv.currency,
period_start=inv.period_start,
period_end=inv.period_end,
paid_at=inv.paid_at,
hosted_invoice_url=inv.hosted_invoice_url,
pdf_url=inv.pdf_url,
created_at=inv.created_at,
)
for inv in invoices
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to fetch invoices from Commerce: {str(e)}",
)
# =============================================================================
# Usage Sync (to Commerce for metered billing)
# =============================================================================
@router.post("/commerce/sync")
@router.post("/sync")
async def sync_usage(
project: ProjectDep,
) -> dict:
"""
Manually sync usage to Hanzo Commerce.
"""Manually sync usage to Commerce.
Useful before subscription changes to ensure all usage is reported.
Reports accumulated compute units to Commerce for metered billing.
Commerce handles invoicing via Square.
"""
result = await usage_sync_worker.sync_project(project.id)
return result
@router.get("/commerce/sync/status", response_model=SyncStatusResponse)
@router.get("/sync/status", response_model=SyncStatusResponse)
async def get_sync_status() -> SyncStatusResponse:
"""Get usage sync worker status."""
status_data = await usage_sync_worker.get_status()
@@ -450,26 +495,22 @@ async def get_sync_status() -> SyncStatusResponse:
# =============================================================================
# Unified IAM + Commerce Endpoints
# Unified IAM + Commerce Billing
# =============================================================================
from bootnode.core.billing.unified import get_unified_billing_client
from bootnode.core.iam import IAMUser, get_current_user
from bootnode.core.billing.unified import (
UnifiedUser,
get_unified_billing_client,
)
from fastapi import Depends
class UnifiedUserResponse(BaseModel):
"""Unified user with IAM and Commerce data."""
"""Unified user with IAM and Commerce billing data."""
iam_id: str
email: str
name: str
org: str
commerce_customer_id: str | None
stripe_customer_id: str | None
square_customer_id: str | None
has_payment_method: bool
default_payment_method: str | None
@@ -490,23 +531,22 @@ class PaymentMethodResponse(BaseModel):
async def get_billing_account(
user: IAMUser = Depends(get_current_user),
) -> UnifiedUserResponse:
"""Get unified billing account for authenticated user.
"""Get billing account for authenticated user.
Auto-creates Commerce customer if not exists, linking to IAM user.
This is the main entry point for users accessing billing.
Links IAM user to Commerce customer. Creates Commerce customer if needed.
"""
client = get_unified_billing_client()
unified = await client.get_or_create_customer(user)
unified = get_unified_billing_client()
unified_user = await unified.get_or_create_customer(user)
return UnifiedUserResponse(
iam_id=unified.iam_id,
email=unified.email,
name=unified.name,
org=unified.org,
commerce_customer_id=unified.commerce_customer_id,
stripe_customer_id=unified.stripe_customer_id,
has_payment_method=unified.has_payment_method,
default_payment_method=unified.default_payment_method,
iam_id=unified_user.iam_id,
email=unified_user.email,
name=unified_user.name,
org=unified_user.org,
commerce_customer_id=unified_user.commerce_customer_id,
square_customer_id=unified_user.square_customer_id,
has_payment_method=unified_user.has_payment_method,
default_payment_method=unified_user.default_payment_method,
)
@@ -514,36 +554,35 @@ async def get_billing_account(
async def get_account_subscriptions(
user: IAMUser = Depends(get_current_user),
) -> list[dict]:
"""Get all subscriptions for authenticated user across Commerce."""
client = get_unified_billing_client()
return await client.get_customer_subscriptions(user)
"""Get all Commerce subscriptions for authenticated user."""
unified = get_unified_billing_client()
return await unified.get_customer_subscriptions(user)
@router.get("/account/invoices")
async def get_account_invoices(
user: IAMUser = Depends(get_current_user),
) -> list[dict]:
"""Get all invoices for authenticated user."""
client = get_unified_billing_client()
return await client.get_customer_invoices(user)
"""Get all Commerce invoices for authenticated user."""
unified = get_unified_billing_client()
return await unified.get_customer_invoices(user)
@router.get("/account/payment-methods", response_model=list[PaymentMethodResponse])
async def get_account_payment_methods(
user: IAMUser = Depends(get_current_user),
) -> list[PaymentMethodResponse]:
"""Get payment methods for authenticated user."""
client = get_unified_billing_client()
methods = await client.get_customer_payment_methods(user)
"""Get payment methods for authenticated user (Square cards via Commerce)."""
unified = get_unified_billing_client()
methods = await unified.get_customer_payment_methods(user)
return [
PaymentMethodResponse(
id=m.get("id", ""),
type=m.get("type", "card"),
brand=m.get("card", {}).get("brand"),
last4=m.get("card", {}).get("last4"),
exp_month=m.get("card", {}).get("exp_month"),
exp_year=m.get("card", {}).get("exp_year"),
brand=m.get("card", {}).get("brand") if isinstance(m.get("card"), dict) else None,
last4=m.get("card", {}).get("last4") if isinstance(m.get("card"), dict) else None,
exp_month=m.get("card", {}).get("exp_month") if isinstance(m.get("card"), dict) else None,
exp_year=m.get("card", {}).get("exp_year") if isinstance(m.get("card"), dict) else None,
is_default=m.get("is_default", False),
)
for m in methods
@@ -556,24 +595,8 @@ async def sync_iam_to_commerce(
) -> dict:
"""Sync IAM user data to Commerce customer.
Updates Commerce with latest IAM profile data.
Updates Commerce customer with latest IAM profile data.
"""
client = get_unified_billing_client()
result = await client.sync_iam_to_commerce(user)
return {"synced": True, "customer": result}
@router.post("/account/subscribe")
async def create_account_subscription(
plan_id: str,
project: ProjectDep,
user: IAMUser = Depends(get_current_user),
) -> dict:
"""Create subscription for authenticated user.
Links the subscription to both the IAM user (via Commerce)
and the bootnode project.
"""
client = get_unified_billing_client()
result = await client.create_subscription(user, plan_id, project.id)
return result
unified = get_unified_billing_client()
result = await unified.sync_iam_to_commerce(user)
return {"synced": True, "result": result}
+8 -3
View File
@@ -5,7 +5,8 @@ Exports:
- Pricing tiers and limits
- Real-time usage tracking (Redis)
- Billing service for subscriptions
- Hanzo Commerce integration
- Hanzo Commerce integration (Square-based payments)
- Unified IAM + Commerce integration
"""
from bootnode.core.billing.commerce import (
@@ -77,7 +78,7 @@ __all__ = [
"billing_service",
"UsageSummary",
"Invoice",
# Hanzo Commerce
# Hanzo Commerce (Square-based payments)
"HanzoCommerceClient",
"commerce_client",
"CommerceError",
@@ -95,8 +96,12 @@ __all__ = [
# Usage Sync
"UsageSyncWorker",
"usage_sync_worker",
# Webhooks
# Commerce Webhooks
"CommerceWebhookHandler",
"webhook_handler",
"WebhookVerificationError",
# Unified IAM + Commerce
"UnifiedBillingClient",
"UnifiedUser",
"get_unified_billing_client",
]
+190 -56
View File
@@ -1,10 +1,12 @@
"""Hanzo Commerce client for subscription billing.
Integrates with Hanzo Commerce API (Stripe-based) for:
Integrates with Hanzo Commerce API (Square-based) for:
- Customer management
- Subscription lifecycle
- Checkout (Square payment authorization/capture)
- Usage metering (PAYG)
- Invoice retrieval
- Order management
"""
from datetime import datetime
@@ -38,12 +40,12 @@ class CommerceError(Exception):
class HanzoCommerceClient:
"""Client for Hanzo Commerce billing API."""
"""Client for Hanzo Commerce billing API (Square backend)."""
def __init__(self) -> None:
settings = get_settings()
self.base_url = settings.hanzo_commerce_url
self.api_key = settings.hanzo_commerce_api_key
self.base_url = settings.commerce_url
self.api_key = settings.commerce_api_key
self._timeout = httpx.Timeout(30.0)
def _headers(self) -> dict[str, str]:
@@ -108,13 +110,10 @@ class HanzoCommerceClient:
name: str,
org: str = "hanzo",
) -> str:
"""
Create customer in Hanzo Commerce.
Returns customer_id.
"""
"""Create customer in Hanzo Commerce. Returns customer_id."""
data = await self._request(
"POST",
"/api/v1/customers",
"/api/v1/user",
json={
"email": email,
"name": name,
@@ -137,7 +136,7 @@ class HanzoCommerceClient:
async def get_customer(self, customer_id: str) -> Customer:
"""Get customer by ID."""
data = await self._request("GET", f"/api/v1/customers/{customer_id}")
data = await self._request("GET", f"/api/v1/user/{customer_id}")
return Customer(
id=data["id"],
email=data["email"],
@@ -156,7 +155,7 @@ class HanzoCommerceClient:
try:
data = await self._request(
"GET",
"/api/v1/customers",
"/api/v1/user",
params={"email": email},
)
customers = data.get("data", [])
@@ -193,7 +192,7 @@ class HanzoCommerceClient:
data = await self._request(
"PATCH",
f"/api/v1/customers/{customer_id}",
f"/api/v1/user/{customer_id}",
json=update_data,
)
return Customer(
@@ -204,6 +203,143 @@ class HanzoCommerceClient:
metadata=data.get("metadata", {}),
)
# =========================================================================
# Checkout (Square payment via Commerce)
# =========================================================================
async def create_checkout(
self,
customer_id: str,
plan_slug: str,
project_id: UUID,
nonce: str | None = None,
source_id: str | None = None,
success_url: str | None = None,
cancel_url: str | None = None,
) -> dict[str, Any]:
"""Authorize a payment via Commerce (Square).
Args:
customer_id: Commerce customer ID
plan_slug: Plan to subscribe to (e.g. bootnode-payg)
project_id: Bootnode project ID
nonce: Square payment nonce from Web Payments SDK
source_id: Square source ID (card on file)
success_url: Redirect URL on success
cancel_url: Redirect URL on cancel
Returns:
Dict with order_id, authorization_id, and checkout_url (if hosted)
"""
payload: dict[str, Any] = {
"customer_id": customer_id,
"plan_slug": plan_slug,
"metadata": {
"project_id": str(project_id),
"source": "bootnode",
},
}
if nonce:
payload["nonce"] = nonce
if source_id:
payload["source_id"] = source_id
if success_url:
payload["success_url"] = success_url
if cancel_url:
payload["cancel_url"] = cancel_url
data = await self._request(
"POST",
"/api/v1/checkout/authorize",
json=payload,
)
logger.info(
"Checkout authorized via Commerce",
order_id=data.get("order_id"),
customer_id=customer_id,
plan_slug=plan_slug,
)
return data
async def capture_checkout(self, order_id: str) -> dict[str, Any]:
"""Capture an authorized payment.
Args:
order_id: Commerce order ID from create_checkout
Returns:
Dict with capture result (order status, payment details)
"""
data = await self._request(
"POST",
f"/api/v1/checkout/capture/{order_id}",
)
logger.info("Checkout captured", order_id=order_id)
return data
async def charge(
self,
customer_id: str,
amount_cents: int,
currency: str = "usd",
description: str = "",
metadata: dict | None = None,
) -> dict[str, Any]:
"""Single-step charge (authorize + capture).
Args:
customer_id: Commerce customer ID
amount_cents: Amount in cents
currency: Currency code
description: Charge description
metadata: Additional metadata
Returns:
Dict with order_id, payment details
"""
payload: dict[str, Any] = {
"customer_id": customer_id,
"amount": amount_cents,
"currency": currency,
}
if description:
payload["description"] = description
if metadata:
payload["metadata"] = metadata
return await self._request(
"POST",
"/api/v1/checkout/charge",
json=payload,
)
# =========================================================================
# Order Management
# =========================================================================
async def get_user_orders(
self,
customer_id: str,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Get orders for a customer.
Args:
customer_id: Commerce customer ID
limit: Max orders to return
Returns:
List of order dicts
"""
data = await self._request(
"GET",
f"/api/v1/user/{customer_id}/orders",
params={"limit": limit},
)
return data.get("data", [])
# =========================================================================
# Subscription Management
# =========================================================================
@@ -214,13 +350,13 @@ class HanzoCommerceClient:
plan_slug: str,
project_id: UUID,
) -> Subscription:
"""
Create subscription for a plan.
Plans: bootnode-free, bootnode-payg, bootnode-enterprise
"""Create subscription for a plan.
Plans: bootnode-free, bootnode-payg, bootnode-growth, bootnode-enterprise
"""
data = await self._request(
"POST",
"/api/v1/subscriptions",
"/api/v1/subscribe",
json={
"customer_id": customer_id,
"plan_slug": plan_slug,
@@ -254,7 +390,7 @@ class HanzoCommerceClient:
async def get_subscription(self, subscription_id: str) -> Subscription:
"""Get subscription by ID."""
data = await self._request("GET", f"/api/v1/subscriptions/{subscription_id}")
data = await self._request("GET", f"/api/v1/subscribe/{subscription_id}")
return Subscription(
id=data["id"],
customer_id=data["customer_id"],
@@ -275,18 +411,18 @@ class HanzoCommerceClient:
async def get_subscriptions_by_customer(
self, customer_id: str
) -> list[Subscription]:
"""Get all subscriptions for a customer."""
"""Get all subscriptions for a customer (via user orders endpoint)."""
data = await self._request(
"GET",
"/api/v1/subscriptions",
params={"customer_id": customer_id},
f"/api/v1/user/{customer_id}/orders",
params={"type": "subscription"},
)
return [
Subscription(
id=s["id"],
customer_id=s["customer_id"],
plan_slug=s["plan_slug"],
status=SubscriptionStatus(s["status"]),
customer_id=s.get("customer_id", customer_id),
plan_slug=s.get("plan_slug", ""),
status=SubscriptionStatus(s.get("status", "active")),
current_period_start=datetime.fromisoformat(s["current_period_start"]),
current_period_end=datetime.fromisoformat(s["current_period_end"]),
cancel_at_period_end=s.get("cancel_at_period_end", False),
@@ -304,7 +440,7 @@ class HanzoCommerceClient:
"""Upgrade or downgrade subscription."""
data = await self._request(
"PATCH",
f"/api/v1/subscriptions/{subscription_id}",
f"/api/v1/subscribe/{subscription_id}",
json={"plan_slug": plan_slug},
)
@@ -331,13 +467,14 @@ class HanzoCommerceClient:
subscription_id: str,
immediately: bool = False,
) -> bool:
"""
Cancel subscription.
"""Cancel subscription.
Commerce uses DELETE /api/v1/subscribe/:id.
If immediately=False, cancels at end of billing period.
"""
data = await self._request(
"POST",
f"/api/v1/subscriptions/{subscription_id}/cancel",
"DELETE",
f"/api/v1/subscribe/{subscription_id}",
json={"immediately": immediately},
)
@@ -348,24 +485,6 @@ class HanzoCommerceClient:
)
return data.get("cancelled", True)
async def reactivate_subscription(self, subscription_id: str) -> Subscription:
"""Reactivate a cancelled subscription (if not yet expired)."""
data = await self._request(
"POST",
f"/api/v1/subscriptions/{subscription_id}/reactivate",
)
return Subscription(
id=data["id"],
customer_id=data["customer_id"],
plan_slug=data["plan_slug"],
status=SubscriptionStatus(data["status"]),
current_period_start=datetime.fromisoformat(data["current_period_start"]),
current_period_end=datetime.fromisoformat(data["current_period_end"]),
cancel_at_period_end=data.get("cancel_at_period_end", False),
metadata=data.get("metadata", {}),
created_at=datetime.fromisoformat(data["created_at"]),
)
# =========================================================================
# Usage Metering (for PAYG)
# =========================================================================
@@ -377,10 +496,13 @@ class HanzoCommerceClient:
timestamp: datetime,
idempotency_key: str | None = None,
) -> None:
"""Report metered usage for PAYG billing."""
"""Report metered usage for PAYG billing.
Commerce handles metering internally and creates Square invoices.
"""
await self._request(
"POST",
f"/api/v1/subscriptions/{subscription_id}/usage",
f"/api/v1/subscribe/{subscription_id}/usage",
json={
"quantity": compute_units,
"timestamp": timestamp.isoformat(),
@@ -410,7 +532,7 @@ class HanzoCommerceClient:
return await self._request(
"GET",
f"/api/v1/subscriptions/{subscription_id}/usage",
f"/api/v1/subscribe/{subscription_id}/usage",
params=params or None,
)
@@ -423,11 +545,11 @@ class HanzoCommerceClient:
customer_id: str,
limit: int = 10,
) -> list[Invoice]:
"""Get customer invoices."""
"""Get customer invoices (mapped from Commerce orders)."""
data = await self._request(
"GET",
"/api/v1/invoices",
params={"customer_id": customer_id, "limit": limit},
f"/api/v1/user/{customer_id}/orders",
params={"limit": limit},
)
return [
Invoice(
@@ -461,8 +583,8 @@ class HanzoCommerceClient:
]
async def get_invoice(self, invoice_id: str) -> Invoice:
"""Get specific invoice."""
inv = await self._request("GET", f"/api/v1/invoices/{invoice_id}")
"""Get specific invoice (mapped from Commerce order)."""
inv = await self._request("GET", f"/api/v1/order/{invoice_id}")
return Invoice(
id=inv["id"],
customer_id=inv["customer_id"],
@@ -494,8 +616,8 @@ class HanzoCommerceClient:
try:
inv = await self._request(
"GET",
"/api/v1/invoices/upcoming",
params={"customer_id": customer_id},
f"/api/v1/user/{customer_id}/orders",
params={"status": "draft", "limit": 1},
)
return Invoice(
id=inv.get("id", "upcoming"),
@@ -522,6 +644,18 @@ class HanzoCommerceClient:
return None
raise
# =========================================================================
# Payment Methods (via Commerce → Square)
# =========================================================================
async def get_payment_methods(self, customer_id: str) -> list[dict[str, Any]]:
"""Get payment methods for a customer (Square cards on file)."""
data = await self._request(
"GET",
f"/api/v1/user/{customer_id}/paymentmethods",
)
return data.get("data", [])
# Global singleton
commerce_client = HanzoCommerceClient()
+27 -23
View File
@@ -1,7 +1,7 @@
"""Usage sync worker for Hanzo Commerce.
"""Usage sync worker for Commerce billing.
Periodically syncs compute unit usage from Redis to Hanzo Commerce
for PAYG (pay-as-you-go) billing.
for PAYG (pay-as-you-go) metered billing. Commerce handles invoicing via Square.
"""
import asyncio
@@ -33,8 +33,7 @@ class UsageSyncWorker:
"""Periodically sync usage from Redis to Hanzo Commerce."""
def __init__(self, interval_seconds: int = 3600) -> None:
"""
Initialize sync worker.
"""Initialize sync worker.
Args:
interval_seconds: Sync interval (default: 1 hour)
@@ -59,19 +58,23 @@ class UsageSyncWorker:
async def _get_payg_subscriptions(
self, db: AsyncSession
) -> list[tuple[uuid.UUID, str]]:
"""
Get all active PAYG subscriptions.
) -> list[tuple[uuid.UUID, str, str]]:
"""Get all active PAYG subscriptions.
Returns list of (project_id, hanzo_subscription_id) tuples.
Returns list of (project_id, hanzo_subscription_id, hanzo_customer_id) tuples.
"""
result = await db.execute(
select(Subscription.project_id, Subscription.hanzo_subscription_id).where(
select(
Subscription.project_id,
Subscription.hanzo_subscription_id,
Subscription.hanzo_customer_id,
).where(
Subscription.tier == PricingTier.PAY_AS_YOU_GO.value,
Subscription.hanzo_subscription_id.isnot(None),
Subscription.hanzo_customer_id.isnot(None),
)
)
return [(row[0], row[1]) for row in result.all()]
return [(row[0], row[1], row[2]) for row in result.all()]
async def _get_unsync_usage(self, project_id: uuid.UUID) -> int:
"""Get compute units accumulated since last sync."""
@@ -93,11 +96,11 @@ class UsageSyncWorker:
self,
project_id: uuid.UUID,
subscription_id: str,
customer_id: str,
) -> dict[str, Any]:
"""
Sync usage for a single project.
"""Sync usage for a single project.
Returns sync result dict.
Reports compute units to Commerce, which handles metered invoicing via Square.
"""
result = {
"project_id": str(project_id),
@@ -121,7 +124,7 @@ class UsageSyncWorker:
sync_time = datetime.now(UTC)
idempotency_key = f"{project_id}:{sync_time.strftime('%Y-%m-%d-%H')}"
# Report to Commerce
# Report to Commerce (uses subscription_id, Commerce handles Square invoicing)
await commerce_client.report_usage(
subscription_id=subscription_id,
compute_units=compute_units,
@@ -158,8 +161,7 @@ class UsageSyncWorker:
return result
async def sync_all_projects(self) -> dict[str, Any]:
"""
Sync usage for all active PAYG subscriptions.
"""Sync usage for all active PAYG subscriptions.
Returns summary of sync operation.
"""
@@ -184,8 +186,8 @@ class UsageSyncWorker:
subscriptions = await self._get_payg_subscriptions(db)
summary["total_projects"] = len(subscriptions)
for project_id, subscription_id in subscriptions:
result = await self._sync_project(project_id, subscription_id)
for project_id, subscription_id, customer_id in subscriptions:
result = await self._sync_project(project_id, subscription_id, customer_id)
if result["synced"]:
summary["synced"] += 1
@@ -217,27 +219,29 @@ class UsageSyncWorker:
return summary
async def sync_project(self, project_id: uuid.UUID) -> dict[str, Any]:
"""
Sync usage for a specific project immediately.
"""Sync usage for a specific project immediately.
Useful for on-demand sync before subscription changes.
"""
async with async_session() as db:
result = await db.execute(
select(Subscription.hanzo_subscription_id).where(
select(
Subscription.hanzo_subscription_id,
Subscription.hanzo_customer_id,
).where(
Subscription.project_id == project_id
)
)
row = result.first()
if not row or not row[0]:
if not row or not row[0] or not row[1]:
return {
"project_id": str(project_id),
"synced": False,
"error": "No Commerce subscription found",
}
return await self._sync_project(project_id, row[0])
return await self._sync_project(project_id, row[0], row[1])
async def run(self) -> None:
"""Run the sync loop."""
+24 -31
View File
@@ -10,7 +10,7 @@ Flow:
4. All billing operations use the linked customer
"""
from datetime import datetime
from datetime import datetime, UTC
from typing import Any
from uuid import UUID
@@ -35,7 +35,7 @@ class UnifiedUser(BaseModel):
# Commerce Customer (created on first billing access)
commerce_customer_id: str | None = None
stripe_customer_id: str | None = None
square_customer_id: str | None = None
# Billing Status
has_payment_method: bool = False
@@ -53,7 +53,10 @@ class UnifiedUser(BaseModel):
if commerce_data:
user.commerce_customer_id = commerce_data.get("id")
user.stripe_customer_id = commerce_data.get("stripe_customer_id")
# Square customer ID is nested under accounts
accounts = commerce_data.get("accounts", {})
if isinstance(accounts, dict):
user.square_customer_id = accounts.get("square")
user.has_payment_method = commerce_data.get("has_payment_method", False)
user.default_payment_method = commerce_data.get("default_payment_method")
@@ -70,8 +73,8 @@ class UnifiedBillingClient:
def __init__(self) -> None:
settings = get_settings()
self.iam_url = settings.iam_url
self.commerce_url = settings.hanzo_commerce_url
self.commerce_api_key = settings.hanzo_commerce_api_key
self.commerce_url = settings.commerce_url
self.commerce_api_key = settings.commerce_api_key
self._timeout = httpx.Timeout(30.0)
def _commerce_headers(self) -> dict[str, str]:
@@ -115,28 +118,18 @@ class UnifiedBillingClient:
"""Find Commerce customer by Hanzo IAM ID."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
# Try v2 API endpoint first
response = await client.get(
f"{self.commerce_url}/api/v2/customers",
f"{self.commerce_url}/api/v1/user",
params={"hanzo_id": hanzo_id},
headers=self._commerce_headers(),
)
if response.status_code == 200:
data = response.json()
customers = data.get("customers", [])
customers = data.get("data", [])
if customers:
return customers[0]
# Fallback: try by email lookup via v1 API
response = await client.get(
f"{self.commerce_url}/api/v1/customers",
params={"hanzo_id": hanzo_id},
headers=self._commerce_headers(),
)
if response.status_code == 200:
data = response.json()
# Single-object response
if data.get("id"):
return data
@@ -150,7 +143,7 @@ class UnifiedBillingClient:
"""Create Commerce customer linked to IAM user."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(
f"{self.commerce_url}/api/v1/customers",
f"{self.commerce_url}/api/v1/user",
json={
"email": iam_user.email,
"name": iam_user.name,
@@ -193,12 +186,12 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.commerce_url}/api/v1/customers/{unified_user.commerce_customer_id}/subscriptions",
f"{self.commerce_url}/api/v1/user/{unified_user.commerce_customer_id}/orders",
headers=self._commerce_headers(),
)
if response.status_code == 200:
return response.json().get("subscriptions", [])
return response.json().get("data", [])
return []
@@ -211,17 +204,18 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.commerce_url}/api/v1/customers/{unified_user.commerce_customer_id}/invoices",
f"{self.commerce_url}/api/v1/user/{unified_user.commerce_customer_id}/orders",
params={"type": "invoice"},
headers=self._commerce_headers(),
)
if response.status_code == 200:
return response.json().get("invoices", [])
return response.json().get("data", [])
return []
async def get_customer_payment_methods(self, iam_user: IAMUser) -> list[dict]:
"""Get payment methods for IAM user."""
"""Get payment methods for IAM user (Square cards via Commerce)."""
unified_user = await self.get_or_create_customer(iam_user)
if not unified_user.commerce_customer_id:
@@ -229,12 +223,12 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.get(
f"{self.commerce_url}/api/v1/customers/{unified_user.commerce_customer_id}/payment-methods",
f"{self.commerce_url}/api/v1/user/{unified_user.commerce_customer_id}/paymentmethods",
headers=self._commerce_headers(),
)
if response.status_code == 200:
return response.json().get("payment_methods", [])
return response.json().get("data", [])
return []
@@ -253,7 +247,7 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(
f"{self.commerce_url}/api/v1/subscriptions",
f"{self.commerce_url}/api/v1/subscribe",
json={
"customer_id": unified_user.commerce_customer_id,
"plan_id": plan_id,
@@ -282,9 +276,8 @@ class UnifiedBillingClient:
"""Report metered usage for PAYG billing."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.post(
f"{self.commerce_url}/api/v1/usage",
f"{self.commerce_url}/api/v1/subscribe/{subscription_id}/usage",
json={
"subscription_id": subscription_id,
"quantity": compute_units,
"unit": "compute_units",
"timestamp": timestamp.isoformat(),
@@ -316,7 +309,7 @@ class UnifiedBillingClient:
async with httpx.AsyncClient(timeout=self._timeout) as client:
response = await client.patch(
f"{self.commerce_url}/api/v1/customers/{unified_user.commerce_customer_id}",
f"{self.commerce_url}/api/v1/user/{unified_user.commerce_customer_id}",
json={
"email": iam_user.email,
"name": iam_user.name,
@@ -324,7 +317,7 @@ class UnifiedBillingClient:
"iam_id": iam_user.id,
"iam_org": iam_user.org,
"iam_roles": iam_user.roles,
"last_sync": datetime.utcnow().isoformat(),
"last_sync": datetime.now(UTC).isoformat(),
},
},
headers=self._commerce_headers(),
+148 -3
View File
@@ -1,9 +1,10 @@
"""Hanzo Commerce webhook handler.
Handles webhooks from Hanzo Commerce for:
Handles webhooks from Hanzo Commerce (Square-based) for:
- Order events (authorize, complete, cancel)
- Subscription lifecycle events
- Invoice events
- Payment events
- Payment events (paid, failed, refunded)
"""
import hashlib
@@ -46,7 +47,7 @@ class CommerceWebhookHandler:
def __init__(self) -> None:
settings = get_settings()
self.webhook_secret = settings.hanzo_commerce_webhook_secret
self.webhook_secret = settings.commerce_webhook_secret
def verify_signature(self, payload: bytes, signature: str) -> bool:
"""
@@ -92,12 +93,22 @@ class CommerceWebhookHandler:
)
handlers = {
# Order events
"order.completed": self.handle_order_completed,
"order.cancelled": self.handle_order_cancelled,
# Subscription lifecycle
"subscription.created": self.handle_subscription_created,
"subscription.updated": self.handle_subscription_updated,
"subscription.cancelled": self.handle_subscription_cancelled,
"subscription.reactivated": self.handle_subscription_reactivated,
# Invoice events
"invoice.paid": self.handle_invoice_paid,
"invoice.payment_failed": self.handle_invoice_failed,
# Payment events (forwarded through Commerce from Square)
"payment.paid": self.handle_payment_paid,
"payment.failed": self.handle_payment_failed,
"payment.refunded": self.handle_payment_refunded,
# Customer events
"customer.created": self.handle_customer_created,
"customer.updated": self.handle_customer_updated,
}
@@ -339,6 +350,91 @@ class CommerceWebhookHandler:
return {"subscription_id": subscription_id, "tier": tier.value}
# =========================================================================
# Order Handlers
# =========================================================================
async def handle_order_completed(self, data: dict[str, Any]) -> dict:
"""Handle order.completed — payment captured successfully.
May trigger subscription provisioning if order is for a plan upgrade.
"""
order_id = data.get("id")
customer_id = data.get("customer_id")
metadata = data.get("metadata", {})
plan_slug = metadata.get("plan_slug")
project_id_str = metadata.get("project_id")
logger.info(
"Order completed",
order_id=order_id,
customer_id=customer_id,
plan_slug=plan_slug,
)
# If order is for a plan subscription, provision it
if plan_slug and project_id_str:
try:
project_id = UUID(project_id_str)
except ValueError:
return {"order_id": order_id, "error": "invalid_project_id"}
tier = PLAN_TO_TIER.get(plan_slug, PricingTier.FREE)
limits = get_tier_limits(tier)
subscription_id = data.get("subscription_id")
async with async_session() as db:
result = await db.execute(
select(Subscription).where(Subscription.project_id == project_id)
)
subscription = result.scalar_one_or_none()
if subscription:
subscription.tier = tier.value
subscription.monthly_cu_limit = limits.monthly_cu
subscription.rate_limit_per_second = limits.rate_limit_per_second
subscription.max_apps = limits.max_apps
subscription.max_webhooks = limits.max_webhooks
if subscription_id:
subscription.hanzo_subscription_id = subscription_id
if customer_id:
subscription.hanzo_customer_id = customer_id
else:
subscription = Subscription(
project_id=project_id,
tier=tier.value,
hanzo_subscription_id=subscription_id,
hanzo_customer_id=customer_id,
monthly_cu_limit=limits.monthly_cu,
rate_limit_per_second=limits.rate_limit_per_second,
max_apps=limits.max_apps,
max_webhooks=limits.max_webhooks,
billing_cycle_start=datetime.now(UTC),
billing_cycle_end=datetime.fromisoformat(
data.get("current_period_end", datetime.now(UTC).isoformat())
),
)
db.add(subscription)
await db.commit()
await self._invalidate_cache(project_id)
return {"order_id": order_id, "plan_slug": plan_slug}
async def handle_order_cancelled(self, data: dict[str, Any]) -> dict:
"""Handle order.cancelled — payment authorization voided."""
order_id = data.get("id")
reason = data.get("reason", "unknown")
logger.info(
"Order cancelled",
order_id=order_id,
reason=reason,
)
return {"order_id": order_id, "reason": reason}
# =========================================================================
# Invoice Handlers
# =========================================================================
@@ -422,6 +518,55 @@ class CommerceWebhookHandler:
return {"customer_id": customer_id}
# =========================================================================
# Payment Handlers (forwarded from Square via Commerce)
# =========================================================================
async def handle_payment_paid(self, data: dict[str, Any]) -> dict:
"""Handle payment.paid — successful payment via Square."""
payment_id = data.get("id")
order_id = data.get("order_id")
amount = data.get("amount", 0)
logger.info(
"Payment received",
payment_id=payment_id,
order_id=order_id,
amount_cents=amount,
)
return {"payment_id": payment_id, "amount": amount}
async def handle_payment_failed(self, data: dict[str, Any]) -> dict:
"""Handle payment.failed — Square payment declined."""
payment_id = data.get("id")
order_id = data.get("order_id")
error_code = data.get("error_code", "unknown")
logger.warning(
"Payment failed",
payment_id=payment_id,
order_id=order_id,
error_code=error_code,
)
return {"payment_id": payment_id, "error_code": error_code}
async def handle_payment_refunded(self, data: dict[str, Any]) -> dict:
"""Handle payment.refunded — refund processed via Square."""
payment_id = data.get("id")
refund_id = data.get("refund_id")
amount = data.get("amount", 0)
logger.info(
"Payment refunded",
payment_id=payment_id,
refund_id=refund_id,
amount_cents=amount,
)
return {"payment_id": payment_id, "refund_id": refund_id, "amount": amount}
# =========================================================================
# Helpers
# =========================================================================
+5
View File
@@ -32,6 +32,11 @@ metadata:
kms.hanzo.ai/environment: "production"
type: Opaque
# stringData intentionally empty - use KMS or kubectl create secret
# Required secrets (inject via KMS, kubectl, or sealed-secrets):
# database-url, redis-url, jwt-secret, api-key-salt
# stripe-secret-key, stripe-webhook-secret, stripe-publishable-key
# stripe-product-id, stripe-price-payg
# stripe-price-growth-base, stripe-price-growth-metered
stringData: {}
---
# Chain configuration (non-sensitive)