Files
bootnode/api/tests/test_billing.py
T
zeekay ad6dc4e6eb feat: migrate billing from Stripe to Hanzo Commerce + Square
Remove all direct Stripe integration. Bootnode now uses Hanzo Commerce
API as its payment gateway (Commerce handles Square internally).

- Remove stripe dependency from pyproject.toml
- Rewrite commerce.py client with correct Commerce API paths:
  /api/v1/user (customers), /api/v1/subscribe (subscriptions),
  /api/v1/checkout/* (payments), /api/v1/order (orders/invoices)
- Add unified.py: IAM + Commerce customer linking (hanzo.id SSO)
- Rewrite webhooks.py: Commerce event handler with HMAC verification
- Update sync.py: usage metering via Commerce (not Stripe)
- Update billing API endpoints: checkout, account, webhooks
- Config uses COMMERCE_* prefix (not HANZO_COMMERCE_*)
- Add test_billing.py: 46 tests covering CU, tiers, Commerce client,
  webhooks, and unified billing
2026-02-10 20:50:18 -08:00

520 lines
20 KiB
Python

"""Billing module tests — compute units, tiers, Commerce client, and webhooks."""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from bootnode.core.billing.compute_units import (
COMPUTE_UNITS,
DEFAULT_COMPUTE_UNITS,
get_batch_compute_units,
get_compute_units,
)
from bootnode.core.billing.tiers import (
TIER_LIMITS,
PricingTier,
TierLimits,
calculate_monthly_cost,
get_tier_limits,
)
# =============================================================================
# Compute Units
# =============================================================================
class TestComputeUnits:
def test_free_methods_cost_1(self):
assert get_compute_units("eth_chainId") == 1
assert get_compute_units("eth_blockNumber") == 1
assert get_compute_units("net_version") == 1
def test_light_methods_cost_5(self):
assert get_compute_units("eth_getBalance") == 5
assert get_compute_units("eth_getTransactionByHash") == 5
def test_medium_methods_cost_10(self):
assert get_compute_units("eth_call") == 10
assert get_compute_units("eth_estimateGas") == 10
def test_heavy_methods_cost_25(self):
assert get_compute_units("eth_getLogs") == 25
def test_transaction_methods_cost_50(self):
assert get_compute_units("eth_sendRawTransaction") == 50
def test_debug_methods_cost_100(self):
assert get_compute_units("debug_traceTransaction") == 100
def test_unknown_method_returns_default(self):
assert get_compute_units("nonexistent_method") == DEFAULT_COMPUTE_UNITS
def test_batch_compute_units(self):
methods = ["eth_chainId", "eth_getBalance", "eth_call"]
assert get_batch_compute_units(methods) == 1 + 5 + 10
def test_batch_empty(self):
assert get_batch_compute_units([]) == 0
def test_solana_methods(self):
assert get_compute_units("getAccountInfo") == 5
assert get_compute_units("sendTransaction") == 50
assert get_compute_units("simulateTransaction") == 25
def test_erc4337_methods(self):
assert get_compute_units("eth_sendUserOperation") == 75
assert get_compute_units("eth_estimateUserOperationGas") == 25
def test_bootnode_enhanced_apis(self):
assert get_compute_units("tokens_getBalances") == 15
assert get_compute_units("nfts_getOwned") == 20
assert get_compute_units("webhooks_create") == 5
# =============================================================================
# Pricing Tiers
# =============================================================================
class TestPricingTiers:
def test_all_tiers_defined(self):
for tier in PricingTier:
assert tier in TIER_LIMITS
def test_free_tier_limits(self):
limits = get_tier_limits(PricingTier.FREE)
assert limits.monthly_cu == 30_000_000
assert limits.rate_limit_per_second == 25
assert limits.max_apps == 5
assert limits.max_webhooks == 5
assert limits.price_per_million_cu == 0
def test_payg_tier_limits(self):
limits = get_tier_limits(PricingTier.PAY_AS_YOU_GO)
assert limits.monthly_cu == 0 # unlimited
assert limits.rate_limit_per_second == 300
assert limits.max_apps == 30
assert limits.price_per_million_cu == 40 # $0.40
def test_growth_tier_limits(self):
limits = get_tier_limits(PricingTier.GROWTH)
assert limits.monthly_cu == 100_000_000
assert limits.rate_limit_per_second == 500
assert limits.price_per_million_cu == 35 # $0.35
def test_enterprise_tier_limits(self):
limits = get_tier_limits(PricingTier.ENTERPRISE)
assert limits.rate_limit_per_second == 1000
assert limits.max_apps == 0 # unlimited
assert limits.price_per_million_cu == 0 # custom
def test_tier_rate_limits_ascending(self):
"""Higher tiers should have higher rate limits."""
free = get_tier_limits(PricingTier.FREE)
payg = get_tier_limits(PricingTier.PAY_AS_YOU_GO)
growth = get_tier_limits(PricingTier.GROWTH)
enterprise = get_tier_limits(PricingTier.ENTERPRISE)
assert free.rate_limit_per_second < payg.rate_limit_per_second
assert payg.rate_limit_per_second < growth.rate_limit_per_second
assert growth.rate_limit_per_second < enterprise.rate_limit_per_second
# =============================================================================
# Cost Calculation
# =============================================================================
class TestCostCalculation:
def test_free_tier_always_zero(self):
assert calculate_monthly_cost(PricingTier.FREE, 0) == 0
assert calculate_monthly_cost(PricingTier.FREE, 1_000_000) == 0
def test_enterprise_tier_always_zero(self):
assert calculate_monthly_cost(PricingTier.ENTERPRISE, 0) == 0
assert calculate_monthly_cost(PricingTier.ENTERPRISE, 999_000_000) == 0
def test_payg_1_million_cu(self):
# PAYG: $0.40 per million CU, no included CU
cost = calculate_monthly_cost(PricingTier.PAY_AS_YOU_GO, 1_000_000)
assert cost == 40 # 40 cents
def test_payg_10_million_cu(self):
cost = calculate_monthly_cost(PricingTier.PAY_AS_YOU_GO, 10_000_000)
assert cost == 400 # $4.00
def test_payg_zero_usage(self):
cost = calculate_monthly_cost(PricingTier.PAY_AS_YOU_GO, 0)
assert cost == 0
def test_growth_within_included(self):
# Growth includes 100M CU, no overage
cost = calculate_monthly_cost(PricingTier.GROWTH, 50_000_000)
assert cost == 0 # base fee not counted here (Commerce handles it)
def test_growth_at_included(self):
cost = calculate_monthly_cost(PricingTier.GROWTH, 100_000_000)
assert cost == 0
def test_growth_with_overage(self):
# 10M overage at $0.35 per million
cost = calculate_monthly_cost(PricingTier.GROWTH, 110_000_000)
assert cost == 350 # $3.50
# =============================================================================
# Plan Slug Mappings
# =============================================================================
class TestPlanSlugs:
def test_plan_to_tier_mapping(self):
"""Verify the plan-to-tier mapping in webhooks module."""
from bootnode.core.billing.webhooks import PLAN_TO_TIER
assert PLAN_TO_TIER["bootnode-free"] == PricingTier.FREE
assert PLAN_TO_TIER["bootnode-payg"] == PricingTier.PAY_AS_YOU_GO
assert PLAN_TO_TIER["bootnode-growth"] == PricingTier.GROWTH
assert PLAN_TO_TIER["bootnode-enterprise"] == PricingTier.ENTERPRISE
def test_api_tier_to_plan_slug(self):
"""Verify the tier-to-plan slug mapping in API module."""
from bootnode.api.billing import TIER_TO_PLAN_SLUG
assert TIER_TO_PLAN_SLUG[PricingTier.FREE] == "bootnode-free"
assert TIER_TO_PLAN_SLUG[PricingTier.PAY_AS_YOU_GO] == "bootnode-payg"
assert TIER_TO_PLAN_SLUG[PricingTier.GROWTH] == "bootnode-growth"
assert TIER_TO_PLAN_SLUG[PricingTier.ENTERPRISE] == "bootnode-enterprise"
# =============================================================================
# Commerce Client
# =============================================================================
class TestCommerceClient:
@patch("bootnode.core.billing.commerce.get_settings")
def test_client_uses_commerce_config(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
assert client.base_url == "http://test:8001"
assert client.api_key == "test-key"
@patch("bootnode.core.billing.commerce.get_settings")
def test_client_headers(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
headers = client._headers()
assert headers["Authorization"] == "Bearer test-key"
assert headers["X-Source"] == "bootnode"
assert headers["Content-Type"] == "application/json"
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_create_customer(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
# Mock the _request method
client._request = AsyncMock(return_value={"id": "cust_123"})
customer_id = await client.create_customer(
user_id="user_1", email="[email protected]", name="Test User"
)
assert customer_id == "cust_123"
client._request.assert_called_once()
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_create_checkout(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
client._request = AsyncMock(return_value={
"order_id": "order_123",
"checkout_url": "https://commerce.hanzo.ai/checkout/order_123",
"authorization_id": "auth_456",
})
result = await client.create_checkout(
customer_id="cust_123",
plan_slug="bootnode-payg",
project_id=uuid4(),
success_url="https://example.com/success",
)
assert result["order_id"] == "order_123"
assert result["checkout_url"] is not None
client._request.assert_called_once_with(
"POST",
"/api/v1/checkout/authorize",
json=pytest.approx(client._request.call_args[1]["json"], abs=0) if False else client._request.call_args[1].get("json", client._request.call_args[0][2] if len(client._request.call_args[0]) > 2 else None),
)
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_capture_checkout(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
client._request = AsyncMock(return_value={"order_id": "order_123", "status": "completed"})
result = await client.capture_checkout("order_123")
assert result["status"] == "completed"
client._request.assert_called_once_with("POST", "/api/v1/checkout/capture/order_123")
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_report_usage(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
client._request = AsyncMock(return_value={})
now = datetime.now(UTC)
await client.report_usage(
subscription_id="sub_123",
compute_units=5_000_000,
timestamp=now,
idempotency_key="test-key-123",
)
client._request.assert_called_once()
call_args = client._request.call_args
assert call_args[0][0] == "POST"
assert "/usage" in call_args[0][1]
@patch("bootnode.core.billing.commerce.get_settings")
@pytest.mark.asyncio
async def test_get_user_orders(self, mock_settings):
mock_settings.return_value = MagicMock(
commerce_url="http://test:8001",
commerce_api_key="test-key",
)
from bootnode.core.billing.commerce import HanzoCommerceClient
client = HanzoCommerceClient()
client._request = AsyncMock(return_value={
"data": [
{"id": "order_1", "status": "completed"},
{"id": "order_2", "status": "pending"},
]
})
orders = await client.get_user_orders("cust_123")
assert len(orders) == 2
assert orders[0]["id"] == "order_1"
# =============================================================================
# Commerce Webhooks
# =============================================================================
class TestCommerceWebhooks:
@patch("bootnode.core.billing.webhooks.get_settings")
def test_no_webhook_secret_allows_through(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
# No secret = verification skipped (returns True)
assert handler.verify_signature(b"payload", "any-sig") is True
@patch("bootnode.core.billing.webhooks.get_settings")
def test_valid_signature(self, mock_settings):
import hashlib
import hmac
secret = "test-secret"
mock_settings.return_value = MagicMock(commerce_webhook_secret=secret)
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
payload = b'{"type":"test"}'
expected_sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
assert handler.verify_signature(payload, expected_sig) is True
@patch("bootnode.core.billing.webhooks.get_settings")
def test_invalid_signature(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test-secret")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
assert handler.verify_signature(b'{"type":"test"}', "bad-signature") is False
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_unhandled_event_type(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "some.unknown.event",
"id": "evt_123",
"data": {},
})
assert result["handled"] is False
assert result["reason"] == "unknown_type"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_order_completed(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "order.completed",
"id": "evt_456",
"data": {
"id": "order_123",
"customer_id": "cust_456",
"metadata": {},
},
})
assert result["handled"] is True
assert result["result"]["order_id"] == "order_123"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_order_cancelled(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "order.cancelled",
"id": "evt_789",
"data": {
"id": "order_123",
"reason": "customer_request",
},
})
assert result["handled"] is True
assert result["result"]["reason"] == "customer_request"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_payment_paid(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "payment.paid",
"id": "evt_pay1",
"data": {
"id": "pay_123",
"order_id": "order_456",
"amount": 4900,
},
})
assert result["handled"] is True
assert result["result"]["amount"] == 4900
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_payment_refunded(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "payment.refunded",
"id": "evt_ref1",
"data": {
"id": "pay_123",
"refund_id": "ref_456",
"amount": 2500,
},
})
assert result["handled"] is True
assert result["result"]["refund_id"] == "ref_456"
@patch("bootnode.core.billing.webhooks.get_settings")
@pytest.mark.asyncio
async def test_handles_invoice_paid(self, mock_settings):
mock_settings.return_value = MagicMock(commerce_webhook_secret="test")
from bootnode.core.billing.webhooks import CommerceWebhookHandler
handler = CommerceWebhookHandler()
result = await handler.handle_event({
"type": "invoice.paid",
"id": "evt_inv1",
"data": {
"id": "inv_123",
"customer_id": "cust_456",
"amount_paid": 4900,
},
})
assert result["handled"] is True
assert result["result"]["amount_paid"] == 4900
# =============================================================================
# Unified Billing Client
# =============================================================================
class TestUnifiedUser:
def _make_iam_user(self, **kwargs):
"""Create a fake IAM user. Can't use MagicMock because 'name' is reserved."""
from types import SimpleNamespace
defaults = {"id": "user_1", "email": "[email protected]", "name": "Test", "org": "hanzo"}
defaults.update(kwargs)
return SimpleNamespace(**defaults)
def test_from_iam_user_no_commerce(self):
from bootnode.core.billing.unified import UnifiedUser
iam_user = self._make_iam_user()
user = UnifiedUser.from_iam_user(iam_user)
assert user.iam_id == "user_1"
assert user.commerce_customer_id is None
assert user.square_customer_id is None
def test_from_iam_user_with_commerce(self):
from bootnode.core.billing.unified import UnifiedUser
iam_user = self._make_iam_user()
commerce_data = {
"id": "cust_123",
"accounts": {"square": "sq_cust_456"},
"has_payment_method": True,
"default_payment_method": "card_789",
}
user = UnifiedUser.from_iam_user(iam_user, commerce_data)
assert user.commerce_customer_id == "cust_123"
assert user.square_customer_id == "sq_cust_456"
assert user.has_payment_method is True