Billing#
All billing is per-user, processed through Stripe.
Billing Modes#
| Mode | Description |
|---|---|
| Subscription | Monthly/yearly, auto-renewal |
| Credits | One-time credit pack purchase |
| Lifetime | One-time payment, permanent access |
Stripe Webhook#
Endpoint URL: https://api.yourdomain.com/v1/webhook/stripe
Configure in Stripe Dashboard → Webhooks.
Required Events#
| Event | Handling |
|---|---|
checkout.session.completed | Credit purchase, lifetime deal completed |
customer.subscription.created | Create subscription record |
customer.subscription.updated | Upgrade/downgrade, cancel renewal |
customer.subscription.deleted | Subscription canceled |
invoice.payment_succeeded | Invoice payment success |
invoice.payment_failed | Payment failed |
invoice.paid | Invoice paid, award credits |
Setup Steps#
- Stripe Dashboard → Webhooks → Add endpoint
- Endpoint URL:
https://api.yourdomain.com/v1/webhook/stripe - Select the events above
- Copy Webhook signing secret →
.envSTRIPE_WEBHOOK_SECRET_KEY
Products & Prices#
Products and prices are created in Stripe Dashboard and auto-synced to local database (products and prices tables) via Webhooks.
The admin panel "Products" and "Prices" pages display synced data.
Add a Subscription Plan#
1. Create Product & Price in Stripe Dashboard#
- Go to Stripe Dashboard → Products
- Click Add product
- Fill in product name (e.g. "Pro Plan")
- Add Features — they sync to the frontend display
- Add a Price:
- Subscription: choose Recurring, set amount and interval (monthly/yearly)
- Lifetime: choose One time, set amount
2. Set Price Metadata#
In the Stripe Price metadata:
| Key | Description | Example |
|---|---|---|
credits | Credits granted on purchase / renewal | 1000 |
plan | Plan identifier used in code | pro |
3. Sync to Local DB#
In /owner/products admin page, click Sync from Stripe to import products and prices into local tables.
4. Auto Display#
Once synced, the /subscription page renders new plans automatically — no frontend change needed.
Check User Status (Backend)#
In any service or route handler, call getActiveSubscriptionByUser(user_id) and inspect rows by current_period_end to distinguish subscription vs lifetime:
typescriptimport { getActiveSubscriptionByUser } from "@/server/modules/billing/subscription.service"
const res = await getActiveSubscriptionByUser(user_id)
const rows = res.data
// Has any active paid plan (subscription or lifetime)
const hasAny = rows.length > 0
// Has lifetime — current_period_end IS NULL means never expires
const hasLifetime = rows.some((s) => s.current_period_end === null)
// Has active subscription (non-lifetime)
const hasSubscription = rows.some((s) => s.current_period_end !== null)
// Subscribed to a specific plan (using price metadata.plan)
const hasPro = rows.some((s) => s.price_metadata?.plan === "pro")
if (!hasAny) {
throw new Error("Subscription required")
}
The SQL already filters
status = 'active'andcurrent_period_end IS NULL OR current_period_end > now, sorows.length > 0means "currently active".
Check User Status (Frontend)#
Use useBillingStore, which keeps subscription / lifetime / credits separated:
typescriptimport { useBillingStore } from "@/lib/stores/billing"
function MyPage() {
const { subscription, lifetime, hasSubscription, hasLifetime, getTotalCredits } = useBillingStore()
// Has active subscription
if (hasSubscription()) { /* ... */ }
// Has lifetime
if (hasLifetime()) { /* ... */ }
// Total available credits
const credits = getTotalCredits()
// Specific plan: subscription is an array, includes product_name
const isPro = subscription?.some((s) => s.product_name === "Pro Plan")
return <div>...</div>
}
Data comes from
/v1/subscription/active,/v1/lifetime/active,/v1/credits/active._app/route.tsxcallsfetchAll()after login, so child pages can just read the store.