171 lines
6.3 KiB
Python
171 lines
6.3 KiB
Python
"""
|
|
Stripe webhook handling is a 100%-coverage path per CLAUDE.md §18.
|
|
These tests exercise the pure data-transformation logic in
|
|
billing_service (no real Stripe API calls are made).
|
|
"""
|
|
from services import billing_service
|
|
from repositories.tenant_repository import tenant_repository
|
|
|
|
|
|
def test_checkout_completed_with_upgrade_metadata_updates_tenant():
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
|
|
billing_service.handle_checkout_completed({
|
|
'metadata': {'tenant_id': tenant['id'], 'target_tier': 'discover'},
|
|
'subscription': 'sub_123',
|
|
})
|
|
|
|
updated = tenant_repository.get_by_id(tenant['id'])
|
|
assert updated['tier'] == 'discover'
|
|
assert updated['max_seats'] == 8
|
|
assert updated['stripe_subscription_id'] == 'sub_123'
|
|
assert updated['status'] == 'trial'
|
|
assert updated['trial_ends_at'] is not None
|
|
|
|
|
|
def test_checkout_completed_without_metadata_is_ignored_not_raised():
|
|
# New-signup checkout sessions (no upgrade metadata) are out of
|
|
# scope for this handler — manual onboarding handles new tenants.
|
|
# Must not raise.
|
|
billing_service.handle_checkout_completed({'metadata': {}})
|
|
|
|
|
|
def test_subscription_updated_changes_tier_when_present_in_metadata():
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
|
|
billing_service.handle_subscription_updated({'metadata': {'tier': 'intelligence'}}, tenant['id'])
|
|
|
|
updated = tenant_repository.get_by_id(tenant['id'])
|
|
assert updated['tier'] == 'intelligence'
|
|
assert updated['max_seats'] == 10
|
|
|
|
|
|
def test_subscription_updated_no_op_without_tier_metadata():
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
|
|
billing_service.handle_subscription_updated({'metadata': {}}, tenant['id'])
|
|
|
|
updated = tenant_repository.get_by_id(tenant['id'])
|
|
assert updated['tier'] == 'analyse'
|
|
|
|
|
|
def test_subscription_deleted_marks_tenant_inactive():
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
|
|
billing_service.handle_subscription_deleted(tenant['id'])
|
|
|
|
assert tenant_repository.get_by_id(tenant['id'])['status'] == 'inactive'
|
|
|
|
|
|
def test_upgrade_checkout_rejects_unconfigured_tier(monkeypatch):
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
monkeypatch.delenv('STRIPE_PRICE_DISCOVER', raising=False)
|
|
|
|
try:
|
|
billing_service.create_upgrade_checkout(tenant['id'], 'discover')
|
|
assert False, 'expected ValueError'
|
|
except ValueError as e:
|
|
assert 'No Stripe price configured' in str(e)
|
|
|
|
|
|
class _FakeCheckoutSession:
|
|
url = 'https://checkout.stripe.com/fake-session'
|
|
|
|
|
|
def _fake_stripe(monkeypatch, captured):
|
|
"""
|
|
Stands in for services.billing_service._stripe() — captures the
|
|
kwargs checkout.Session.create() was called with instead of
|
|
hitting the real Stripe API, so the customer/customer_email
|
|
branching can be asserted directly.
|
|
"""
|
|
fake = type('_FakeStripe', (), {
|
|
'checkout': type('_Checkout', (), {
|
|
'Session': type('_Session', (), {
|
|
'create': staticmethod(lambda **kwargs: captured.update(kwargs) or _FakeCheckoutSession())
|
|
})
|
|
})
|
|
})
|
|
monkeypatch.setattr(billing_service, '_stripe', lambda: fake)
|
|
|
|
|
|
def test_upgrade_checkout_omits_customer_when_tenant_has_none(monkeypatch):
|
|
"""
|
|
Regression test — Stripe rejects an empty-string `customer` param
|
|
outright (raises InvalidRequestError), and every trial/manually-
|
|
onboarded tenant (CLAUDE.md §32) starts with stripe_customer_id=""
|
|
rather than unset. This must be omitted, not passed empty.
|
|
"""
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'email': 'pm@gadventures.com',
|
|
'tier': 'analyse', 'status': 'trial', 'max_seats': 5, 'stripe_customer_id': '',
|
|
})
|
|
monkeypatch.setenv('STRIPE_PRICE_DISCOVER', 'price_123')
|
|
captured = {}
|
|
_fake_stripe(monkeypatch, captured)
|
|
|
|
url = billing_service.create_upgrade_checkout(tenant['id'], 'discover')
|
|
|
|
assert url == 'https://checkout.stripe.com/fake-session'
|
|
assert 'customer' not in captured
|
|
assert captured['customer_email'] == 'pm@gadventures.com'
|
|
|
|
|
|
def test_upgrade_checkout_uses_existing_customer_id_when_present(monkeypatch):
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'email': 'pm@gadventures.com',
|
|
'tier': 'analyse', 'status': 'active', 'max_seats': 5, 'stripe_customer_id': 'cus_existing123',
|
|
})
|
|
monkeypatch.setenv('STRIPE_PRICE_DISCOVER', 'price_123')
|
|
captured = {}
|
|
_fake_stripe(monkeypatch, captured)
|
|
|
|
billing_service.create_upgrade_checkout(tenant['id'], 'discover')
|
|
|
|
assert captured['customer'] == 'cus_existing123'
|
|
assert 'customer_email' not in captured
|
|
|
|
|
|
def test_checkout_completed_captures_new_stripe_customer_id():
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'trial', 'max_seats': 5, 'stripe_customer_id': '',
|
|
})
|
|
|
|
billing_service.handle_checkout_completed({
|
|
'metadata': {'tenant_id': tenant['id'], 'target_tier': 'discover'},
|
|
'subscription': 'sub_123',
|
|
'customer': 'cus_new456',
|
|
})
|
|
|
|
assert tenant_repository.get_by_id(tenant['id'])['stripe_customer_id'] == 'cus_new456'
|
|
|
|
|
|
def test_checkout_completed_does_not_clobber_customer_id_when_event_lacks_one():
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5, 'stripe_customer_id': 'cus_existing123',
|
|
})
|
|
|
|
billing_service.handle_checkout_completed({
|
|
'metadata': {'tenant_id': tenant['id'], 'target_tier': 'intelligence'},
|
|
'subscription': 'sub_123',
|
|
})
|
|
|
|
assert tenant_repository.get_by_id(tenant['id'])['stripe_customer_id'] == 'cus_existing123'
|