Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Production-ready Flask SaaS template**

Multi-tenant workspaces, Stripe billing, OAuth, and team collaboration out of the box. Build your product, not infrastructure.
Multi-tenant workspaces, subscription billing (Stripe or Chargebee), OAuth, and team collaboration out of the box. Build your product, not infrastructure.

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Expand All @@ -21,7 +21,7 @@ https://github.com/user-attachments/assets/c955e2a2-8f25-4430-98fe-5bbc95ffb4da
## What's Included

- **Multi-tenant workspaces** - Data isolation, scales from solo to teams
- **Stripe billing** - Checkout, webhooks, customer portal
- **Subscription billing** - Stripe or Chargebee, hosted checkout, webhooks, customer portal
- **OAuth authentication** - Google & GitHub login
- **Team collaboration** - Roles (admin/member), member management
- **Modern stack** - Flask 3.1, Vue 3, Vuetify 3, PostgreSQL, Redis
Expand All @@ -48,6 +48,9 @@ cd readykit
# 2. Configure (edit .env)
GOOGLE_OAUTH_CLIENT_ID=your_id
GOOGLE_OAUTH_CLIENT_SECRET=your_secret

# Billing: choose stripe (default) or chargebee
BILLING_PROVIDER=stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PRO_PRICE_ID=price_...

Expand Down
16 changes: 15 additions & 1 deletion checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,19 @@ def check_billing_service(app):

assert callable(requires_pro_plan)
assert hasattr(HostedBilling, "create_upgrade_session")
assert hasattr(HostedBilling, "create_portal_session")
assert hasattr(HostedBilling, "handle_successful_payment")


@check("BillingEvent model exists")
def check_billing_event_model(app):
from enferno.user.models import BillingEvent

with app.app_context():
assert hasattr(BillingEvent, "provider")
BillingEvent.query.limit(1).all()


@check("Auth decorators work")
def check_auth_decorators(app):
from enferno.services.auth import require_superadmin, require_superadmin_api
Expand All @@ -127,11 +137,15 @@ def check_routes(app):
"/",
"/login",
"/dashboard/",
"/stripe/webhook",
]

for route in critical_routes:
assert route in rules, f"Missing route: {route}"

# Billing webhook - one of these must exist based on provider
webhook_routes = ["/stripe/webhook", "/chargebee/webhook"]
assert any(r in rules for r in webhook_routes), "Missing billing webhook route"


@check("Security config is sane")
def check_security_config(app):
Expand Down
138 changes: 120 additions & 18 deletions docs/billing.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
# Billing

Stripe integration for subscriptions and payments.
Subscription billing with Stripe or Chargebee.

## Overview

ReadyKit uses Stripe's hosted pages for billing - no custom checkout UI to build or maintain. Users upgrade via Stripe Checkout and manage subscriptions through the Stripe Customer Portal.
ReadyKit uses hosted payment pages - no custom checkout UI to build or maintain. Users upgrade via the provider's checkout page and manage subscriptions through their portal.

::: warning Choose Your Provider First
Select your billing provider before going to production. Switching providers after users have subscribed requires manual migration of customer data. Set `BILLING_PROVIDER` in your environment and stick with it.
:::

## Supported Providers

| Provider | Best For |
|----------|----------|
| **Stripe** | Most SaaS apps, US/EU focus, extensive API |
| **Chargebee** | Complex billing needs, subscription management, international |

## Plans

Expand All @@ -31,6 +42,8 @@ From [Stripe Dashboard](https://dashboard.stripe.com):

```bash
# .env
BILLING_PROVIDER=stripe

STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_PRO_PRICE_ID=price_...
Expand All @@ -45,25 +58,68 @@ PRO_PRICE_INTERVAL=month

In Stripe Dashboard → Webhooks → Add endpoint:

- **URL**: `https://yourdomain.com/api/webhooks/stripe/webhook`
- **URL**: `https://yourdomain.com/stripe/webhook`
- **Events to listen for**:
- `checkout.session.completed`
- `customer.subscription.deleted`
- `invoice.payment_failed`

Copy the signing secret to `STRIPE_WEBHOOK_SECRET`.

## Chargebee Setup

### 1. Get Your Credentials

From [Chargebee Dashboard](https://app.chargebee.com):

1. **Settings → API Keys** → Copy your API key
2. **Product Catalog → Items** → Create an item with a price
3. **Settings → Webhooks** → Add endpoint (see below)

### 2. Configure Environment

```bash
# .env
BILLING_PROVIDER=chargebee

CHARGEBEE_SITE=your-site # e.g., "acme" for acme.chargebee.com
CHARGEBEE_API_KEY=your_api_key
CHARGEBEE_PRO_ITEM_PRICE_ID=Pro-Plan-USD-Monthly

# Webhook authentication (required in production)
CHARGEBEE_WEBHOOK_USERNAME=webhook_user
CHARGEBEE_WEBHOOK_PASSWORD=your_secure_password

# Display values (shown in UI)
PRO_PRICE_DISPLAY=$29
PRO_PRICE_INTERVAL=month
```

### 3. Set Up Webhook

In Chargebee Dashboard → Settings → Webhooks → Add webhook:

- **URL**: `https://yourdomain.com/chargebee/webhook`
- **Authentication**: Basic Auth with your configured username/password
- **Events to listen for**:
- `subscription_cancelled`
- `payment_failed`

::: info Chargebee Webhook Security
Chargebee uses HTTP Basic Auth for webhook verification (not HMAC signatures like Stripe). Always configure `CHARGEBEE_WEBHOOK_USERNAME` and `CHARGEBEE_WEBHOOK_PASSWORD` in production. Unauthenticated webhooks are only allowed in debug mode for local testing.
:::

## How Billing Works

### Upgrade Flow

```
User clicks "Upgrade"
→ Create Stripe Checkout session
→ Redirect to Stripe
→ Create checkout session
→ Redirect to provider's hosted page
→ User completes payment
Stripe redirects to success URL
→ Validate session_id
Provider redirects to success URL
→ Validate session
→ Upgrade workspace to Pro
```

Expand All @@ -83,12 +139,13 @@ def upgrade(workspace_id):

### Success Callback

The success URL includes a `session_id` that's validated server-side:
The success URL includes a session ID that's validated server-side:

```python
@app.route("/billing/success")
def billing_success():
session_id = request.args.get("session_id")
# Works with both Stripe (session_id) and Chargebee (id)
session_id = request.args.get("session_id") or request.args.get("id")
workspace_id = HostedBilling.handle_successful_payment(session_id)

if workspace_id:
Expand All @@ -100,24 +157,24 @@ def billing_success():
```

::: info
The `session_id` is the security token. Always validate it via Stripe API before upgrading - never trust URL parameters directly.
The session ID is the security token. Always validate it via the provider's API before upgrading - never trust URL parameters directly.
:::

### Manage Billing (Customer Portal)

Existing Pro users can manage their subscription through Stripe's Customer Portal:
Existing Pro users can manage their subscription through the provider's portal:

```python
@app.route("/workspace/<int:workspace_id>/billing/")
@require_workspace_access("admin")
def manage_billing(workspace_id):
workspace = g.current_workspace

if not workspace.stripe_customer_id:
if not workspace.billing_customer_id:
return redirect(url_for("portal.upgrade", workspace_id=workspace_id))

session = HostedBilling.create_portal_session(
customer_id=workspace.stripe_customer_id,
customer_id=workspace.billing_customer_id,
workspace_id=workspace_id,
base_url=request.host_url
)
Expand All @@ -128,20 +185,37 @@ def manage_billing(workspace_id):

Webhooks update workspace status automatically when billing changes:

### Stripe Events

| Event | Action |
|-------|--------|
| `checkout.session.completed` | Upgrade workspace to Pro, save customer_id |
| `customer.subscription.deleted` | Downgrade workspace to Free |
| `invoice.payment_failed` | Downgrade workspace to Free |

### Chargebee Events

| Event | Action |
|-------|--------|
| `subscription_cancelled` | Downgrade workspace to Free |
| `payment_failed` | Downgrade workspace to Free |

::: tip Chargebee Upgrades
Chargebee upgrades are handled via the redirect flow only (not webhooks). This is intentional - the webhook would arrive after the redirect in most cases anyway.
:::

### Idempotency

Webhooks are idempotent - duplicate events are safely ignored using the `StripeEvent` model:
Webhooks are idempotent - duplicate events are safely ignored using the `BillingEvent` model:

```python
# Duplicate events are caught by unique constraint
try:
db.session.add(StripeEvent(event_id=event_id, event_type=event.type))
db.session.add(BillingEvent(
event_id=event_id,
event_type=event_type,
provider="stripe" # or "chargebee"
))
db.session.commit()
except IntegrityError:
db.session.rollback()
Expand Down Expand Up @@ -173,6 +247,8 @@ For web pages, it redirects to the upgrade page.

## Testing Locally

### Stripe

Use [Stripe CLI](https://stripe.com/docs/stripe-cli) to test webhooks locally:

```bash
Expand All @@ -183,13 +259,29 @@ brew install stripe/stripe-cli/stripe
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:5000/api/webhooks/stripe/webhook
stripe listen --forward-to localhost:5000/stripe/webhook

# In another terminal, trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
```

### Chargebee

Use [ngrok](https://ngrok.com) to expose your local server:

```bash
# Start ngrok
ngrok http 5000

# In Chargebee Dashboard:
# 1. Add webhook URL: https://your-ngrok-url.ngrok.io/chargebee/webhook
# 2. Set Basic Auth credentials matching your .env
# 3. Trigger test events from the webhook settings page
```

For local testing without authentication, set `FLASK_DEBUG=1` - webhooks will be accepted without Basic Auth in debug mode.

## Checking Plan Status

```python
Expand Down Expand Up @@ -217,10 +309,20 @@ if workspace.is_pro:
class Workspace(db.Model):
# Billing fields
plan = db.Column(db.String(20), default="free") # "free" or "pro"
stripe_customer_id = db.Column(db.String(255)) # Stripe customer ID
upgraded_at = db.Column(db.DateTime) # When they upgraded
billing_customer_id = db.Column(db.String(255)) # Provider customer ID
upgraded_at = db.Column(db.DateTime) # When they upgraded

@property
def is_pro(self):
return self.plan == "pro"
```

## Provider Comparison

| Feature | Stripe | Chargebee |
|---------|--------|-----------|
| Webhook auth | HMAC signature | Basic Auth |
| Upgrade via | Redirect + Webhook | Redirect only |
| Portal URL field | `.url` | `.access_url` (wrapped) |
| Session param | `session_id` | `id` |
| Customer ID location | `session.customer` | `hosted_page.content["customer"]["id"]` |
Loading