Next.js 15 Fullstack (App Router + Server Actions) · Pelajaran

Pengukuran Penggunaan dan Penegakan Langganan

Lacak penggunaan setiap penyewa dan batasi akses berdasarkan batas paket serta status penagihan.

Pelajaran 4 dari 413 langkah

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 organisasi
  • subscription_plans — batas setiap fitur untuk setiap tingkatan
  • tenant_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 upsert untuk membuat atau mengatur ulang baris tenant_usage untuk periode baru
  • Simpan stripe_subscription_id pada 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:

  1. Baca api_calls_used dari basis data
  2. Jika used < limit, lanjutkan
  3. 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) dari tenant_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 active atau trialing sebelum memeriksa batas — akun past_due diblokir terlepas dari penggunaannya.
  • Penambahan atomik: Gunakan fungsi Postgres dengan FOR UPDATE untuk memeriksa dan menambah dalam satu pernyataan, sehingga menghilangkan kondisi balapan.
  • Batas paket yang di-cache: Gunakan unstable_cache dengan 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.paid untuk mengatur ulang penghitung penggunaan — jangan pernah mengandalkan tugas cron untuk menentukan batas periode penagihan.
  • Pembatasan fitur: FEATURE_MAP statis 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.

Gratis untuk memulai

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

  1. Resolusi Penyewa Berbasis Subdomain dan Jalur
  2. Pola Isolasi Data Penyewa Tingkat Baris
  3. Tema dan Bendera Fitur Per Penyewa
  4. Pengukuran Penggunaan dan Penegakan Langganan
← Kembali ke Next.js 15 Fullstack (App Router + Server Actions)