---
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](#english) | [Bahasa Indonesia](#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_id`s 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
```typescript
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(), // Stripe/Midtrans customer reference
subscriptionId: text("subscription_id").primaryKey(), // Gateway subscription reference
status: text("status").notNull(), // trialing, active, past_due, canceled
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
```typescript
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 });
}
// Handle Event
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 });
}
```