Pengukuran Penggunaan dan Penegakan Langganan
Lacak penggunaan setiap penyewa dan batasi akses berdasarkan batas paket serta status penagihan.
Pengukuran Penggunaan dan Penegakan Langganan adalah pelajaran Next.js 15 Fullstack (App Router + Server Actions) gratis di CoddyKit. Ini adalah pelajaran 4 dari 4. Kamu bisa membaca pelajaran lengkapnya di bawah secara gratis — lalu praktikkan langsung di browser dengan editor kode bawaan dan tutor AI 24/7. Ini adalah bagian dari jalur belajar Next.js 15 Fullstack (App Router + Server Actions), dan progresmu tersinkronisasi di web dan aplikasi CoddyKit. Kursus Next.js 15 Fullstack (App Router + Server Actions) mencakup 4 pelajaran total.
Mengapa Metering Penggunaan Penting dalam SaaS
Dalam aplikasi SaaS multitenant, pelanggan yang berbeda membayar untuk tingkatan yang berbeda. Paket Starter mungkin mengizinkan 1.000 panggilan API per bulan, sedangkan paket Enterprise mengizinkan akses tanpa batas. Tanpa metering penggunaan, setiap tenant mendapatkan pengalaman yang sama, terlepas dari jumlah yang mereka bayarkan.
Metering penggunaan memiliki dua tujuan:
- Penegakan: Memblokir atau menurunkan kualitas layanan saat batas tercapai
- Sinyal penagihan: Mengirimkan data konsumsi yang akurat ke penyedia penagihan Anda (misalnya Stripe)
Dalam Next.js 15 dengan App Router, metering terintegrasi secara alami dengan Server Actions dan Route Handlers — dua tempat tempat pekerjaan sebenarnya dijalankan di server.
Model Data: Tenant, Paket, dan Penggunaan
Mulailah dengan skema yang mencatat hubungan antara tenant, paket langganan aktifnya, dan penghitung penggunaannya saat ini. Skema Postgres minimal dapat terlihat seperti berikut:
tenants— satu baris untuk setiap organisasisubscription_plans— batas setiap fitur untuk setiap tingkatantenant_usage— penghitung berjalan yang diatur ulang pada setiap siklus penagihan
Menyimpan penggunaan dalam tabel khusus (alih-alih menjumlahkan log peristiwa pada setiap permintaan) membuat pemeriksaan batas cukup dilakukan dengan satu pembacaan terindeks, yang sangat penting bagi Server Actions dengan latensi rendah.
// types/billing.ts
export type PlanTier = 'starter' | 'pro' | 'enterprise';
export interface SubscriptionPlan {
id: string;
tier: PlanTier;
limits: {
apiCallsPerMonth: number; // -1 = unlimited
teamMembers: number;
storageMb: number;
};
}
export interface TenantUsage {
tenantId: string;
billingPeriodStart: Date;
apiCallsUsed: number;
teamMembersActive: number;
storageMbUsed: number;
}
export interface Tenant {
id: string;
name: string;
planId: string;
subscriptionStatus: 'active' | 'past_due' | 'canceled' | 'trialing';
}Menentukan Tenant Saat Ini di App Router
Setiap pemeriksaan penegakan perlu mengetahui tenant mana yang sedang bertindak. Dalam App Router, identitas tenant biasanya disandikan dalam JWT sesi atau diturunkan dari subdomain. Fungsi utilitas bersama akan menentukannya sekali dan melemparkan galat jika pengguna belum terautentikasi.
Menempatkannya dalam modul lib/tenant.ts membuat Server Actions dan Route Handlers tetap DRY — keduanya memanggil resolver yang sama, alih-alih masing-masing mengurai sesi secara terpisah.
// lib/tenant.ts
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
export interface TenantContext {
tenantId: string;
userId: string;
planId: string;
subscriptionStatus: string;
}
export async function requireTenantContext(): Promise<TenantContext> {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => cookieStore.getAll() } }
);
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) throw new Error('Unauthenticated');
const { data: membership } = await supabase
.from('tenant_memberships')
.select('tenant_id, tenants(plan_id, subscription_status)')
.eq('user_id', user.id)
.single();
if (!membership) throw new Error('No tenant found for user');
return {
tenantId: membership.tenant_id,
userId: user.id,
planId: (membership.tenants as any).plan_id,
subscriptionStatus: (membership.tenants as any).subscription_status,
};
}Memeriksa Status Langganan Sebelum Tindakan Apa Pun
Sebelum memeriksa batas penggunaan, selalu pastikan bahwa langganan tenant berada dalam kondisi baik. Akun past_due atau canceled harus diblokir meskipun belum mencapai batas penggunaannya.
Sentralisasikan logika ini dalam fungsi penjaga yang dapat dipanggil oleh Server Actions dan Route Handlers. Lemparkan galat bertipe agar pemanggil dapat menampilkan UI yang sesuai (misalnya banner "Aktifkan kembali langganan").
// lib/billing-guard.ts
import { requireTenantContext, TenantContext } from './tenant';
export class SubscriptionError extends Error {
constructor(
message: string,
public readonly code: 'SUBSCRIPTION_INACTIVE' | 'LIMIT_EXCEEDED' | 'FEATURE_NOT_AVAILABLE'
) {
super(message);
this.name = 'SubscriptionError';
}
}
const ACTIVE_STATUSES = new Set(['active', 'trialing']);
export async function assertActiveSubscription(ctx?: TenantContext): Promise<TenantContext> {
const tenantCtx = ctx ?? await requireTenantContext();
if (!ACTIVE_STATUSES.has(tenantCtx.subscriptionStatus)) {
throw new SubscriptionError(
`Subscription is ${tenantCtx.subscriptionStatus}. Please update your billing details.`,
'SUBSCRIPTION_INACTIVE'
);
}
return tenantCtx;
}Penambahan Penggunaan Atomik dengan Pemeriksaan Batas
Bagian paling penting dari metering adalah pemeriksaan dan penambahan secara atomik. Pola naif — membaca penggunaan saat ini, membandingkannya dengan batas, lalu menulis — memiliki kondisi balapan: dua permintaan bersamaan dapat sama-sama lolos pemeriksaan sebelum salah satunya menambah penghitung.
Pendekatan yang benar menggunakan satu pernyataan SQL yang memeriksa dan menambah secara atomik, lalu mengembalikan apakah operasi berhasil. Dalam Postgres, pendekatan ini berupa UPDATE ... RETURNING bersyarat atau prosedur tersimpan.
// lib/usage.ts
import { createClient } from '@/lib/supabase-admin';
export async function incrementApiCallUsage(
tenantId: string,
limit: number
): Promise<{ allowed: boolean; used: number }> {
const supabase = createClient();
// Atomic: only increment if under the limit (-1 means unlimited)
const { data, error } = await supabase.rpc('increment_api_usage', {
p_tenant_id: tenantId,
p_limit: limit,
});
if (error) throw new Error(`Usage increment failed: ${error.message}`);
return {
allowed: data.allowed as boolean,
used: data.new_count as number,
};
}
/*
Corresponding Postgres function (run once as a migration):
CREATE OR REPLACE FUNCTION increment_api_usage(
p_tenant_id UUID,
p_limit INT
) RETURNS JSON AS $$
DECLARE
v_current INT;
v_allowed BOOLEAN;
BEGIN
SELECT api_calls_used INTO v_current FROM tenant_usage
WHERE tenant_id = p_tenant_id
AND billing_period_start = date_trunc('month', now())
FOR UPDATE;
v_allowed := (p_limit = -1) OR (v_current < p_limit);
IF v_allowed THEN
UPDATE tenant_usage
SET api_calls_used = api_calls_used + 1
WHERE tenant_id = p_tenant_id
AND billing_period_start = date_trunc('month', now());
END IF;
RETURN json_build_object('allowed', v_allowed, 'new_count', v_current + 1);
END;
$$ LANGUAGE plpgsql;
*/Mengambil Batas Paket Saat Runtime
Batas paket harus diambil dari basis data (atau cache cepat) saat runtime — jangan pernah ditulis secara hardcode dalam logika aplikasi. Dengan begitu, Anda dapat mengubah batas paket tanpa melakukan deployment.
Lakukan caching data paket secara agresif: batas jarang berubah, sehingga cache dalam memori dengan TTL singkat (atau unstable_cache bawaan Next.js) menghindari perjalanan bolak-balik ke basis data pada setiap permintaan.
// lib/plans.ts
import { unstable_cache } from 'next/cache';
import { createClient } from '@/lib/supabase-admin';
import type { SubscriptionPlan } from '@/types/billing';
export const getPlanLimits = unstable_cache(
async (planId: string): Promise<SubscriptionPlan> => {
const supabase = createClient();
const { data, error } = await supabase
.from('subscription_plans')
.select('id, tier, limit_api_calls, limit_team_members, limit_storage_mb')
.eq('id', planId)
.single();
if (error || !data) throw new Error(`Plan ${planId} not found`);
return {
id: data.id,
tier: data.tier,
limits: {
apiCallsPerMonth: data.limit_api_calls,
teamMembers: data.limit_team_members,
storageMb: data.limit_storage_mb,
},
};
},
['plan-limits'],
{ revalidate: 300 } // 5-minute TTL
);Menggabungkan Semuanya dalam Server Action
Sekarang gabungkan penjaga, pencarian paket, dan penambahan penggunaan ke dalam satu Server Action. Action ini berjalan sepenuhnya di server; klien tidak pernah menyentuh logika penagihan.
Polanya selalu terdiri dari tiga langkah berikut:
- 1. Autentikasi — tentukan konteks tenant
- 2. Penjagaan — pastikan langganan aktif, ambil batas, dan periksa penggunaan
- 3. Eksekusi — jalankan logika bisnis sebenarnya
// app/actions/generate-report.ts
'use server';
import { requireTenantContext } from '@/lib/tenant';
import { assertActiveSubscription, SubscriptionError } from '@/lib/billing-guard';
import { getPlanLimits } from '@/lib/plans';
import { incrementApiCallUsage } from '@/lib/usage';
export async function generateReportAction(
reportType: string
): Promise<{ success: boolean; error?: string; reportId?: string }> {
try {
// Step 1: Resolve tenant
const ctx = await requireTenantContext();
// Step 2: Guard — subscription status + usage
await assertActiveSubscription(ctx);
const plan = await getPlanLimits(ctx.planId);
const { allowed, used } = await incrementApiCallUsage(
ctx.tenantId,
plan.limits.apiCallsPerMonth
);
if (!allowed) {
return {
success: false,
error: `Monthly API limit of ${plan.limits.apiCallsPerMonth} reached (used: ${used}). Upgrade your plan to continue.`,
};
}
// Step 3: Real work
const reportId = await createReport(ctx.tenantId, reportType);
return { success: true, reportId };
} catch (err) {
if (err instanceof SubscriptionError) {
return { success: false, error: err.message };
}
throw err; // unexpected errors bubble up
}
}
async function createReport(tenantId: string, type: string): Promise<string> {
// ... actual report generation logic
return crypto.randomUUID();
}Penegakan di Tingkat Middleware untuk Rute API
Server Actions sangat cocok untuk alur berbasis formulir, tetapi tenant juga menggunakan kuota melalui rute API publik (misalnya /api/v1/data). Terapkan metering di sini dengan pembungkus middleware yang dapat digunakan ulang, alih-alih menyalin-tempel kode penjagaan ke setiap Route Handler.
Pola pembungkus ini terkadang disebut rantai middleware API atau pabrik handler. Dengan begitu, setiap Route Handler dapat berfokus pada logika bisnis.
// lib/with-metering.ts
import { NextRequest, NextResponse } from 'next/server';
import { requireTenantContext } from './tenant';
import { assertActiveSubscription, SubscriptionError } from './billing-guard';
import { getPlanLimits } from './plans';
import { incrementApiCallUsage } from './usage';
type Handler = (req: NextRequest, ctx: { tenantId: string }) => Promise<NextResponse>;
export function withMetering(handler: Handler) {
return async (req: NextRequest): Promise<NextResponse> => {
try {
const tenant = await requireTenantContext();
await assertActiveSubscription(tenant);
const plan = await getPlanLimits(tenant.planId);
const { allowed } = await incrementApiCallUsage(
tenant.tenantId,
plan.limits.apiCallsPerMonth
);
if (!allowed) {
return NextResponse.json(
{ error: 'rate_limit_exceeded', message: 'Monthly API limit reached.' },
{ status: 429 }
);
}
return handler(req, { tenantId: tenant.tenantId });
} catch (err) {
if (err instanceof SubscriptionError) {
return NextResponse.json(
{ error: 'subscription_inactive', message: err.message },
{ status: 402 }
);
}
return NextResponse.json({ error: 'internal_error' }, { status: 500 });
}
};
}
// Usage in app/api/v1/data/route.ts:
// export const GET = withMetering(async (req, { tenantId }) => {
// const data = await fetchData(tenantId);
// return NextResponse.json(data);
// });Menyajikan Penggunaan kepada Tenant: Dasbor Penggunaan
Tenant perlu melihat penggunaan mereka agar dapat mengambil keputusan yang tepat tentang peningkatan paket. Titik akhir API penggunaan mengembalikan konsumsi periode saat ini beserta batas paket.
Mengembalikan used dan limit memungkinkan frontend menampilkan bilah kemajuan tanpa perlu mengetahui detail paket secara terpisah. Kembalikan bidang percentage yang telah dihitung sebelumnya di server untuk menyederhanakan klien.
// app/api/billing/usage/route.ts
import { NextResponse } from 'next/server';
import { requireTenantContext } from '@/lib/tenant';
import { getPlanLimits } from '@/lib/plans';
import { createClient } from '@/lib/supabase-admin';
export async function GET() {
const ctx = await requireTenantContext();
const plan = await getPlanLimits(ctx.planId);
const supabase = createClient();
const { data: usage } = await supabase
.from('tenant_usage')
.select('api_calls_used, storage_mb_used, team_members_active')
.eq('tenant_id', ctx.tenantId)
.gte('billing_period_start', new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString())
.single();
const apiCallsUsed = usage?.api_calls_used ?? 0;
const limit = plan.limits.apiCallsPerMonth;
return NextResponse.json({
planTier: plan.tier,
billingPeriod: new Date().toISOString().slice(0, 7),
apiCalls: {
used: apiCallsUsed,
limit,
percentage: limit === -1 ? 0 : Math.round((apiCallsUsed / limit) * 100),
unlimited: limit === -1,
},
storage: {
usedMb: usage?.storage_mb_used ?? 0,
limitMb: plan.limits.storageMb,
},
teamMembers: {
active: usage?.team_members_active ?? 0,
limit: plan.limits.teamMembers,
},
});
}Mengatur Ulang Penggunaan pada Perpanjangan Siklus Penagihan
Penghitung penggunaan harus diatur ulang saat periode penagihan diperpanjang. Pendekatan paling rapi adalah handler webhook Stripe yang mendengarkan peristiwa invoice.paid dan mengatur ulang penghitung untuk tenant tersebut.
Jangan pernah mengandalkan tugas cron yang memeriksa tanggal — tugas itu dapat meleset, berjalan dua kali, atau melewatkan perpanjangan. Peristiwa Stripe adalah sinyal otoritatif bahwa periode penagihan baru telah dimulai.
- Verifikasi tanda tangan webhook dengan
stripe.webhooks.constructEvent - Gunakan
upsertuntuk membuat atau mengatur ulang baristenant_usageuntuk periode baru - Simpan
stripe_subscription_idpada tenant agar Anda dapat menemukannya dari peristiwa tersebut
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@/lib/supabase-admin';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'invoice.paid') {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription as string;
const periodStart = new Date((invoice.period_start) * 1000);
const supabase = createClient();
const { data: tenant } = await supabase
.from('tenants')
.select('id')
.eq('stripe_subscription_id', subscriptionId)
.single();
if (tenant) {
await supabase.from('tenant_usage').upsert({
tenant_id: tenant.id,
billing_period_start: periodStart.toISOString(),
api_calls_used: 0,
storage_mb_used: 0,
team_members_active: 0,
}, { onConflict: 'tenant_id,billing_period_start' });
}
}
return NextResponse.json({ received: true });
}Pembatasan Fitur Berdasarkan Tingkatan Paket
Selain batas kuantitas (jumlah panggilan API), produk SaaS juga membatasi fitur berdasarkan tingkatan paket. Tenant Starter tidak boleh mengaktifkan SSO atau mengakses log audit, terlepas dari jumlah penggunaannya.
Modelkan feature flag sebagai peta statis yang menggunakan tingkatan sebagai kunci. Periksa fitur tersebut dalam Server Action atau Route Handler sebelum melanjutkan. Dengan begitu, definisi fitur tersimpan di satu tempat dan perubahan tingkatan cukup dilakukan pada kode.
// lib/features.ts
import type { PlanTier } from '@/types/billing';
const FEATURE_MAP: Record<PlanTier, Set<string>> = {
starter: new Set(['basic_reports', 'api_access']),
pro: new Set(['basic_reports', 'api_access', 'advanced_reports', 'webhooks', 'audit_log']),
enterprise: new Set([
'basic_reports', 'api_access', 'advanced_reports',
'webhooks', 'audit_log', 'sso', 'custom_roles', 'sla_support'
]),
};
export function hasFeature(tier: PlanTier, feature: string): boolean {
return FEATURE_MAP[tier]?.has(feature) ?? false;
}
// Usage in a Server Action:
// import { hasFeature } from '@/lib/features';
// import { SubscriptionError } from '@/lib/billing-guard';
//
// const plan = await getPlanLimits(ctx.planId);
// if (!hasFeature(plan.tier, 'sso')) {
// throw new SubscriptionError(
// 'SSO is only available on the Enterprise plan.',
// 'FEATURE_NOT_AVAILABLE'
// );
// }Pemeriksaan Pengetahuan: Penegakan Penggunaan Atomik
Sebuah tim sedang membangun aplikasi SaaS multitenant. Mereka menerapkan metering penggunaan untuk panggilan API seperti berikut:
- Baca
api_calls_useddari basis data - Jika
used < limit, lanjutkan - Setelah operasi selesai, tambahkan 1 ke
api_calls_used
Apa masalah utama dari pendekatan ini?
Ringkasan: Metering Penggunaan dan Penegakan Langganan
Sekarang Anda memiliki sistem metering siap produksi yang lengkap untuk SaaS multitenant berbasis Next.js 15. Berikut ringkasan pola utama yang telah dibahas:
- Model data bertipe: Pisahkan
subscription_plans(batas) daritenant_usage(penghitung) agar pembacaan berlangsung cepat dan batas dapat dikonfigurasi tanpa deployment. - Resolver konteks tenant: Satu fungsi
requireTenantContext()yang digunakan oleh semua Server Actions dan Route Handlers. - Penjaga status langganan: Selalu periksa status
activeatautrialingsebelum memeriksa batas — akunpast_duediblokir terlepas dari penggunaannya. - Penambahan atomik: Gunakan fungsi Postgres dengan
FOR UPDATEuntuk memeriksa dan menambah dalam satu pernyataan, sehingga menghilangkan kondisi balapan. - Batas paket yang di-cache: Gunakan
unstable_cachedengan TTL singkat agar data paket tidak diambil pada setiap permintaan. - Pembungkus yang dapat digunakan ulang:
withMetering()membungkus Route Handlers dengan rapi, sehingga logika bisnis terpisah dari logika penagihan. - Pengaturan ulang melalui webhook Stripe: Dengarkan
invoice.paiduntuk mengatur ulang penghitung penggunaan — jangan pernah mengandalkan tugas cron untuk menentukan batas periode penagihan. - Pembatasan fitur:
FEATURE_MAPstatis untuk setiap tingkatan mengontrol akses kemampuan secara terpisah dari batas kuantitas.
Menggabungkan pola-pola ini memberikan pengalaman layanan yang adil, dapat ditegakkan, dan transparan bagi setiap tenant, sekaligus melindungi infrastruktur Anda dari konsumsi berlebihan.
Belajar TypeScript dengan tutor AI — gratis
Tulis dan jalankan kode asli di browser kamu, dapatkan bantuan instan dari tutor AI 24/7, dan lanjutkan di mana kamu tinggalkan di web atau aplikasi.
- Kursus
- 22
- Pelajaran
- 88
Pertanyaan yang Sering Diajukan
Apakah pelajaran “Pengukuran Penggunaan dan Penegakan Langganan” gratis?
Ya — teks lengkap “Pengukuran Penggunaan dan Penegakan Langganan” gratis dibaca di sini di web. Untuk praktiknya secara interaktif (editor kode bawaan dan tutor AI 24/7) dan buka sisa kursus Next.js 15 Fullstack (App Router + Server Actions), upgrade ke CoddyKit PRO. Kursus Next.js 15 Fullstack (App Router + Server Actions) mencakup 4 pelajaran total.
Apa yang akan aku pelajari di “Pengukuran Penggunaan dan Penegakan Langganan”?
Lacak penggunaan setiap penyewa dan batasi akses berdasarkan batas paket serta status penagihan. Kamu berlatih Next.js 15 Fullstack (App Router + Server Actions) dengan kode praktik yang langsung kamu jalankan di browser, dan tutor AI 24/7 menjawab pertanyaanmu saat kamu mengerjakan pelajaran ini.
Apakah aku perlu pengalaman untuk memulai Next.js 15 Fullstack (App Router + Server Actions)?
Tidak diperlukan pengalaman sebelumnya. Next.js 15 Fullstack (App Router + Server Actions) di CoddyKit dirancang untuk pemula hingga pelajar tingkat lanjut, jadi kamu bisa memulai di sini atau dari awal dan belajar sesuai kecepatan kamu sendiri. Ini adalah pelajaran 4 dari 4.
Berapa lama pelajaran “Pengukuran Penggunaan dan Penegakan Langganan” memakan waktu?
Sebagian besar pelajaran CoddyKit memakan waktu sekitar 5–10 menit. Setiap pelajaran ringkas dan interaktif, jadi kamu membuat kemajuan stabil dan melanjutkan dari tempat kamu tinggalkan di web dan aplikasi.
Bisakah aku menulis dan menjalankan kode dalam pelajaran Next.js 15 Fullstack (App Router + Server Actions) ini?
Ya. Setiap pelajaran Next.js 15 Fullstack (App Router + Server Actions) menyertakan editor kode bawaan, jadi kamu menulis dan menjalankan kode nyata langsung di browser dan mendapatkan umpan balik AI instan — tidak diperlukan penyiapan lokal.
Semua pelajaran dalam kursus ini
- Resolusi Penyewa Berbasis Subdomain dan Jalur
- Pola Isolasi Data Penyewa Tingkat Baris
- Tema dan Bendera Fitur Per Penyewa
- Pengukuran Penggunaan dan Penegakan Langganan