| name | saas-billing |
| description | Implement and audit SaaS billing systems, subscription state machines, secure webhooks, and local database synchronization / Implementasi dan audit sistem billing SaaS, state machine langganan, webhook aman, dan sinkronisasi database lokal. |
| author | Roedy Rustam |
SaaS Billing & Subscription Management
English | Bahasa Indonesia
English
Description
This skill provides complete guidelines for implementing and auditing SaaS billing systems, subscription state machines, secure webhook architectures, and local database synchronization using Stripe, Midtrans, or Paddle.
Trigger Conditions
Use this skill when:
- Designing or auditing SaaS billing flows (pricing pages, checkout sessions, customer portals).
- Setting up webhook endpoints and signature verification.
- Designing database schemas or state machines to track subscription statuses (active, past_due, trialing, canceled).
- Implementing multi-tier pricing strategies (per-seat, tiered, usage-based billing).
- Handling subscription lifecycle transitions, failures (dunning), and upgrades/downgrades.
Core Architecture
1. Database Subscription State Machine
Your database must act as the source of truth for authorization, but synchronize with the payment provider. Ensure the following subscription states are tracked:
trialing: User has access to trial features. Track trial_end timestamp.
active: Current billing cycle paid. Access fully granted.
past_due: Payment failed. Do not immediately revoke access; trigger dunning emails and display grace period warning in UI.
canceled: Subscription ended. Revoke premium features and prompt for reactivation.
2. Secure Webhook Implementation
- Dedicated Entry Point (
multiple-entry-points): Isolate webhook processing into its own entry point (e.g., webhook.php or a separate edge function). This avoids bootstrapping heavy frontend/template engines and ensures rapid response times to the payment gateway.
- Signature Verification: Always verify webhook signatures using the gateway's official SDK and webhook secret. Never trust raw request bodies.
- Idempotency: Webhook events might be delivered multiple times. Save handled
event_ids to a database table to avoid duplicate processing.
- Async Processing: Return a
200 OK status immediately to the gateway, then process the webhook body asynchronously (e.g., via background queues) to avoid gateway timeout.
3. Code Example: Drizzle Schema
import { pgTable, uuid, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const subscriptions = pgTable("subscriptions", {
id: uuid("id").defaultRandom().primaryKey(),
tenantId: uuid("tenant_id").notNull(),
customerId: text("customer_id").notNull(),
subscriptionId: text("subscription_id").primaryKey(),
status: text("status").notNull(),
priceId: text("price_id").notNull(),
currentPeriodEnd: timestamp("current_period_end").notNull(),
cancelAtPeriodEnd: boolean("cancel_at_period_end").default(false),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
Bahasa Indonesia
Deskripsi
Skill ini memberikan pedoman lengkap untuk mengimplementasikan dan mengaudit sistem billing SaaS, state machine langganan, arsitektur webhook yang aman, dan sinkronisasi database lokal menggunakan Stripe, Midtrans, atau Paddle.
Kondisi Pemicu
Gunakan skill ini ketika:
- Merancang atau mengaudit alur billing SaaS (halaman harga, checkout, portal pelanggan).
- Menyiapkan endpoint webhook dan verifikasi tanda tangan (signature verification).
- Merancang skema database atau state machine untuk melacak status langganan (active, past_due, trialing, canceled).
- Mengimplementasikan strategi penagihan multi-tier (per-seat, berjenjang, usage-based).
- Menangani transisi siklus hidup langganan, kegagalan pembayaran (dunning), serta upgrade/downgrade.
Arsitektur Inti
1. State Machine Database Langganan
Database Anda harus bertindak sebagai sumber kebenaran (source of truth) untuk otorisasi, tetapi tersinkronisasi dengan penyedia pembayaran. Pastikan status langganan berikut dilacak:
trialing: Pengguna memiliki akses ke fitur uji coba. Lacak timestamp trial_end.
active: Siklus penagihan saat ini lunas. Akses diberikan penuh.
past_due: Pembayaran gagal. Jangan langsung mencabut akses; kirim email penagihan ulang (dunning) dan tampilkan peringatan masa tenggang di UI.
canceled: Langganan berakhir. Cabut fitur premium dan tawarkan reaktivasi.
2. Implementasi Webhook Aman
- Entry Point Terdedikasi (
multiple-entry-points): Isolasi pemrosesan webhook ke dalam entry point tersendiri (misal: webhook.php atau edge function). Ini menghindari pemuatan mesin template/frontend yang berat dan memastikan waktu respons yang sangat cepat ke payment gateway.
- Verifikasi Tanda Tangan: Selalu verifikasi signature webhook menggunakan SDK resmi gateway dan webhook secret. Jangan pernah mempercayai body request mentah.
- Idempotensi: Event webhook dapat dikirim lebih dari sekali. Simpan
event_id yang telah diproses ke dalam tabel database untuk menghindari pemrosesan ganda.
- Pemrosesan Asinkron: Kembalikan status
200 OK segera ke payment gateway, lalu proses webhook di background queue agar koneksi tidak mengalami timeout.
3. Contoh Kode: Handler Webhook Next.js 15
import { NextResponse } from "next/server";
import stripe from "@/lib/stripe";
import { db } from "@/db";
import { subscriptions } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature") as string;
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err: any) {
return new NextResponse(`Webhook Error: ${err.message}`, { status: 400 });
}
switch (event.type) {
case "customer.subscription.updated":
case "customer.subscription.deleted":
const subscription = event.data.object;
await db.update(subscriptions)
.set({
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
})
.where(eq(subscriptions.subscriptionId, subscription.id));
break;
}
return NextResponse.json({ received: true });
}