Skip to content

Commit 7b913b6

Browse files
authored
Merge pull request #4 from level09/chargebee-integration
Add Chargebee as alternative billing provider
2 parents c330dbd + 15fc624 commit 7b913b6

10 files changed

Lines changed: 594 additions & 232 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Production-ready Flask SaaS template**
44

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

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

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

2323
- **Multi-tenant workspaces** - Data isolation, scales from solo to teams
24-
- **Stripe billing** - Checkout, webhooks, customer portal
24+
- **Subscription billing** - Stripe or Chargebee, hosted checkout, webhooks, customer portal
2525
- **OAuth authentication** - Google & GitHub login
2626
- **Team collaboration** - Roles (admin/member), member management
2727
- **Modern stack** - Flask 3.1, Vue 3, Vuetify 3, PostgreSQL, Redis
@@ -48,6 +48,9 @@ cd readykit
4848
# 2. Configure (edit .env)
4949
GOOGLE_OAUTH_CLIENT_ID=your_id
5050
GOOGLE_OAUTH_CLIENT_SECRET=your_secret
51+
52+
# Billing: choose stripe (default) or chargebee
53+
BILLING_PROVIDER=stripe
5154
STRIPE_SECRET_KEY=sk_test_...
5255
STRIPE_PRO_PRICE_ID=price_...
5356

checks.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,19 @@ def check_billing_service(app):
100100

101101
assert callable(requires_pro_plan)
102102
assert hasattr(HostedBilling, "create_upgrade_session")
103+
assert hasattr(HostedBilling, "create_portal_session")
103104
assert hasattr(HostedBilling, "handle_successful_payment")
104105

105106

107+
@check("BillingEvent model exists")
108+
def check_billing_event_model(app):
109+
from enferno.user.models import BillingEvent
110+
111+
with app.app_context():
112+
assert hasattr(BillingEvent, "provider")
113+
BillingEvent.query.limit(1).all()
114+
115+
106116
@check("Auth decorators work")
107117
def check_auth_decorators(app):
108118
from enferno.services.auth import require_superadmin, require_superadmin_api
@@ -127,11 +137,15 @@ def check_routes(app):
127137
"/",
128138
"/login",
129139
"/dashboard/",
130-
"/stripe/webhook",
131140
]
141+
132142
for route in critical_routes:
133143
assert route in rules, f"Missing route: {route}"
134144

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

136150
@check("Security config is sane")
137151
def check_security_config(app):

docs/billing.md

Lines changed: 120 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
# Billing
22

3-
Stripe integration for subscriptions and payments.
3+
Subscription billing with Stripe or Chargebee.
44

55
## Overview
66

7-
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.
7+
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.
8+
9+
::: warning Choose Your Provider First
10+
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.
11+
:::
12+
13+
## Supported Providers
14+
15+
| Provider | Best For |
16+
|----------|----------|
17+
| **Stripe** | Most SaaS apps, US/EU focus, extensive API |
18+
| **Chargebee** | Complex billing needs, subscription management, international |
819

920
## Plans
1021

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

3243
```bash
3344
# .env
45+
BILLING_PROVIDER=stripe
46+
3447
STRIPE_SECRET_KEY=sk_test_...
3548
STRIPE_PUBLISHABLE_KEY=pk_test_...
3649
STRIPE_PRO_PRICE_ID=price_...
@@ -45,25 +58,68 @@ PRO_PRICE_INTERVAL=month
4558

4659
In Stripe Dashboard → Webhooks → Add endpoint:
4760

48-
- **URL**: `https://yourdomain.com/api/webhooks/stripe/webhook`
61+
- **URL**: `https://yourdomain.com/stripe/webhook`
4962
- **Events to listen for**:
5063
- `checkout.session.completed`
5164
- `customer.subscription.deleted`
5265
- `invoice.payment_failed`
5366

5467
Copy the signing secret to `STRIPE_WEBHOOK_SECRET`.
5568

69+
## Chargebee Setup
70+
71+
### 1. Get Your Credentials
72+
73+
From [Chargebee Dashboard](https://app.chargebee.com):
74+
75+
1. **Settings → API Keys** → Copy your API key
76+
2. **Product Catalog → Items** → Create an item with a price
77+
3. **Settings → Webhooks** → Add endpoint (see below)
78+
79+
### 2. Configure Environment
80+
81+
```bash
82+
# .env
83+
BILLING_PROVIDER=chargebee
84+
85+
CHARGEBEE_SITE=your-site # e.g., "acme" for acme.chargebee.com
86+
CHARGEBEE_API_KEY=your_api_key
87+
CHARGEBEE_PRO_ITEM_PRICE_ID=Pro-Plan-USD-Monthly
88+
89+
# Webhook authentication (required in production)
90+
CHARGEBEE_WEBHOOK_USERNAME=webhook_user
91+
CHARGEBEE_WEBHOOK_PASSWORD=your_secure_password
92+
93+
# Display values (shown in UI)
94+
PRO_PRICE_DISPLAY=$29
95+
PRO_PRICE_INTERVAL=month
96+
```
97+
98+
### 3. Set Up Webhook
99+
100+
In Chargebee Dashboard → Settings → Webhooks → Add webhook:
101+
102+
- **URL**: `https://yourdomain.com/chargebee/webhook`
103+
- **Authentication**: Basic Auth with your configured username/password
104+
- **Events to listen for**:
105+
- `subscription_cancelled`
106+
- `payment_failed`
107+
108+
::: info Chargebee Webhook Security
109+
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.
110+
:::
111+
56112
## How Billing Works
57113

58114
### Upgrade Flow
59115

60116
```
61117
User clicks "Upgrade"
62-
→ Create Stripe Checkout session
63-
→ Redirect to Stripe
118+
→ Create checkout session
119+
→ Redirect to provider's hosted page
64120
→ User completes payment
65-
Stripe redirects to success URL
66-
→ Validate session_id
121+
Provider redirects to success URL
122+
→ Validate session
67123
→ Upgrade workspace to Pro
68124
```
69125

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

84140
### Success Callback
85141

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

88144
```python
89145
@app.route("/billing/success")
90146
def billing_success():
91-
session_id = request.args.get("session_id")
147+
# Works with both Stripe (session_id) and Chargebee (id)
148+
session_id = request.args.get("session_id") or request.args.get("id")
92149
workspace_id = HostedBilling.handle_successful_payment(session_id)
93150

94151
if workspace_id:
@@ -100,24 +157,24 @@ def billing_success():
100157
```
101158

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

106163
### Manage Billing (Customer Portal)
107164

108-
Existing Pro users can manage their subscription through Stripe's Customer Portal:
165+
Existing Pro users can manage their subscription through the provider's portal:
109166

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

116-
if not workspace.stripe_customer_id:
173+
if not workspace.billing_customer_id:
117174
return redirect(url_for("portal.upgrade", workspace_id=workspace_id))
118175

119176
session = HostedBilling.create_portal_session(
120-
customer_id=workspace.stripe_customer_id,
177+
customer_id=workspace.billing_customer_id,
121178
workspace_id=workspace_id,
122179
base_url=request.host_url
123180
)
@@ -128,20 +185,37 @@ def manage_billing(workspace_id):
128185

129186
Webhooks update workspace status automatically when billing changes:
130187

188+
### Stripe Events
189+
131190
| Event | Action |
132191
|-------|--------|
133192
| `checkout.session.completed` | Upgrade workspace to Pro, save customer_id |
134193
| `customer.subscription.deleted` | Downgrade workspace to Free |
135194
| `invoice.payment_failed` | Downgrade workspace to Free |
136195

196+
### Chargebee Events
197+
198+
| Event | Action |
199+
|-------|--------|
200+
| `subscription_cancelled` | Downgrade workspace to Free |
201+
| `payment_failed` | Downgrade workspace to Free |
202+
203+
::: tip Chargebee Upgrades
204+
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.
205+
:::
206+
137207
### Idempotency
138208

139-
Webhooks are idempotent - duplicate events are safely ignored using the `StripeEvent` model:
209+
Webhooks are idempotent - duplicate events are safely ignored using the `BillingEvent` model:
140210

141211
```python
142212
# Duplicate events are caught by unique constraint
143213
try:
144-
db.session.add(StripeEvent(event_id=event_id, event_type=event.type))
214+
db.session.add(BillingEvent(
215+
event_id=event_id,
216+
event_type=event_type,
217+
provider="stripe" # or "chargebee"
218+
))
145219
db.session.commit()
146220
except IntegrityError:
147221
db.session.rollback()
@@ -173,6 +247,8 @@ For web pages, it redirects to the upgrade page.
173247

174248
## Testing Locally
175249

250+
### Stripe
251+
176252
Use [Stripe CLI](https://stripe.com/docs/stripe-cli) to test webhooks locally:
177253

178254
```bash
@@ -183,13 +259,29 @@ brew install stripe/stripe-cli/stripe
183259
stripe login
184260

185261
# Forward webhooks to local server
186-
stripe listen --forward-to localhost:5000/api/webhooks/stripe/webhook
262+
stripe listen --forward-to localhost:5000/stripe/webhook
187263

188264
# In another terminal, trigger test events
189265
stripe trigger checkout.session.completed
190266
stripe trigger customer.subscription.deleted
191267
```
192268

269+
### Chargebee
270+
271+
Use [ngrok](https://ngrok.com) to expose your local server:
272+
273+
```bash
274+
# Start ngrok
275+
ngrok http 5000
276+
277+
# In Chargebee Dashboard:
278+
# 1. Add webhook URL: https://your-ngrok-url.ngrok.io/chargebee/webhook
279+
# 2. Set Basic Auth credentials matching your .env
280+
# 3. Trigger test events from the webhook settings page
281+
```
282+
283+
For local testing without authentication, set `FLASK_DEBUG=1` - webhooks will be accepted without Basic Auth in debug mode.
284+
193285
## Checking Plan Status
194286

195287
```python
@@ -217,10 +309,20 @@ if workspace.is_pro:
217309
class Workspace(db.Model):
218310
# Billing fields
219311
plan = db.Column(db.String(20), default="free") # "free" or "pro"
220-
stripe_customer_id = db.Column(db.String(255)) # Stripe customer ID
221-
upgraded_at = db.Column(db.DateTime) # When they upgraded
312+
billing_customer_id = db.Column(db.String(255)) # Provider customer ID
313+
upgraded_at = db.Column(db.DateTime) # When they upgraded
222314

223315
@property
224316
def is_pro(self):
225317
return self.plan == "pro"
226318
```
319+
320+
## Provider Comparison
321+
322+
| Feature | Stripe | Chargebee |
323+
|---------|--------|-----------|
324+
| Webhook auth | HMAC signature | Basic Auth |
325+
| Upgrade via | Redirect + Webhook | Redirect only |
326+
| Portal URL field | `.url` | `.access_url` (wrapped) |
327+
| Session param | `session_id` | `id` |
328+
| Customer ID location | `session.customer` | `hosted_page.content["customer"]["id"]` |

0 commit comments

Comments
 (0)