Billing#

All billing is per-user, processed through Stripe.

Billing Modes#

ModeDescription
SubscriptionMonthly/yearly, auto-renewal
CreditsOne-time credit pack purchase
LifetimeOne-time payment, permanent access

Stripe Webhook#

Endpoint URL: https://api.yourdomain.com/v1/webhook/stripe

Configure in Stripe Dashboard → Webhooks.

Required Events#

EventHandling
checkout.session.completedCredit purchase, lifetime deal completed
customer.subscription.createdCreate subscription record
customer.subscription.updatedUpgrade/downgrade, cancel renewal
customer.subscription.deletedSubscription canceled
invoice.payment_succeededInvoice payment success
invoice.payment_failedPayment failed
invoice.paidInvoice paid, award credits

Setup Steps#

  1. Stripe Dashboard → Webhooks → Add endpoint
  2. Endpoint URL: https://api.yourdomain.com/v1/webhook/stripe
  3. Select the events above
  4. Copy Webhook signing secret → .env STRIPE_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#

  1. Go to Stripe Dashboard → Products
  2. Click Add product
  3. Fill in product name (e.g. "Pro Plan")
  4. Add Features — they sync to the frontend display
  5. 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:

KeyDescriptionExample
creditsCredits granted on purchase / renewal1000
planPlan identifier used in codepro

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' and current_period_end IS NULL OR current_period_end > now, so rows.length > 0 means "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.tsx calls fetchAll() after login, so child pages can just read the store.