Bundle: SaaS billing IDR + Midtrans untuk SMB
Stack lengkap untuk SaaS billing customer Indonesia: Astro front, Bun+Hono API, Drizzle+Postgres, Midtrans Snap untuk payment. Bisa ship dalam 2-3 minggu.
Apa ini
Resep stack yang saya pakai untuk 2 klien SaaS billing dengan customer Indonesia:
- SaaS billing untuk fotograf (Rp 99rb-299rb/bulan, 40 customer)
- SaaS booking klinik dental (Rp 199rb/bulan, 12 customer)
Goals: ship dalam 2-3 minggu, biaya infra < $30/bulan, customer Indonesia friction-less payment.
The stack
Frontend: Astro 6 + React islands (dashboard) + Tailwind
API: Bun 1.3 + Hono + Zod validation
Database: Postgres (Neon) + Drizzle ORM + Drizzle Migrate
Auth: Custom JWT (email + password) atau Lucia
Payment: Midtrans Snap (popup + redirect)
Email: Resend (transactional) atau Mailgun
Infra: Cloudflare Pages (frontend) + Hetzner VPS (API)
Monitoring: Sentry (error) + Plausible (analytics)
Mengapa Midtrans (bukan Stripe)
Stripe di Indonesia: tidak support Indonesian businesses untuk receive payment. Anda butuh US business entity atau payment gateway internasional yang punya banyak fee.
Midtrans: payment gateway Indonesia paling matang. Support:
- Bank transfer (BCA, Mandiri, BNI, Permata, dll)
- Virtual account (auto-verification)
- E-wallet (GoPay, OVO, ShopeePay, DANA)
- Credit card (Visa, Mastercard, JCB)
- Cardless installment (Akulaku, Kredivo)
- QRIS
Fee: 2.0-3.5% per transaksi tergantung payment method. Tidak ada monthly fee.
Alternative: Xendit (similar offering, lebih banyak feature B2B), tapi onboarding lebih lambat dan fee slightly lebih tinggi untuk small volume.
Setup Midtrans Snap
Snap = popup payment UI yang Midtrans host. Anda redirect user ke Snap, dapatkan callback saat selesai.
// api/payment/create.ts
import { Hono } from 'hono';
import { z } from 'zod';
import midtransClient from 'midtrans-client';
const snap = new midtransClient.Snap({
isProduction: process.env.NODE_ENV === 'production',
serverKey: process.env.MIDTRANS_SERVER_KEY!,
});
const createPaymentSchema = z.object({
planId: z.string(),
userId: z.string(),
});
const app = new Hono();
app.post('/payment/create', async (c) => {
const body = createPaymentSchema.parse(await c.req.json());
const plan = await db.plans.findById(body.planId);
const user = await db.users.findById(body.userId);
const orderId = `ORD-${Date.now()}-${user.id.slice(0, 8)}`;
const transaction = await snap.createTransaction({
transaction_details: {
order_id: orderId,
gross_amount: plan.priceIdr,
},
customer_details: {
first_name: user.name,
email: user.email,
},
item_details: [{
id: plan.id,
price: plan.priceIdr,
quantity: 1,
name: plan.name,
}],
enabled_payments: ['bca_va', 'bni_va', 'mandiri_va', 'permata_va', 'gopay', 'shopeepay', 'qris'],
});
await db.orders.create({
id: orderId,
userId: user.id,
planId: plan.id,
amount: plan.priceIdr,
status: 'pending',
snapToken: transaction.token,
});
return c.json({ token: transaction.token, redirectUrl: transaction.redirect_url });
});
export default app;
Webhook handler
Midtrans kirim webhook saat status payment berubah. Critical: verify signature untuk security.
// api/payment/webhook.ts
import crypto from 'node:crypto';
app.post('/payment/webhook', async (c) => {
const body = await c.req.json();
const { order_id, status_code, gross_amount, signature_key, transaction_status } = body;
// Verify signature
const expectedSig = crypto
.createHash('sha512')
.update(`${order_id}${status_code}${gross_amount}${process.env.MIDTRANS_SERVER_KEY}`)
.digest('hex');
if (signature_key !== expectedSig) {
return c.json({ error: 'Invalid signature' }, 401);
}
const order = await db.orders.findById(order_id);
if (!order) return c.json({ error: 'Order not found' }, 404);
// Update status based on Midtrans transaction_status
let newStatus: 'paid' | 'failed' | 'pending' = 'pending';
if (transaction_status === 'settlement' || transaction_status === 'capture') newStatus = 'paid';
if (['cancel', 'deny', 'expire', 'failure'].includes(transaction_status)) newStatus = 'failed';
await db.orders.update(order_id, { status: newStatus });
// If paid, activate subscription
if (newStatus === 'paid') {
await db.subscriptions.create({
userId: order.userId,
planId: order.planId,
validUntil: getValidUntil(order.planId),
});
await sendEmailReceipt(order.userId, order_id);
}
return c.json({ ok: true });
});
Frontend integration
Frontend pakai Snap.js dari Midtrans untuk render popup payment.
<!-- Astro page or React component -->
<script src="https://app.midtrans.com/snap/snap.js" data-client-key="YOUR_CLIENT_KEY"></script>
<button id="pay-btn">Subscribe Rp 99.000/bulan</button>
<script>
document.getElementById('pay-btn').addEventListener('click', async () => {
const res = await fetch('/api/payment/create', {
method: 'POST',
body: JSON.stringify({ planId: 'pro', userId: 'user_123' }),
});
const { token } = await res.json();
window.snap.pay(token, {
onSuccess: (result) => {
window.location.href = '/dashboard?payment=success';
},
onPending: (result) => {
window.location.href = '/dashboard?payment=pending';
},
onError: (result) => {
alert('Payment failed. Coba lagi.');
},
});
});
</script>
Recurring billing
Tantangan: Midtrans recurring tidak as smooth dari Stripe. Anda butuh:
- Setup “subscription” di Midtrans dashboard, atau
- Implement renewal logic manual (cron yang charge ulang setiap month)
Untuk SaaS small, saya rekomendasi pendekatan manual renewal:
// cron: setiap hari jam 02:00 WIB
async function processRenewals() {
const expiringSubs = await db.subscriptions.findExpiringInDays(3);
for (const sub of expiringSubs) {
// Kirim email reminder 3 hari sebelum expire
await sendRenewalReminder(sub.userId, sub.validUntil);
}
const expiredSubs = await db.subscriptions.findExpired();
for (const sub of expiredSubs) {
// Auto-create payment link untuk renewal
await createPaymentLink(sub);
await sendPaymentLinkEmail(sub.userId, sub.id);
}
}
Customer harus action sendiri untuk renew. Less ideal dari auto-charge, tapi compliant dengan regulasi Indonesia (tidak ada auto-debit kecuali ada explicit consent).
Schema database
Drizzle schema essentials:
// db/schema.ts
import { pgTable, text, integer, timestamp, boolean } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const plans = pgTable('plans', {
id: text('id').primaryKey(),
name: text('name').notNull(),
priceIdr: integer('price_idr').notNull(), // store as IDR cents (no decimals di IDR)
intervalDays: integer('interval_days').notNull(), // 30 for monthly, 365 for yearly
});
export const orders = pgTable('orders', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
planId: text('plan_id').notNull().references(() => plans.id),
amount: integer('amount').notNull(),
status: text('status', { enum: ['pending', 'paid', 'failed'] }).default('pending').notNull(),
snapToken: text('snap_token'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const subscriptions = pgTable('subscriptions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
planId: text('plan_id').notNull().references(() => plans.id),
validUntil: timestamp('valid_until').notNull(),
active: boolean('active').default(true).notNull(),
});
Cost (per bulan)
Untuk SaaS 50-200 paying customer Rp 99rb-299rb/bulan:
| Item | Cost (USD) |
|---|---|
| Cloudflare Pages (frontend) | $0 |
| Hetzner VPS CX21 (Bun + Hono API) | $5 |
| Neon Postgres (paid plan) | $19 |
| Resend (transactional email) | $0 (free tier sampai 3K emails/bulan) |
| Sentry (error monitoring) | $0 (developer plan) |
| Plausible (analytics) | $9 |
| Midtrans fees | 2.5-3% per transaksi |
| Total infra | $33/bulan |
Untuk 100 customer × Rp 199rb avg = Rp 19.9 juta/bulan gross. Margin sangat sehat.
Yang sering bermasalah
Webhook tidak deliver: Midtrans webhook kadang gagal kalau API Anda down 30+ detik. Implement retry queue Anda sendiri (BullMQ + Redis kalau perlu, atau simple cron untuk reprocess pending > 30 menit).
Virtual account expiry: VA Midtrans expire setelah 24 jam default. Customer yang bayar transfer ATM kadang skip ini. Set expiry 7 hari (configurable via custom_expiry parameter).
Currency display: di UI, format Rp 99.000 (titik untuk separator ribuan, tidak ada decimal). Banyak template international format pakai comma — confuse Indonesian user.
Tax (PPN 11%): untuk SaaS jual ke business customer, mungkin perlu PPN. Cek dengan accountant. Untuk consumer, biasanya tidak perlu sampai threshold tertentu.
Verdict
Recommended untuk SaaS billing customer Indonesia 2026.
Stack ini sudah saya ship 2 production project tanpa major issue. Pengalaman developer baik, customer experience smooth (Snap popup familiar untuk Indonesian user).
Total ship time: 2-3 minggu untuk MVP, 4-6 minggu untuk production-ready dengan webhook retry, email templates, admin dashboard.
Ditulis oleh Asti Larasati