Complete Stripe Payment Project: Build a Full Billing System
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
- What is the recommended architecture for a Stripe billing system?
- How do you handle the checkout.session.completed webhook?
- What is the purpose of the customer portal?
- How do you ensure idempotent webhook processing?
Answers:
- 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.
- 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.
- 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.
- 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
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