How to Test Subscription and Billing Flows (Complete Guide)
Subscription billing looks simple on a whiteboard: user picks plan, pays monthly, gets access. In production it becomes a distributed state machine with at least seven actors — your app, the payment g
Why Subscription Testing Breaks Teams
Subscription billing looks simple on a whiteboard: user picks plan, pays monthly, gets access. In production it becomes a distributed state machine with at least seven actors — your app, the payment gateway, the tax engine, the entitlement service, the webhook handler, the dunning scheduler, and the customer — all operating on eventually consistent clocks. A single missed webhook, a timezone boundary crossed during a plan change, or a grace-period edge case can leave a paying customer locked out or a cancelled user still streaming 4K video.
Most teams test the happy path: start trial, convert, cancel. They skip the combinatorial explosion of proration calculations, mid-cycle upgrades with partial refunds, dunning retries that span billing cycle boundaries, and the "paid but still gated" race where the webhook arrives after the client has already checked entitlements. This guide covers every state transition you need to verify, the edge cases that only appear in production, and how to automate the lot without writing a thousand brittle scripts.
The Subscription State Machine You're Actually Testing
Before writing tests, map the real states. Marketing calls them "trial," "active," "cancelled." Your code sees this:
| Internal State | Gateway Status | Entitlement | User-Facing Label | Transitions Into |
|---|---|---|---|---|
trial_active | trialing | Full | "14-day free trial" | trial_converted, trial_expired, trial_cancelled |
trial_converted | active | Full | "Pro Monthly" | active, past_due |
active | active | Full | "Pro Monthly" | past_due, cancelled, upgraded, downgraded |
past_due | past_due | Degraded/Full* | "Payment failed — retrying" | active, cancelled, unpaid |
grace_period | past_due | Full | "Payment failed — 3 days left" | active, past_due, cancelled |
unpaid | unpaid | None | "Subscription paused" | active (via recovery), cancelled |
cancelled | canceled | None** | "Cancelled — access until period_end" | reactivated (win-back) |
paused | paused | None | "Paused by user" | active |
refunded | refunded | None | "Refunded" | — |
\* Grace period behavior varies: Stripe keeps entitlements during past_due if you configure it; Razorpay may cut access immediately.
\** cancelled with cancel_at_period_end=true still grants access until current_period_end.
Your entitlement service must answer one question correctly at any millisecond: has_access(user_id, feature_id) → boolean. Every test below ultimately validates that function.
Comprehensive Test Matrix
The following matrix covers every state transition and the assertions each requires. Use it as your master checklist — copy into Notion, GitHub Projects, or your test management tool.
| Category | Scenario | Preconditions | Action | Expected Gateway State | Expected Entitlement | Critical Assertions |
|---|---|---|---|---|---|---|
| Trial | Start trial | New user, no payment method | POST /subscriptions with trial_days=14 | trialing, trial_end=now+14d | Full | Trial end timestamp stored; no charge attempted |
| Trial | Trial converts automatically | trial_active, 14 days elapsed | Wait for invoice.payment_succeeded webhook | active | Full | Webhook processed idempotently; receipt emailed |
| Trial | Trial cancelled before conversion | trial_active, day 10 | DELETE /subscriptions/{id} | canceled, cancel_at_period_end=false | None immediately | No invoice generated; analytics event trial_cancelled |
| Trial | Payment method added mid-trial | trial_active, day 5 | PUT /payment-method | trialing (unchanged) | Full | Card tokenized; no charge until trial_end |
| Upgrade | Immediate upgrade with proration | active on $10/mo, day 5 of 30 | PUT /subscriptions/{id} plan=$30/mo, proration_behavior=create_prorations | active, new price, proration invoice | Full (new tier) | Proration amount = ($30-$10) × (25/30) = $16.67; invoice paid |
| Upgrade | Upgrade at cycle boundary | active on $10/mo, day 30 (renewal day) | PUT plan=$30/mo | active, new price, no proration invoice | Full (new tier) | No proration invoice; next invoice $30 |
| Downgrade | Downgrade with credit | active on $30/mo, day 10 | PUT plan=$10/mo, proration_behavior=create_prorations | active, new price, credit note | Full (lower tier) | Credit = ($30-$10) × (20/30) = $13.33; applied to next invoice |
| Downgrade | Downgrade at period end | active on $30/mo, cancel_at_period_end=true | PUT plan=$10/mo, proration_behavior=none | active (new plan at renewal) | Full (current tier until renewal) | No immediate charge; next invoice $10 |
| Dunning | First payment failure | active, card declined | invoice.payment_failed webhook | past_due | Full (grace period) | Dunning email #1 sent; retry scheduled per settings |
| Dunning | Retry succeeds in grace period | past_due, day 2 of 7 grace | invoice.payment_succeeded webhook | active | Full | Grace period cancelled; no dunning email #2 |
| Dunning | All retries exhausted | past_due, day 8 (post-grace) | Final invoice.payment_failed | unpaid or canceled (config) | None | Access revoked; subscription_cancelled event |
| Dunning | Manual payment via hosted page | past_due, user clicks email link | User pays on Stripe Hosted Invoice Page | active | Full | Webhook reconciles; no duplicate charge |
| Cancellation | Cancel at period end | active | PUT cancel_at_period_end=true | active (with cancel_at_period_end) | Full until current_period_end | cancel_at timestamp stored; win-back email scheduled |
| Cancellation | Immediate cancellation | active | DELETE /subscriptions/{id} | canceled | None immediately | Prorated refund if configured; access revoked |
| Win-back | Reactivate cancelled sub | cancelled, within 30 days | POST /subscriptions with same customer | active (new subscription) | Full | New subscription ID; old ID preserved for history |
| Refund | Full refund recent charge | active, charge 2 days ago | POST /refunds amount=full | refunded (charge) | Unchanged* | Refund webhook processed; credit balance updated |
| Refund | Prorated refund on downgrade | active, downgraded day 10 | Automatic credit note | active | Lower tier | Credit note amount matches proration calc |
| Tax | VAT validation EU B2B | active, EU VAT ID provided | PUT /customer vat_id=DE123456789 | active | Full | Reverse charge applied; invoice shows 0% VAT |
| Tax | GST India place of supply | active, Indian address | Address change to Maharashtra | active | Full | IGST vs CGST/SGST split correct on next invoice |
| Entitlement | Webhook delay race | past_due, payment succeeds | Webhook delayed 30s; client polls entitlements | active (eventually) | Must not flip false | Client caches last-known-good; no flicker |
| Entitlement | Clock skew on trial end | Server clock +5min vs gateway | Trial ends per gateway, not server | active (per gateway) | Full | Entitlement uses gateway trial_end, not local clock |
| Currency | Plan currency change | active USD, user moves to EUR | Migration script runs | active EUR (new sub) | Full | No double-charge; old sub cancelled cleanly |
\* Refunds on active subscriptions typically don't revoke access until period end unless you explicitly cancel.
Trial Flows: The Deceptively Hard Part
Trials generate the most support tickets per line of code. The logic seems trivial — "free for 14 days" — but the boundary conditions multiply fast.
Trial Start Without Payment Method
Stripe, Razorpay, and Paddle all allow trial_period_days without a payment method. Test these variations:
# Test: trial starts, no payment method, user cancels day 3
def test_trial_no_pm_cancel_early():
user = create_user()
sub = stripe.Subscription.create(
customer=user.stripe_customer_id,
items=[{"price": PRICE_PRO_MONTHLY}],
trial_period_days=14,
payment_behavior="allow_incomplete", # critical: no PM required
)
assert sub.status == "trialing"
assert sub.trial_end == approx(now() + 14*86400)
# User cancels day 3
cancelled = stripe.Subscription.delete(sub.id)
assert cancelled.status == "canceled"
assert cancelled.cancel_at_period_end is False
# No invoice should exist
invoices = stripe.Invoice.list(customer=user.stripe_customer_id)
assert len(invoices.data) == 0
Assertion checklist for trial start:
trial_endtimestamp persisted in your DB matches gateway (within 1 second)- No
invoice.createdwebhook fires - Analytics event
trial_startedincludestrial_endandplan_id - Entitlement service returns
has_access=Truefor all trial-included features
Trial Conversion: The Webhook Race
The conversion moment is where "paid but still gated" happens. Sequence:
- Gateway charges card at
trial_end - Gateway emits
invoice.payment_succeeded→ your webhook endpoint - Your webhook updates local subscription status to
active - Client polls
/me/entitlementsor receives push
If step 3 takes 500ms and the client polls at 250ms, they see trial_expired → locked out. Fix: entitlement service must treat trialing + trial_end > now as full access, and trialing + trial_end < now + latest_invoice.status != paid as degraded, not revoked.
# Entitlement resolution logic (simplified)
def resolve_entitlement(user_id: str, feature: str) -> EntitlementResult:
sub = db.get_subscription(user_id)
if not sub:
return EntitlementResult(has_access=False, reason="no_subscription")
# Gateway is source of truth for status
gateway_sub = stripe.Subscription.retrieve(sub.gateway_id)
if gateway_sub.status == "active":
return EntitlementResult(has_access=True, plan=gateway_sub.items[0].price.id)
if gateway_sub.status == "trialing":
if gateway_sub.trial_end > time.time():
return EntitlementResult(has_access=True, plan="trial", trial_ends_at=gateway_sub.trial_end)
else:
# Trial ended, check latest invoice
latest_invoice = stripe.Invoice.retrieve(gateway_sub.latest_invoice)
if latest_invoice.status == "paid":
return EntitlementResult(has_access=True, plan=gateway_sub.items[0].price.id)
return EntitlementResult(has_access=False, reason="trial_expired_unpaid")
if gateway_sub.status in ("past_due", "unpaid"):
# Grace period logic: check if within grace window
if is_in_grace_period(gateway_sub):
return EntitlementResult(has_access=True, degraded=True, reason="grace_period")
return EntitlementResult(has_access=False, reason=gateway_sub.status)
return EntitlementResult(has_access=False, reason=gateway_sub.status)
Test the race explicitly:
def test_trial_conversion_webhook_race():
"""Simulate webhook delay while client polls entitlements."""
user = create_user_with_trial(days_remaining=0) # trial ends now
# Gateway has charged, but webhook not processed yet
with patch("webhooks.stripe.WebhookHandler.process") as mock_process:
mock_process.side_effect = lambda *a, **kw: time.sleep(0.5) # 500ms delay
# Fire webhook in background
webhook_thread = threading.Thread(target=fire_conversion_webhook, args=(user,))
webhook_thread.start()
# Client polls immediately (simulating race)
for _ in range(10):
entitlement = api.get_entitlements(user.id)
# Must NOT return has_access=False during this window
assert entitlement.has_access is True, "Entitlement flickered during webhook processing"
time.sleep(0.1)
webhook_thread.join()
Trial Extension and Promotional Trials
Marketing will ask: "Extend this user's trial by 7 days" or "Give this influencer a 60-day trial." Test both:
| Scenario | API Call | Entitlement Impact | Invoice Impact |
|---|---|---|---|
| Extend active trial | PUT /subscriptions/{id} trial_end=now+7d | trial_end moves forward | None |
| Promotional trial (existing customer) | POST /subscriptions trial_period_days=60 | New subscription in trialing | None until conversion |
| Trial with payment method collected upfront | payment_behavior=default_incomplete | Same access, but latest_invoice exists | Invoice in draft until trial_end |
Critical test: Extending a trial that already ended (user churned). Gateway may call). Your system must either:
- Reactivate as new trial (new subscription ID), or
- Reopen old subscription with new
trial_end(only ifcancel_at_period_end=falseand notcanceled)
Plan Changes: Proration, Credits, and the Midnight Boundary
Plan changes are where money leaks. Proration math differs by gateway, and "immediate vs. end-of-period" behavior trips every team once.
Upgrade: Immediate with Proration (Stripe Default)
def test_upgrade_immediate_proration():
# User on $10/mo, day 5 of 30-day cycle (25 days remaining)
sub = create_active_subscription(user, PRICE_10_MONTHLY, cycle_start=days_ago(5))
# Upgrade to $30/mo immediate
updated = stripe.Subscription.modify(
sub.id,
items=[{"id": sub.items.data[0].id, "price": PRICE_30_MONTHLY}],
proration_behavior="create_prorations",
)
# Verify proration invoice created
proration_invoice = stripe.Invoice.list(
customer=sub.customer,
subscription=sub.id,
status="open"
).data[0]
# Math: ($30 - $10) * (25/30) = $16.67
expected_proration = round((30 - 10) * (25 / 30), 2)
actual_proration = sum(
li.amount for li in proration_invoice.lines.data
if li.proration
) / 100 # cents to dollars
assert actual_proration == expected_proration
assert proration_invoice.status == "paid" # auto-paid if payment method exists
# Entitlement immediately reflects new plan
ent = api.get_entitlements(user.id)
assert ent.plan_id == PRICE_30_MONTHLY
Downgrade: Credit Note at Period End
def test_downgrade_end_of_period_no_proration():
sub = create_active_subscription(user, PRICE_30_MONTHLY, cycle_start=days_ago(10))
# Downgrade to $10/mo at period end
updated = stripe.Subscription.modify(
sub.id,
items=[{"id": sub.items.data[0].id, "price": PRICE_10_MONTHLY}],
proration_behavior="none", # key: no proration invoice
)
# No immediate invoice
invoices = stripe.Invoice.list(subscription=sub.id, status="open")
assert len(invoices.data) == 0
# Current period still $30 access
ent = api.get_entitlements(user.id)
assert ent.plan_id == PRICE_30_MONTHLY
# Fast-forward to renewal
with freeze_time(sub.current_period_end + 1):
stripe.Invoice.pay(stripe.Invoice.create(customer=sub.customer))
ent = api.get_entitlements(user.id)
assert ent.plan_id == PRICE_10_MONTHLY
The Midnight Boundary Bug
Billing cycles anchor to the subscription creation timestamp, not calendar days. A user upgrading at 23:59:50 UTC on day 15 gets different proration than 00:00:10 UTC on day 16. Test both sides of the boundary:
@freeze_time("2024-01-15 23:59:50")
def test_upgrade_one_minute_before_cycle_boundary():
sub = create_subscription(cycle_start=days_ago(14, hours=10)) # 30-day cycle
# 10 minutes into day 15, 23h50m remaining
updated = upgrade_immediate(sub, PRICE_30_MONTHLY)
proration = get_proration_amount(updated)
# 23h50m = 1430 minutes of 43200 minutes (30 days) = 3.31% of cycle
assert proration == round(20 * (1430 / 43200), 2)
@freeze_time("2024-01-16 00:00:10")
def test_upgrade_ten_seconds_after_cycle_boundary():
sub = create_subscription(cycle_start=days_ago(15)) # new cycle just started
updated = upgrade_immediate(sub, PRICE_30_MONTHLY)
proration = get_proration_amount(updated)
# ~full cycle remaining
assert proration == round(20 * (43190 / 43200), 2)
Quantity Changes (Per-Seat Billing)
If you bill per seat, quantity changes follow the same proration rules. Test:
def test_seat_increase_mid_cycle():
sub = create_subscription(price=PRICE_PER_SEAT_10, quantity=5, cycle_start=days_ago(10))
# 20 days remaining, add 3 seats
updated = stripe.Subscription.modify(
sub.id,
items=[{"id": sub.items.data[0].id, "quantity": 8}],
proration_behavior="create_prorations",
)
proration = get_proration_amount(updated)
# $10/seat * 3 seats * (20/30) = $20
assert proration == 20.00
Dunning, Grace Periods, and the Unpaid State
Dunning is the subsystem most likely to silently lose revenue. Stripe's default: 3 retries over 7 days, then unpaid. Razorpay: configurable. Your job: verify the state machine matches your business policy.
Dunning Configuration Matrix
| Setting | Stripe Default | Recommended SaaS | Test Case |
|---|---|---|---|
| First retry | 1 hour | 1 hour | invoice.payment_failed → past_due |
| Second retry | 3 days | 2 days | Verify email #2 sent |
| Third retry | 5 days | 5 days | Verify email #3 (final warning) |
| Final action | unpaid | cancel | Access revoked, subscription_cancelled event |
| Grace period | None (past_due = no access) | 3-7 days | Entitlements preserved during grace |
| Email templates | Basic | Branded, localized | Verify template variables render |
Testing the Full Dunning Cycle
def test_dunning_full_cycle_to_cancellation():
"""Simulate 8 days of failed payments with 3 retries."""
user = create_user_with_active_sub(PRICE_30_MONTHLY)
sub_id = user.subscription.gateway_id
# Day 0: Payment fails
fire_webhook("invoice.payment_failed", {
"subscription": sub_id,
"attempt_count": 0,
"next_payment_attempt": timestamp(hours=1),
})
assert get_sub_status(sub_id) == "past_due"
assert_email_sent(user, "dunning_1")
# Day 1: Retry 1 fails
fire_webhook("invoice.payment_failed", {
"subscription": sub_id,
"attempt_count": 1,
"next_payment_attempt": timestamp(days=2),
})
assert get_sub_status(sub_id) == "past_due"
assert_email_sent(user, "dunning_2")
# Day 3: Retry 2 fails
fire_webhook("invoice.payment_failed", {
"subscription": sub_id,
"attempt_count": 2,
"next_payment_attempt": timestamp(days=5),
})
assert get_sub_status(sub_id) == "past_due"
assert_email_sent(user, "dunning_3_final")
# Day 8: Final retry fails → cancelled
fire_webhook("invoice.payment_failed", {
"subscription": sub_id,
"attempt_count": 3,
"next_payment_attempt": None,
})
assert get_sub_status(sub_id) == "canceled"
assert_email_sent(user, "subscription_cancelled")
# Entitlements revoked
ent = api.get_entitlements(user.id)
assert ent.has_access is False
Grace Period: Entitlements During Past Due
If you configure grace period (Stripe: pause_collection + custom logic, or use subscription_pause), entitlements must stay active. Test the boundary:
def test_grace_period_entitlements_preserved():
user = create_user_with_active_sub(PRICE_30_MONTHLY)
sub_id = user.subscription.gateway_id
# Configure 3-day grace period in your system
config.set("dunning.grace_period_days", 3)
fire_webhook("invoice.payment_failed", {"subscription": sub_id})
# Day 1: still in grace
with freeze_time(days=1):
ent = api.get_entitlements(user.id)
assert ent.has_access is True
assert ent.degraded is True # UI can show banner
# Day 3: last day of grace
with freeze_time(days=3):
ent = api.get_entitlements(user.id)
assert ent.has_access is True
# Day 4: grace expired
with freeze_time(days=4):
ent = api.get_entitlements(user.id)
assert ent.has_access is False
Manual Payment Recovery
Users often pay via the hosted invoice page after dunning emails. Test the reconciliation:
def test_manual_payment_via_hosted_invoice_page():
user = create_user_with_active_sub(PRICE_30_MONTHLY)
sub_id = user.subscription.gateway_id
# Enter past_due
fire_webhook("invoice.payment_failed", {"subscription": sub_id})
assert get_sub_status(sub_id) == "past_due"
# User clicks email link, pays on Stripe hosted page
# This triggers invoice.payment_succeeded webhook
fire_webhook("invoice.payment_succeeded", {
"subscription": sub_id,
"payment_intent": {"id": "pi_manual_payment"},
})
# Subscription should be active
assert get_sub_status(sub_id) == "active"
# No duplicate charge on next cycle
with freeze_time(days=30):
run_billing_cycle()
invoices = stripe.Invoice.list(customer=user.stripe_customer_id, limit=2)
# Only one paid invoice in this cycle
paid_count = sum(1 for inv in invoices.data if inv.status == "paid")
assert paid_count == 1
Cancellation, Win-Back, and Reactivation
Cancellation isn't a single action — it's a family of behaviors with different revenue implications.
Cancel at Period End vs Immediate
| Cancellation Type | API Call | Access Until | Refund | Use Case |
|---|---|---|---|---|
cancel_at_period_end=true | PUT subscription cancel_at_period_end=true | current_period_end | None | User-initiated "cancel but keep access" |
| Immediate cancel | DELETE subscription | Immediately | Prorated (config) | Admin action, fraud, compliance |
| Pause | PUT subscription pause_collection=true | Immediately | None | User vacation, temporary budget freeze |
Test the immediate cancel refund logic:
def test_immediate_cancel_with_prorated_refund():
user = create_user_with_active_sub(PRICE_30_MONTHLY, cycle_start=days_ago(10))
# 20 days unused
sub_id = user.subscription.gateway_id
cancelled = stripe.Subscription.delete(sub_id, prorate=True)
assert cancelled.status == "canceled"
assert cancelled.canceled_at is not None
# Refund created
refunds = stripe.Refund.list(charge=latest_charge_id)
assert len(refunds.data) == 1
# $30 * (20/30) = $20
assert refunds.data[0].amount == 2000 # cents
# Entitlements immediately revoked
ent = api.get_entitlements(user.id)
assert ent.has_access is False
Win-Back Flow: Reactivating Cancelled Subscriptions
Win-back offers (discounts, extended trials) create new subscriptions. Test that history preserves:
def test_win_back_creates_new_subscription_preserves_history():
user = create_user_with_cancelled_sub(PRICE_30_MONTHLY, cancelled_days_ago=5)
old_sub_id = user.subscription.gateway_id
# Win-back: 50% off for 3 months
winback_price = create_price(PRICE_30_MONTHLY, metadata={"winback": "true", "discount": "50%"})
new_sub = stripe.Subscription.create(
customer=user.stripe_customer_id,
items=[{"price": winback_price}],
trial_period_days=0,
)
assert new_sub.id != old_sub_id
assert new_sub.status == "active"
# History query returns both
history = api.get_subscription_history(user.id)
assert len(history) == 2
assert history[0].id == new_sub.id
assert history[1].id == old_sub_id
assert history[1].cancelled_at is not None
# Analytics: track winback conversion
events = analytics.get_events(user.id, "subscription_started")
winback_event = [e for e in events if e.properties.get("winback")][0]
assert winback_event.properties["previous_subscription_id"] == old_sub_id
Refunds: Partial, Full, and Prorated
Refunds interact dangerously with entitlements. A full refund on an active subscription shouldn't revoke access until period end unless you explicitly cancel. A prorated refund on downgrade is a credit note, not a cash refund.
Refund Type Matrix
| Refund Type | Trigger | Gateway Action | Entitlement Impact | Revenue Recognition |
|---|---|---|---|---|
| Full refund (recent charge) | Support request, dispute | POST /refunds full amount | None (access until period_end) | Revenue reversed |
| Partial refund (goodwill) | Support request | POST /refunds partial | None | Revenue partially reversed |
| Prorated refund (downgrade) | Plan change mid-cycle | Credit note auto-created | Downgraded features immediately | Revenue deferred |
| Refund + cancel | User demands money back | Refund + cancel_at_period_end=false | Immediate revocation | Revenue reversed |
| Chargeback/dispute | Bank dispute | charge.dispute.created webhook | Immediate revocation (usually) | Revenue held |
Testing the "Refund Without Cancel" Edge Case
def test_full_refund_does_not_revoke_access_until_period_end():
user = create_user_with_active_sub(PRICE_30_MONTHLY, cycle_start=days_ago(5))
charge_id = user.subscription.latest_charge_id
# Support issues full refund (e.g., service outage compensation)
refund = stripe.Refund.create(charge=charge_id, amount=3000) # full $30
assert refund.status == "succeeded"
# Entitlements UNCHANGED until period end
ent = api.get_entitlements(user.id)
assert ent.has_access is True
assert ent.plan_id == PRICE_30_MONTHLY
# At period end, subscription continues (no auto-cancel)
with freeze_time(days=25): # period_end
run_billing_cycle()
# Next invoice generated for $30
ent = api.get_entitlements(user.id)
assert ent.has_access is True
Testing Credit Notes on Downgrade
def test_downgrade_creates_credit_note_not_refund():
user = create_user_with_active_sub(PRICE_50_MONTHLY, cycle_start=days_ago(10))
# 20 days remaining, downgrade to $20
updated = stripe.Subscription.modify(
user.subscription.gateway_id,
items=[{"id": user.subscription.item_id, "price": PRICE_20_MONTHLY}],
proration_behavior="create_prorations",
)
# Credit note created, not refund
credit_notes = stripe.CreditNote.list(customer=user.stripe_customer_id, limit=1)
cn = credit_notes.data[0]
assert cn.type == "post_payment"
assert cn.amount == 2000 # $20 credit ($30 diff * 20/30)
assert cn.refund is None # No cash movement
# Credit applied to next invoice
with freeze_time(days=20):
next_invoice = run_billing_cycle()
assert next_invoice.amount_due == 0 # $20 - $20 credit
assert next_invoice.amount_paid == 0
Tax, VAT, GST: The Compliance Minefield
Tax testing requires synthetic addresses, valid/invalid tax IDs, and verification that invoice line items split correctly. You cannot test this with mock data — you need real tax engine responses.
VAT: EU B2B Reverse Charge
def test_eu_b2b_reverse_charge_vat_zero_percent():
"""German company buying from French SaaS — reverse charge, 0% VAT."""
user = create_user(country="DE", vat_id="DE123456789") # Valid VIES number
sub = create_subscription(user, PRICE_100_MONTHLY)
invoice = stripe.Invoice.create(customer=user.stripe_customer_id)
invoice = stripe.Invoice.finalize_invoice(invoice.id)
# Verify VAT validation happened
assert invoice.customer_tax_ids[0].type == "eu_vat"
assert invoice.customer_tax_ids[0].verification.status == "verified"
# Line items show 0% VAT with reverse charge reference
for line in invoice.lines.data:
assert line.tax_amounts == [] # No tax amounts
assert "reverse charge" in line.description.lower() or \
invoice.metadata.get("reverse_charge") == "true"
# Total = subtotal (no tax added)
assert invoice.total == invoice.subtotal
GST India: Place of Supply Determines IGST vs CGST+SGST
def test_gst_india_interstate_igst():
"""Maharashtra company buying from Karnataka SaaS — IGST (interstate)."""
user = create_user(
country="IN",
state="MH", # Maharashtra
gstin="27AA
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free