Skip to content

Complete Stripe Payment Project: Build a Full Billing System

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Complete Stripe Payment Project: Build a Full Billing System. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a production-ready billing system integrating all Stripe concepts: product catalog, Checkout Sessions, subscriptions, Webhooks, customer portal, invoices, refunds, discounts, and comprehensive testing with test cards.

What You'll Learn

How to architect and implement a complete Stripe billing system combining Checkout Sessions, subscription management, Webhook event handling, customer portal, invoice management, discount codes, and refund processing into a single integrated application.

Why It Matters

A properly implemented billing system is the revenue engine of any SaaS. Getting it wrong means lost revenue, chargebacks, and poor customer experience. DodaTech's production billing system processes thousands of transactions monthly with 99.9% uptime.

Real-World Use

A new customer signs up for DodaTech Pro. They choose monthly billing, enter promo code PRO20, complete checkout via Stripe's hosted page, receive an invoice email, later upgrade yearly in the customer portal, and when they cancel, they get a prorated refund automatically.

flowchart LR
    subgraph Setup
        A["Create\nProducts & Prices"] --> B["Configure\nWebhook Endpoint"]
    end
    subgraph Checkout
        C["Create\nCheckout Session"] --> D["Customer\nPays on Stripe"]
        D --> E["Webhook:\nsession.completed"]
    end
    subgraph Post-Purchase
        E --> F["Activate\nSubscription"]
        F --> G["Customer Portal\nManage Billing"]
        F --> H["Recurring\nInvoices"]
    end
    subgraph Support
        H --> I["Refunds &\nDisputes"]
        F --> J["Promotions &\nCoupons"]
    end
    style A fill:#6772e5,color:#fff
    style D fill:#dbeafe,stroke:#2563eb
    style E fill:#fef3c7,stroke:#d97706
    style I fill:#fecaca,stroke:#dc2626

Project Architecture

"""
Project Structure:
billing_system/
  app.py              # Main Flask/FastAPI application
  stripe_config.py    # Stripe client, keys, webhook secret
  products.py         # Product and price management
  checkout.py         # Checkout Session creation
  webhooks.py         # Event handlers
  subscriptions.py    # Subscription management
  invoices.py         # Invoice retrieval and management
  refunds.py          # Refund processing
  promotions.py       # Coupon and promo code management
  customers.py        # Customer CRUD
  test_cards.py       # Test automation suite
"""

1. Configuration and Client Setup

# stripe_config.py
import stripe
import os

class StripeConfig:
    def __init__(self, environment="test"):
        self.environment = environment
        if environment == "test":
            stripe.api_key = os.environ.get("STRIPE_TEST_SECRET_KEY")
            self.webhook_secret = os.environ.get("STRIPE_TEST_WEBHOOK_SECRET")
            self.price_id = "price_TestMonthlyABC"  # Your test price ID
        else:
            stripe.api_key = os.environ.get("STRIPE_LIVE_SECRET_KEY")
            self.webhook_secret = os.environ.get("STRIPE_LIVE_WEBHOOK_SECRET")
            self.price_id = "price_ProdMonthlyXYZ"

        self.domain = os.environ.get("DOMAIN", "http://localhost:8000")

    def verify_live_mode(self):
        if stripe.api_key.startswith("sk_live_"):
            print("WARNING: Running in LIVE mode")
            return True
        print("Running in test mode")
        return False

config = StripeConfig()
config.verify_live_mode()
# Expected output:
# Running in test mode

2. Checkout Session Creation

# checkout.py
import stripe

def create_checkout_session(customer_email, price_id, promo_code=None):
    params = {
        "success_url": f"{config.domain}/success?session_id={{CHECKOUT_SESSION_ID}}",
        "cancel_url": f"{config.domain}/cancel",
        "mode": "subscription",
        "line_items": [{"price": price_id, "quantity": 1}],
        "customer_email": customer_email,
        "allow_promotion_codes": False,
        "metadata": {"source": "project_tutorial"}
    }
    if promo_code:
        params["discounts"] = [{"promotion_code": promo_code}]
    session = stripe.checkout.Session.create(**params)
    print(f"Session created: {session.id}")
    print(f"Customer: {customer_email}")
    print(f"URL: {session.url[:60]}...")
    return session

# Usage
session = create_checkout_session(
    customer_email="test@example.com",
    price_id=config.price_id,
    promo_code="PRO20"
)
# Expected output:
# Session created: cs_test_ABC123...
# Customer: test@example.com
# URL: https://checkout.stripe.com/c/pay/cs_test_...

3. Webhook Handler

# webhooks.py
import stripe
from flask import request, jsonify

webhook_handlers = {}

def webhook_received(event_type):
    """Decorator to register webhook handlers."""
    def decorator(func):
        webhook_handlers[event_type] = func
        return func
    return decorator

def handle_webhook(payload, sig_header):
    """Main webhook endpoint handler."""
    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, config.webhook_secret
        )
        print(f"Received event: {event.type} ({event.id})")
        handler = webhook_handlers.get(event.type)
        if handler:
            handler(event.data.object)
            return jsonify({"status": "success"}), 200
        return jsonify({"status": "ignored"}), 200
    except ValueError:
        return jsonify({"error": "Invalid payload"}), 400
    except stripe.error.SignatureVerificationError:
        return jsonify({"error": "Invalid signature"}), 400

@webhook_received("checkout.session.completed")
def on_checkout_completed(session):
    """Handle successful checkout."""
    print(f"Checkout completed: {session.id}")
    print(f"Customer: {session.customer}")
    print(f"Subscription: {session.subscription}")
    print(f"Amount: ${session.amount_total/100:.2f}")
    # Activate subscription in your database
    # activate_subscription(session.customer, session.subscription)

@webhook_received("invoice.paid")
def on_invoice_paid(invoice):
    print(f"Invoice paid: {invoice.id}")
    print(f"Subscription: {invoice.subscription}")
    print(f"Amount Paid: ${invoice.amount_paid/100:.2f}")
    # Grant access for the billing period

@webhook_received("customer.subscription.updated")
def on_subscription_updated(subscription):
    print(f"Subscription updated: {subscription.id}")
    print(f"Status: {subscription.status}")
    print(f"Current Period End: {subscription.current_period_end}")

@webhook_received("customer.subscription.deleted")
def on_subscription_deleted(subscription):
    print(f"Subscription cancelled: {subscription.id}")
    # Revoke access

4. Customer Portal and Refunds

# portal.py
import stripe

def create_customer_portal(customer_id):
    """Create a customer portal session for self-service billing management."""
    session = stripe.billing_portal.Session.create(
        customer=customer_id,
        return_url=f"{config.domain}/account"
    )
    print(f"Portal session: {session.id}")
    print(f"URL: {session.url}")
    return session

# portal = create_customer_portal("cus_CustomerABC")
# Expected output:
# Portal session: bp_1PortalSessionABC
# URL: https://billing.stripe.com/session/...

# refunds.py
def process_refund(payment_intent_id, amount=None, reason="requested_by_customer"):
    """Process full or partial refund."""
    params = {"payment_intent": payment_intent_id}
    if amount:
        params["amount"] = amount
        print(f"Partial refund: ${amount/100:.2f}")
    else:
        print("Full refund")
    refund = stripe.Refund.create(**params)
    print(f"Refund: {refund.id}")
    print(f"Status: {refund.status}")
    return refund

# process_refund("pi_TestPaymentABC")
# Expected output:
# Full refund
# Refund: re_1RefundABC
# Status: succeeded

5. Test Suite

# test_suite.py
def run_billing_test_suite():
    """Run comprehensive test scenarios."""
    test_cases = [
        ("successful_payment", "4242424242424242", "succeeded"),
        ("sca_payment", "4000002500003155", "requires_action"),
        ("insufficient_funds", "4000000000009995", "requires_payment_method"),
        ("expired_card", "4000000000000069", "requires_payment_method"),
        ("dispute_card", "4000000000000259", "succeeded"),
    ]
    results = []
    for name, card, expected_status in test_cases:
        result = simulate_payment(card)
        passed = result.status == expected_status
        results.append({"test": name, "passed": passed, "status": result.status})
        print(f"{'PASS' if passed else 'FAIL'} | {name}: {result.status}")
    passed_count = sum(1 for r in results if r["passed"])
    print(f"\nResults: {passed_count}/{len(results)} passed")
    return results

# run_billing_test_suite()
# Expected output:
# PASS | successful_payment: succeeded
# PASS | sca_payment: requires_action
# PASS | insufficient_funds: requires_payment_method
# PASS | expired_card: requires_payment_method
# PASS | dispute_card: succeeded
#
# Results: 5/5 passed

Common Mistakes

1. Not Handling Webhook Idempotency

Stripe may send the same webhook event multiple times. Store processed event IDs and skip duplicates. Otherwise you may activate a subscription twice or Process the same payment twice.

2. Missing Webhook Verification

Without verifying the webhook signature using stripe.Webhook.construct_event, anyone can POST fake events to your endpoint. Always verify with your webhook signing secret.

3. Forgetting to Handle All Subscription States

Subscriptions can be active, past_due, canceled, unpaid, incomplete, incomplete_expired, trialing, and paused. Your application must handle each state appropriately.

4. Not Providing a Customer Portal

Without a portal, customers must email support to update billing, causing support load. Always create a customer portal session for self-service plan changes, payment method updates, and cancellations.

5. Skipping Load Testing

Stripe webhooks arrive nearly simultaneously when many subscriptions renew. Test your webhook endpoint under load to ensure it can handle burst traffic without timing out.

Practice Questions

  1. What is the recommended architecture for a Stripe billing system?
  2. How do you handle the checkout.session.completed webhook?
  3. What is the purpose of the customer portal?
  4. How do you ensure idempotent webhook processing?

Answers:

  1. Three-tier architecture: Products/Prices setup layer, Checkout/payment flow layer, and Post-purchase management layer (portal, invoices, refunds). Webhooks connect the payment flow to your application logic.
  2. Extract the customer ID and subscription ID from the session object, create or update the customer in your database, activate the subscription, and send a confirmation email.
  3. The customer portal is Stripe's hosted UI for self-service billing management — plan changes, payment method updates, invoice viewing, and cancellations — reducing support tickets.
  4. Maintain a set or database table of processed webhook event IDs. Before processing, check if the event ID has been handled. If so, skip it. This prevents duplicate processing from retries.

Challenge: Build and deploy a complete billing system as described in this project. Include all 5 components, test all 10 test card scenarios, configure the customer portal, set up webhook forwarding with Stripe CLI, and write a production deployment checklist covering environment variables, webhook secrets, and live mode verification.

FAQ

Should I use Checkout Sessions or Payment Intents for my project?

Start with Checkout Sessions for the hosted UI. Switch to Payment Intents with Elements only if you need a fully custom-branded checkout experience.

How do I handle upgrades and downgrades?

Use the customer portal for self-service plan changes. For custom flows, call stripe.Subscription.update with proration_behavior: always_invoice to charge/credit prorated amounts immediately.

What monitoring should I have for the billing system?

Monitor webhook delivery failures, payment success rates, failed invoice retries, dispute rate, and refund rate. Set up alerts for anomalies using Stripe Sigma or Datadog.

How do I test the complete flow before going live?

Run the test suite with all cards, verify webhooks with stripe trigger, test customer portal navigation, simulate dunning with the 4000000000009995 card, and verify refund flows. Run everything against test mode first.

What security measures should I take for production?

Use environment variables for all secrets, verify webhook signatures, use idempotency keys on all POST requests, restrict API key permissions via Stripe restricted keys, enable Radar for fraud detection, and never log full card data.

Mini Project

The mini project is this complete billing system. Implement all components described above, integrate with Stripe CLI for webhook testing, create a test suite covering all 10 test cards, deploy with proper monitoring and alerts, and document the architecture for team handoff.

What's Next

API Automated Testing — apply automated testing techniques to your Stripe integration.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro