mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user