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

April 10, 2026 · 14 min read · How-To Guides

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 StateGateway StatusEntitlementUser-Facing LabelTransitions Into
trial_activetrialingFull"14-day free trial"trial_converted, trial_expired, trial_cancelled
trial_convertedactiveFull"Pro Monthly"active, past_due
activeactiveFull"Pro Monthly"past_due, cancelled, upgraded, downgraded
past_duepast_dueDegraded/Full*"Payment failed — retrying"active, cancelled, unpaid
grace_periodpast_dueFull"Payment failed — 3 days left"active, past_due, cancelled
unpaidunpaidNone"Subscription paused"active (via recovery), cancelled
cancelledcanceledNone**"Cancelled — access until period_end"reactivated (win-back)
pausedpausedNone"Paused by user"active
refundedrefundedNone"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.

CategoryScenarioPreconditionsActionExpected Gateway StateExpected EntitlementCritical Assertions
TrialStart trialNew user, no payment methodPOST /subscriptions with trial_days=14trialing, trial_end=now+14dFullTrial end timestamp stored; no charge attempted
TrialTrial converts automaticallytrial_active, 14 days elapsedWait for invoice.payment_succeeded webhookactiveFullWebhook processed idempotently; receipt emailed
TrialTrial cancelled before conversiontrial_active, day 10DELETE /subscriptions/{id}canceled, cancel_at_period_end=falseNone immediatelyNo invoice generated; analytics event trial_cancelled
TrialPayment method added mid-trialtrial_active, day 5PUT /payment-methodtrialing (unchanged)FullCard tokenized; no charge until trial_end
UpgradeImmediate upgrade with prorationactive on $10/mo, day 5 of 30PUT /subscriptions/{id} plan=$30/mo, proration_behavior=create_prorationsactive, new price, proration invoiceFull (new tier)Proration amount = ($30-$10) × (25/30) = $16.67; invoice paid
UpgradeUpgrade at cycle boundaryactive on $10/mo, day 30 (renewal day)PUT plan=$30/moactive, new price, no proration invoiceFull (new tier)No proration invoice; next invoice $30
DowngradeDowngrade with creditactive on $30/mo, day 10PUT plan=$10/mo, proration_behavior=create_prorationsactive, new price, credit noteFull (lower tier)Credit = ($30-$10) × (20/30) = $13.33; applied to next invoice
DowngradeDowngrade at period endactive on $30/mo, cancel_at_period_end=truePUT plan=$10/mo, proration_behavior=noneactive (new plan at renewal)Full (current tier until renewal)No immediate charge; next invoice $10
DunningFirst payment failureactive, card declinedinvoice.payment_failed webhookpast_dueFull (grace period)Dunning email #1 sent; retry scheduled per settings
DunningRetry succeeds in grace periodpast_due, day 2 of 7 graceinvoice.payment_succeeded webhookactiveFullGrace period cancelled; no dunning email #2
DunningAll retries exhaustedpast_due, day 8 (post-grace)Final invoice.payment_failedunpaid or canceled (config)NoneAccess revoked; subscription_cancelled event
DunningManual payment via hosted pagepast_due, user clicks email linkUser pays on Stripe Hosted Invoice PageactiveFullWebhook reconciles; no duplicate charge
CancellationCancel at period endactivePUT cancel_at_period_end=trueactive (with cancel_at_period_end)Full until current_period_endcancel_at timestamp stored; win-back email scheduled
CancellationImmediate cancellationactiveDELETE /subscriptions/{id}canceledNone immediatelyProrated refund if configured; access revoked
Win-backReactivate cancelled subcancelled, within 30 daysPOST /subscriptions with same customeractive (new subscription)FullNew subscription ID; old ID preserved for history
RefundFull refund recent chargeactive, charge 2 days agoPOST /refunds amount=fullrefunded (charge)Unchanged*Refund webhook processed; credit balance updated
RefundProrated refund on downgradeactive, downgraded day 10Automatic credit noteactiveLower tierCredit note amount matches proration calc
TaxVAT validation EU B2Bactive, EU VAT ID providedPUT /customer vat_id=DE123456789activeFullReverse charge applied; invoice shows 0% VAT
TaxGST India place of supplyactive, Indian addressAddress change to MaharashtraactiveFullIGST vs CGST/SGST split correct on next invoice
EntitlementWebhook delay racepast_due, payment succeedsWebhook delayed 30s; client polls entitlementsactive (eventually)Must not flip falseClient caches last-known-good; no flicker
EntitlementClock skew on trial endServer clock +5min vs gatewayTrial ends per gateway, not serveractive (per gateway)FullEntitlement uses gateway trial_end, not local clock
CurrencyPlan currency changeactive USD, user moves to EURMigration script runsactive EUR (new sub)FullNo 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 Conversion: The Webhook Race

The conversion moment is where "paid but still gated" happens. Sequence:

  1. Gateway charges card at trial_end
  2. Gateway emits invoice.payment_succeeded → your webhook endpoint
  3. Your webhook updates local subscription status to active
  4. Client polls /me/entitlements or 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:

ScenarioAPI CallEntitlement ImpactInvoice Impact
Extend active trialPUT /subscriptions/{id} trial_end=now+7dtrial_end moves forwardNone
Promotional trial (existing customer)POST /subscriptions trial_period_days=60New subscription in trialingNone until conversion
Trial with payment method collected upfrontpayment_behavior=default_incompleteSame access, but latest_invoice existsInvoice in draft until trial_end

Critical test: Extending a trial that already ended (user churned). Gateway may call). Your system must either:

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

SettingStripe DefaultRecommended SaaSTest Case
First retry1 hour1 hourinvoice.payment_failedpast_due
Second retry3 days2 daysVerify email #2 sent
Third retry5 days5 daysVerify email #3 (final warning)
Final actionunpaidcancelAccess revoked, subscription_cancelled event
Grace periodNone (past_due = no access)3-7 daysEntitlements preserved during grace
Email templatesBasicBranded, localizedVerify 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 TypeAPI CallAccess UntilRefundUse Case
cancel_at_period_end=truePUT subscription cancel_at_period_end=truecurrent_period_endNoneUser-initiated "cancel but keep access"
Immediate cancelDELETE subscriptionImmediatelyProrated (config)Admin action, fraud, compliance
PausePUT subscription pause_collection=trueImmediatelyNoneUser 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 TypeTriggerGateway ActionEntitlement ImpactRevenue Recognition
Full refund (recent charge)Support request, disputePOST /refunds full amountNone (access until period_end)Revenue reversed
Partial refund (goodwill)Support requestPOST /refunds partialNoneRevenue partially reversed
Prorated refund (downgrade)Plan change mid-cycleCredit note auto-createdDowngraded features immediatelyRevenue deferred
Refund + cancelUser demands money backRefund + cancel_at_period_end=falseImmediate revocationRevenue reversed
Chargeback/disputeBank disputecharge.dispute.created webhookImmediate 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