Bagian 1 sampai 3 membangun fondasi teknis multi-tenant; sekarang giliran alasan komersialnya: aplikasi SaaS hidit dari langganan. Toko Bunga Melati boleh memakai fitur gratis selamanya, tetapi jika ingin lebih dari tiga proyek, pemiliknya harus berlangganan plan Pro — dan ketika langganan itu berhenti dibayar, fitur harus menutup secara halus tanpa menghapus data. Pada bagian ini kita membangun dua tabel baru lewat migrasi (plans dan subscriptions), dua class baru (PlanRepository dan SubscriptionService), dan satu teknik yang membuat semuanya bisa diuji tanpa menunggu hari berganti: waktu yang dibekukan menjadi parameter.
Dua Tabel Baru: plans dan subscriptions #
Billing datang belakangan dalam hidup aplikasi sungguhan, jadi kita perlakukan begitu juga: dua tabel baru ditambahkan lewat berkas migrasi terpisah, bukan diedit langsung ke schema.sql Bagian 1. Inilah isi migrasi-billing.sql, yang dimuat pada koneksi yang sama setelah schema dasar:
CREATE TABLE IF NOT EXISTS plans (
kode TEXT PRIMARY KEY,
nama TEXT NOT NULL,
harga_bulanan INTEGER NOT NULL CHECK (harga_bulanan >= 0),
batas_proyek INTEGER NOT NULL CHECK (batas_proyek >= 1)
);
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL UNIQUE REFERENCES tenants(id),
plan_kode TEXT NOT NULL REFERENCES plans(kode),
mulai_pada TEXT NOT NULL,
berakhir_pada TEXT,
dibuat_pada TEXT NOT NULL
);Lima keputusan pada dua tabel tersebut perlu dibedah:
plans.kode TEXT PRIMARY KEY: identitas plan adalah kode string (free,pro), bukan integer autoincrement. Kode ini stabil, terbaca di URL dan log, dan dipakai subscriptions sebagai rujukan.harga_bulanan INTEGERdalam rupiah penuh: harga plan memang selalu bulat (Rp149.000), sehingga integer rupiah sudah eksak. Berbeda dengan proyek Aplikasi Keuangan Pribadi yang menyimpan uang dalam sen karena pecahannya nyata, di sini satuan sen tidak dibutuhkan — dan keputusan satuan uang memang selalu harus eksplisit.batas_proyek INTEGER: inilah fitur yang digerbang billing. Plan Gratis membatasi 3 proyek, plan Pro 25 proyek; gerbang penegakannya ada diSubscriptionService.subscriptions.tenant_id ... UNIQUE: satu tenant hanya punya satu baris langganan. Berpindah plan berarti memperbarui baris itu (upsert), bukan menambah baris — riwayat pergantian plan akan menjadi tugas audit trail pada Bagian 5.berakhir_pada TEXTyang boleh NULL: NULL berarti tanpa batas waktu, dipakai plan gratis yang tidak pernah kedaluwarsa. Plan berbayar mengisinya dengan tanggal 30 hari setelah pembayaran.
Class PlanRepository #
Repository pertama bagian ini membaca katalog plan — dan justru menarik karena query-nya tidak menyaring tenant_id. Katalog plan adalah data konfigurasi global: seluruh tenant membaca daftar plan yang sama, sama seperti tabel tenants pada Bagian 2. Berikut isi lengkap src/PlanRepository.php:
<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk tabel plans (katalog paket langganan).
*
* Katalog plan adalah data konfigurasi, bukan data milik tenant:
* seluruh tenant membaca daftar plan yang sama. Karena itu query
* di sini memang tidak menyaring tenant_id — kebalikan dari
* repository tabel milik tenant.
*/
final class PlanRepository
{
public function __construct(private PDO $pdo)
{
// PRAGMA foreign_keys berlaku per koneksi; constructor adalah
// titik yang pasti dilewati setiap kali repository dibuat.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Daftar seluruh plan, urut harga termurah lebih dulu.
*
* Urutan ini siap dipakai halaman harga: plan gratis tampil
* paling atas sebagai titik masuk, plan berbayar menyusul.
*
* @return list<array<string, mixed>> baris: kode, nama, harga_bulanan, batas_proyek
*/
public function listAll(): array
{
return $this->pdo
->query('SELECT kode, nama, harga_bulanan, batas_proyek FROM plans ORDER BY harga_bulanan')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Ambil satu plan berdasarkan kode; null bila tidak ditemukan.
*
* @return array<string, mixed>|null
*/
public function find(string $kode): ?array
{
$stmt = $this->pdo->prepare(
'SELECT kode, nama, harga_bulanan, batas_proyek FROM plans WHERE kode = :kode'
);
$stmt->execute(['kode' => $kode]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
}listAll() mengurutkan berdasarkan harga sehingga siap dipakai halaman harga, dan find() mengembalikan null untuk kode yang tidak ada — kontrak yang sama dengan findBySlug() pada Bagian 2. Keduanya kita uji sekaligus dengan katalog hasil migrasi:
<?php
require __DIR__ . '/src/PlanRepository.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
$pdo->exec(file_get_contents(__DIR__ . '/migrasi-billing.sql'));
$plans = new PlanRepository($pdo);
echo "Katalog plan:\n";
foreach ($plans->listAll() as $p) {
echo " [{$p['kode']}] {$p['nama']} — Rp"
. number_format((int) $p['harga_bulanan'], 0, ',', '.')
. "/bulan, batas {$p['batas_proyek']} proyek\n";
}
echo "\nfind('pro'):\n";
var_dump($plans->find('pro'));
echo "\nfind('enterprise'):\n";
var_dump($plans->find('enterprise'));Output yang Dihasilkan:
Katalog plan:
[free] Gratis — Rp0/bulan, batas 3 proyek
[pro] Pro Bulanan — Rp149.000/bulan, batas 25 proyek
find('pro'):
array(4) {
["kode"]=>
string(3) "pro"
["nama"]=>
string(11) "Pro Bulanan"
["harga_bulanan"]=>
int(149000)
["batas_proyek"]=>
int(25)
}
find('enterprise'):
NULLDua plan tampil urut dari yang gratis, dan find('enterprise') mengembalikan NULL — kode plan yang tidak terdaftar memang tidak boleh bisa dibeli, dan subscribe() akan menolaknya dengan InvalidArgumentException.
Waktu Beku sebagai Parameter #
Teknik terpenting bagian ini bukan SQL-nya, melainkan cara menangani waktu. Seluruh method SubscriptionService yang butuh waktu menerima DateTimeImmutable dari pemanggil — tidak ada satu pun pemanggilan time(), date(), atau new DateTimeImmutable('now') di dalam class. Waktu saat ini diperlakukan seperti dependency lain: disuntik dari luar.
Konsekuensinya besar. Menguji “apa yang terjadi 34 hari setelah berlangganan” tidak lagi butuh tidur sebulan atau memalsukan jam sistem — cukup membuat objek waktu baru yang menunjuk tanggal itu. Seluruh walkthrough pada bagian ini berjalan di satu proses PHP dengan empat titik waktu beku berbeda, dan test suite pada Bagian 6 akan memakai pola yang persis sama. Materi lengkap tentang DateTimeImmutable ada di chapter Date Time Immutable
.
Class SubscriptionService #
Class ini memegang seluruh siklus langganan. Karena isinya panjang, kita bedah per kelompok method. Berikut isi lengkap src/SubscriptionService.php:
<?php
declare(strict_types=1);
/**
* Thrown saat operasi billing melanggar batas plan.
*
* Dipakai khususnya oleh downgrade(): menurunkan plan ditolak
* bila penggunaan saat ini melebihi batas plan tujuan.
*/
final class BatasanPlanException extends RuntimeException
{
}
/**
* Siklus langganan dan gerbang fitur per tenant.
*
* Seluruh method yang membaca waktu menerima DateTimeImmutable
* dari pemanggil ("frozen now"). Tidak ada pemanggilan time()
* atau date() di dalam class — inilah yang membuat siklus 30 hari,
* masa tenggang, dan kedaluwarsa dapat diuji deterministik: tes
* cukup menjalankan waktu ke depan dengan membuat objek waktu
* baru, bukan menunggu hari berganti.
*
* Model status satu tenant pada satu titik waktu:
* - 'tidak-berlangganan' : tanpa baris subscription -> tier gratis implisit.
* - 'aktif' : dalam masa berlaku (atau plan tanpa batas).
* - 'tenggang' : lewat masa berlaku, masih dalam MASA_TENGGANG_HARI
* -> baca-saja, penulisan data ditolak.
* - 'kedaluwarsa' : lewat masa tenggang -> tier kembali ke batas plan
* gratis; data utuh, penulisan diizinkan selama masih
* di bawah batas gratis.
*/
final class SubscriptionService
{
/** Panjang periode plan berbayar, dalam hari. */
public const MASA_AKTIF_HARI = 30;
/** Masa tenggang baca-saja setelah masa berlaku habis, dalam hari. */
public const MASA_TENGGANG_HARI = 7;
/** Zona waktu yang dipakai menyimpan dan membaca seluruh waktu. */
private const ZONA_WAKTU = 'Asia/Jakarta';
public function __construct(
private PDO $pdo,
private PlanRepository $plans,
) {
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Mulai/perbarui langganan tenant ke sebuah plan.
*
* Plan gratis (harga 0) tidak memiliki masa berlaku
* (berakhir_pada NULL); plan berbayar berlaku MASA_AKTIF_HARI
* hari dihitung dari $now, sehingga memanggil ulang method ini
* saat masih aktif berarti perpanjangan. Baris lama tenant
* diperbarui lewat upsert — satu tenant selalu punya tepat
* satu baris subscription.
*
* @return array<string, mixed> baris subscription hasil operasi
*/
public function subscribe(int $tenantId, string $kodePlan, DateTimeImmutable $now): array
{
$plan = $this->plans->find($kodePlan);
if ($plan === null) {
throw new InvalidArgumentException(
"Plan tidak dikenal: '$kodePlan'"
);
}
$mulai = $now->format('Y-m-d H:i:s');
$berakhir = ((int) $plan['harga_bulanan'] > 0)
? $now->modify('+' . self::MASA_AKTIF_HARI . ' days')->format('Y-m-d H:i:s')
: null;
$stmt = $this->pdo->prepare(
'INSERT INTO subscriptions (tenant_id, plan_kode, mulai_pada, berakhir_pada, dibuat_pada)
VALUES (:tenant_id, :plan_kode, :mulai, :berakhir, :dibuat)
ON CONFLICT(tenant_id) DO UPDATE SET
plan_kode = excluded.plan_kode,
mulai_pada = excluded.mulai_pada,
berakhir_pada = excluded.berakhir_pada,
dibuat_pada = excluded.dibuat_pada'
);
$stmt->execute([
'tenant_id' => $tenantId,
'plan_kode' => $kodePlan,
'mulai' => $mulai,
'berakhir' => $berakhir,
'dibuat' => $mulai,
]);
return $this->rowByTenant($tenantId);
}
/**
* Status langganan tenant pada titik waktu $now.
*
* Lihat daftar empat status pada docblock class. Perbandingan
* waktu memakai string format 'Y-m-d H:i:s' — format ISO yang
* urut secara leksikografis — dengan seluruh nilai ditulis
* pada zona waktu yang sama (ZONA_WAKTU).
*/
public function status(int $tenantId, DateTimeImmutable $now): string
{
$row = $this->rowByTenant($tenantId);
if ($row === null) {
return 'tidak-berlangganan';
}
if ($row['berakhir_pada'] === null) {
return 'aktif';
}
$sekarang = $now->format('Y-m-d H:i:s');
if ($sekarang <= $row['berakhir_pada']) {
return 'aktif';
}
$batasTenggang = (new DateTimeImmutable($row['berakhir_pada'], new DateTimeZone(self::ZONA_WAKTU)))
->modify('+' . self::MASA_TENGGANG_HARI . ' days')
->format('Y-m-d H:i:s');
if ($sekarang <= $batasTenggang) {
return 'tenggang';
}
return 'kedaluwarsa';
}
/**
* Apakah langganan tenant aktif pada $now?
*
* Hanya status 'aktif' yang mengembalikan true — masa tenggang
* sengaja TIDAK dihitung aktif karena penulisan data harus
* sudah berhenti di sana.
*/
public function isActive(int $tenantId, DateTimeImmutable $now): bool
{
return $this->status($tenantId, $now) === 'aktif';
}
/**
* Batas jumlah proyek yang berlaku bagi tenant pada $now.
*
* Status aktif dan tenggang memakai batas plan yang tercatat;
* status kedaluwarsa dan tenant tanpa langganan memakai batas
* plan gratis — data tidak pernah dihapus, hanya pembuatannya
* yang dibatasi.
*/
public function batasProyekEfektif(int $tenantId, DateTimeImmutable $now): int
{
$status = $this->status($tenantId, $now);
if ($status === 'aktif' || $status === 'tenggang') {
$row = $this->rowByTenant($tenantId);
$plan = $this->plans->find($row['plan_kode']);
return (int) $plan['batas_proyek'];
}
return (int) $this->plans->find('free')['batas_proyek'];
}
/**
* Bolehkah tenant membuat proyek baru pada $now?
*
* Dua syarat: bukan sedang berada di masa tenggang (baca-saja),
* dan jumlah proyek saat ini masih di bawah batas efektif.
* Tenant tanpa langganan maupun yang kedaluwarsa tetap boleh
* menulis selama masih di bawah batas plan gratis.
*/
public function bolehBuatProyek(int $tenantId, DateTimeImmutable $now): bool
{
if ($this->status($tenantId, $now) === 'tenggang') {
return false;
}
return $this->jumlahProyek($tenantId) < $this->batasProyekEfektif($tenantId, $now);
}
/**
* Penjelasan mengapa tenant tidak boleh membuat proyek.
*
* Mengembalikan null bila pembuatan proyek justru diizinkan;
* pesan lengkap dengan angka dipakai antarmuka untuk 402/403
* yang informatif.
*/
public function alasanBlokir(int $tenantId, DateTimeImmutable $now): ?string
{
if ($this->bolehBuatProyek($tenantId, $now)) {
return null;
}
$status = $this->status($tenantId, $now);
$jumlah = $this->jumlahProyek($tenantId);
if ($status === 'tenggang') {
$row = $this->rowByTenant($tenantId);
return 'Masa tenggang sejak ' . $row['berakhir_pada']
. ' — data hanya dapat dibaca. Perpanjang langganan untuk membuka kembali penulisan data.';
}
$batas = $this->batasProyekEfektif($tenantId, $now);
$namaPlan = ($status === 'aktif')
? $this->plans->find($this->rowByTenant($tenantId)['plan_kode'])['nama']
: $this->plans->find('free')['nama'];
return "Batas plan $namaPlan: $batas proyek (saat ini $jumlah)."
. (($status === 'kedaluwarsa') ? ' Langganan kedaluwarsa — batas dikembalikan ke plan gratis.' : '');
}
/**
* Turunkan langganan tenant ke plan lain yang lebih rendah.
*
* Aturan gerbang: tenant dengan jumlah proyek melebihi batas
* plan tujuan TIDAK boleh menurun — hapus proyeknya lebih dulu.
* Tanpa aturan ini, downgrade akan meninggalkan tenant dengan
* lebih banyak proyek daripada batas barunya.
*/
public function downgrade(int $tenantId, string $kodePlan, DateTimeImmutable $now): void
{
$target = $this->plans->find($kodePlan);
if ($target === null) {
throw new InvalidArgumentException(
"Plan tidak dikenal: '$kodePlan'"
);
}
$jumlah = $this->jumlahProyek($tenantId);
$batas = (int) $target['batas_proyek'];
if ($jumlah > $batas) {
throw new BatasanPlanException(
'Downgrade ditolak: tenant #' . $tenantId . ' memiliki ' . $jumlah
. ' proyek, batas plan ' . $target['nama'] . ' hanya ' . $batas
. '. Hapus minimal ' . ($jumlah - $batas) . ' proyek terlebih dahulu.'
);
}
$this->subscribe($tenantId, $kodePlan, $now);
}
/**
* Jumlah proyek milik tenant — query tetap ter-scope tenant_id,
* konsisten dengan aturan isolasi Bagian 2.
*/
private function jumlahProyek(int $tenantId): int
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM projects WHERE tenant_id = :tenant_id'
);
$stmt->execute(['tenant_id' => $tenantId]);
return (int) $stmt->fetchColumn();
}
/**
* Baris subscription satu tenant; null bila belum berlangganan.
*
* @return array<string, mixed>|null
*/
private function rowByTenant(int $tenantId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, tenant_id, plan_kode, mulai_pada, berakhir_pada, dibuat_pada
FROM subscriptions WHERE tenant_id = :tenant_id'
);
$stmt->execute(['tenant_id' => $tenantId]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
}Tujuh hal pada class tersebut perlu dibedah:
- Konstanta durasi (
MASA_AKTIF_HARI,MASA_TENGGANG_HARI) membuat kebijakan bisnis terbaca di satu tempat; mengubah periode menjadi 90 hari cukup mengganti satu angka. subscribe()memakai upsertON CONFLICT(tenant_id) DO UPDATE: satu tenant selalu punya tepat satu baris subscription. Memanggil ulang saat masih aktif berarti perpanjangan —mulai_padadigeser ke waktu baru dan masa berlaku dihitung ulang 30 hari dari sana.- Plan gratis dikenali dari harga nol (
berakhir_padaNULL), bukan dari kode'free'yang di-hardcode — plan murah lain pun kelak bisa mengikuti aturan yang sama. status()membandingkan waktu sebagai string formatY-m-d H:i:s. Format ISO-8601 tanpa zona urut secara leksikografis, dan karena seluruh nilai ditulis pada zona waktu yang sama (ZONA_WAKTU), perbandingan string memberikan hasil yang sama dengan perbandingan objek waktu tanpa biaya parsing.- Empat status memetakan dua kebijakan:
tenggangadalah masa baca-saja 7 hari setelah masa berlaku habis — buffer bagi pemilik tenant untuk membayar tanpa kehilangan akses baca;kedaluwarsamengembalikan batas ke tier gratis tanpa menghapus satu baris data pun. bolehBuatProyek()adalah gerbang fitur: hanya masa tenggang yang memblokir total; selain itu batas efektif yang menentukan.alasanBlokir()melengkapi dengan pesan berangka untuk HTTP 402/403 yang informatif.jumlahProyek()tetap menyaringtenant_id— service billing pun tidak boleh melanggar aturan isolasi Bagian 2, dandowngrade()memakai angka yang sama untuk gerbangnya.
Walkthrough: Satu Siklus Hidup Lengkap #
Walkthrough berikut menjalankan seluruh siklus dengan waktu beku: tenant tanpa langganan, berlangganan Pro, jatuh ke masa tenggang, kedaluwarsa, downgrade yang ditolak lalu diterima, dan perpanjangan. Pembuatan proyek di snippet ini memakai SQL langsung agar fokus tetap pada billing:
<?php
require __DIR__ . '/src/PlanRepository.php';
require __DIR__ . '/src/SubscriptionService.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
$pdo->exec(file_get_contents(__DIR__ . '/migrasi-billing.sql'));
$z = fn (string $waktu): DateTimeImmutable
=> new DateTimeImmutable($waktu, new DateTimeZone('Asia/Jakarta'));
$svc = new SubscriptionService($pdo, new PlanRepository($pdo));
// Helper demo: pembuatan proyek tetap menyertakan tenant_id.
function buatProyek(PDO $pdo, int $tenantId, string $nama): void
{
$stmt = $pdo->prepare('INSERT INTO projects (tenant_id, nama) VALUES (:tenant_id, :nama)');
$stmt->execute(['tenant_id' => $tenantId, 'nama' => $nama]);
}
$yaTidak = fn (bool $b): string => $b ? 'YA' : 'TIDAK';
echo "== 1) Tenant #1 tanpa langganan (tier gratis implisit) ==\n";
$now = $z('2026-02-01 09:00:00');
echo "status: {$svc->status(1, $now)}, batas: {$svc->batasProyekEfektif(1, $now)}\n";
echo "bolehBuatProyek: ", $yaTidak($svc->bolehBuatProyek(1, $now)), "\n";
buatProyek($pdo, 1, 'Landing Page Lebaran');
echo "buat proyek ke-3 (2 -> 3): diizinkan\n";
echo "buat proyek ke-4? ", $yaTidak($svc->bolehBuatProyek(1, $now)), "\n";
echo "alasan: {$svc->alasanBlokir(1, $now)}\n";
echo "\n== 2) subscribe(1, 'pro', 2026-02-01 09:00) ==\n";
$sub = $svc->subscribe(1, 'pro', $now);
echo "plan: {$sub['plan_kode']}, mulai: {$sub['mulai_pada']}, berakhir: {$sub['berakhir_pada']}\n";
echo "status: {$svc->status(1, $now)}, isActive: ", $svc->isActive(1, $now) ? 'true' : 'false', "\n";
echo "batas efektif: {$svc->batasProyekEfektif(1, $now)}\n";
buatProyek($pdo, 1, 'Kampanye Ramadan');
buatProyek($pdo, 1, 'Desain Kemasan Baru');
echo "buat proyek ke-4 dan ke-5: diizinkan (batas Pro 25)\n";
echo "\n== 3) 2026-03-05 — lewat berakhir 2026-03-03, dalam tenggang 7 hari ==\n";
$tenggang = $z('2026-03-05 09:00:00');
echo "status: {$svc->status(1, $tenggang)}, isActive: ", $svc->isActive(1, $tenggang) ? 'true' : 'false', "\n";
echo "bolehBuatProyek: ", $yaTidak($svc->bolehBuatProyek(1, $tenggang)), "\n";
echo "alasan: {$svc->alasanBlokir(1, $tenggang)}\n";
echo "\n== 4) 2026-03-15 — lewat masa tenggang ==\n";
$kedaluwarsa = $z('2026-03-15 09:00:00');
echo "status: {$svc->status(1, $kedaluwarsa)}\n";
echo "batas efektif: {$svc->batasProyekEfektif(1, $kedaluwarsa)} (kembali ke plan Gratis)\n";
echo "bolehBuatProyek: ", $yaTidak($svc->bolehBuatProyek(1, $kedaluwarsa)), "\n";
echo "alasan: {$svc->alasanBlokir(1, $kedaluwarsa)}\n";
echo "\n== 5) downgrade ke free dengan 5 proyek ==\n";
try {
$svc->downgrade(1, 'free', $kedaluwarsa);
echo "downgrade berhasil.\n";
} catch (BatasanPlanException $e) {
echo "Ditolak: {$e->getMessage()}\n";
}
echo "\n== 6) hapus 2 proyek, downgrade ulang ==\n";
$pdo->exec(
"DELETE FROM projects WHERE tenant_id = 1 AND nama IN ('Kampanye Ramadan', 'Desain Kemasan Baru')"
);
$svc->downgrade(1, 'free', $kedaluwarsa);
echo "downgrade berhasil — batas efektif: {$svc->batasProyekEfektif(1, $kedaluwarsa)}\n";
echo "status: {$svc->status(1, $kedaluwarsa)}, isActive: ", $svc->isActive(1, $kedaluwarsa) ? 'true' : 'false', "\n";
echo "bolehBuatProyek (3 proyek, batas 3): ", $yaTidak($svc->bolehBuatProyek(1, $kedaluwarsa)), "\n";
$baris = $pdo->query('SELECT COUNT(*) FROM subscriptions WHERE tenant_id = 1')->fetchColumn();
echo "baris subscription tenant #1: $baris\n";
echo "\n== 7) perpanjangan: subscribe ulang 'pro' saat masih berlangganan ==\n";
$sub2 = $svc->subscribe(1, 'pro', $z('2026-02-20 09:00:00'));
echo "mulai: {$sub2['mulai_pada']}, berakhir: {$sub2['berakhir_pada']}\n";
$baris = $pdo->query('SELECT COUNT(*) FROM subscriptions WHERE tenant_id = 1')->fetchColumn();
echo "baris subscription tenant #1 tetap: $baris (upsert, bukan baris baru)\n";Output yang Dihasilkan:
== 1) Tenant #1 tanpa langganan (tier gratis implisit) ==
status: tidak-berlangganan, batas: 3
bolehBuatProyek: YA
buat proyek ke-3 (2 -> 3): diizinkan
buat proyek ke-4? TIDAK
alasan: Batas plan Gratis: 3 proyek (saat ini 3).
== 2) subscribe(1, 'pro', 2026-02-01 09:00) ==
plan: pro, mulai: 2026-02-01 09:00:00, berakhir: 2026-03-03 09:00:00
status: aktif, isActive: true
batas efektif: 25
buat proyek ke-4 dan ke-5: diizinkan (batas Pro 25)
== 3) 2026-03-05 — lewat berakhir 2026-03-03, dalam tenggang 7 hari ==
status: tenggang, isActive: false
bolehBuatProyek: TIDAK
alasan: Masa tenggang sejak 2026-03-03 09:00:00 — data hanya dapat dibaca. Perpanjang langganan untuk membuka kembali penulisan data.
== 4) 2026-03-15 — lewat masa tenggang ==
status: kedaluwarsa
batas efektif: 3 (kembali ke plan Gratis)
bolehBuatProyek: TIDAK
alasan: Batas plan Gratis: 3 proyek (saat ini 5). Langganan kedaluwarsa — batas dikembalikan ke plan gratis.
== 5) downgrade ke free dengan 5 proyek ==
Ditolak: Downgrade ditolak: tenant #1 memiliki 5 proyek, batas plan Gratis hanya 3. Hapus minimal 2 proyek terlebih dahulu.
== 6) hapus 2 proyek, downgrade ulang ==
downgrade berhasil — batas efektif: 3
status: aktif, isActive: true
bolehBuatProyek (3 proyek, batas 3): TIDAK
baris subscription tenant #1: 1
== 7) perpanjangan: subscribe ulang 'pro' saat masih berlangganan ==
mulai: 2026-02-20 09:00:00, berakhir: 2026-03-22 09:00:00
baris subscription tenant #1 tetap: 1 (upsert, bukan baris baru)Bacalah output itu sebagai satu cerita lengkap. Fase 1: tenant tanpa langganan tetap bisa bekerja di bawah batas gratis — proyek ketiga masuk, proyek keempat ditolak dengan alasan berangka. Fase 2: subscribe() membuka batas 25, masa berlaku tercatat 2026-02-01 sampai 2026-03-03 (30 hari), dan dua proyek baru langsung diizinkan. Fase 3: waktu melompat ke 2026-03-05 — dua hari lewat masa berlaku, masih dalam tenggang tujuh hari — status berubah menjadi tenggang, isActive menjadi false, dan penulisan data dikunci total. Fase 4: 2026-03-15 melewati masa tenggang; batas efektif jatuh kembali ke 3, dan karena tenant sudah memegang 5 proyek, pembuatan baru tetap ditolak — data lama tidak pernah disentuh.
Fase 5 dan 6 menguji gerbang downgrade: menurun ke plan Gratis dengan 5 proyek ditolak BatasanPlanException lengkap dengan instruksi hapus minimal 2 proyek; setelah dua proyek dihapus (perhatikan DELETE-nya tetap menyaring tenant_id), downgrade diterima, status kembali aktif tanpa masa berlaku, dan jumlah baris subscription tetap satu — bukti upsert bekerja. Fase 7 menutup dengan perpanjangan: subscribe() ulang saat masih berlangganan menggeser mulai_pada ke 2026-02-20 dan masa berlaku baru 30 hari dari titik itu, tanpa menambah baris kedua.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1, migrasi-billing.sql dari bagian ini, serta dua berkas di folder src/. Susun foldernya sebagai berikut:
aplikasi-saas-multi-tenant/
├── bagian-1-tenant-dan-domain-model/
│ └── schema.sql
└── bagian-4-billing-dan-subscriptions/
├── migrasi-billing.sql
├── src/
│ ├── PlanRepository.php
│ └── SubscriptionService.php
└── uji-billing.phpSimpan walkthrough di atas sebagai uji-billing.php di dalam folder bagian-4-billing-dan-subscriptions, lalu jalankan dari folder tersebut:
php uji-billing.phpUrutan pemuatan penting: schema.sql Bagian 1 lebih dulu (tabel tenants yang dirujuk foreign key subscriptions), baru migrasi-billing.sql. Seluruh contoh memakai :memory: sehingga setiap eksekusi mulai dari data awal yang sama — termasuk dua proyek bawaan tenant #1 yang menentukan angka-angka pada fase 1.
Apa yang Berubah #
Proyek kini memiliki dimensi komersial yang lengkap dan tetap patuh pada aturan isolasi:
- Berkas
migrasi-billing.sqlbaru: tabelplans(katalog dengankodesebagai primary key) dansubscriptions(satu baris per tenant lewatUNIQUE), plus seedINSERT OR IGNOREuntuk plan Gratis dan Pro. - Berkas
src/PlanRepository.phpbaru denganlistAll()untuk halaman harga danfind()untuk resolusi kode plan. - Berkas
src/SubscriptionService.phpbaru:subscribe()dengan upsert dan perpanjangan otomatis,status()empat tahap,isActive(),batasProyekEfektif()dengan fallback tier gratis, gerbangbolehBuatProyek()/alasanBlokir(), dandowngrade()yang menolak penurunan melebihi batas lewatBatasanPlanException. - Seluruh waktu adalah parameter beku: tidak ada
time()di dalam domain logic, sehingga siklus 30 hari, tenggang 7 hari, dan kedaluwarsa diuji dalam satu proses.
Ringkasan #
Billing menutup lingkaran kekuasaan di aplikasi SaaS: schema Bagian 1 memberi setiap data pemiliknya, TenantContext Bagian 2 dan AccessControl Bagian 3 menjawab siapa yang boleh apa, dan SubscriptionService menjawab sampai kapan — dengan masa tenggang sebagai jembatan halus dan batas plan sebagai gerbang fitur yang berangka. Namun perjalanan ini meninggalkan jejak pertanyaan baru: siapa yang berlangganan, kapan proyek dibuat, dan mengapa keanggotaan berubah? Bagian 5 membangun jejak itu — audit trail yang merekam setiap keputusan, plus background jobs yang mengurus pekerjaan di luar request.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
migrasi-billing.sql
Unduh berkas-- migrasi-billing.sql — Bagian 4: Billing dan Subscriptions
-- Dimuat SETELAH schema.sql Bagian 1 pada koneksi yang sama:
--
-- $pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
-- $pdo->exec(file_get_contents(__DIR__ . '/migrasi-billing.sql'));
--
-- Dua tabel baru menambah dimensi komersial ke proyek:
-- plans (katalog paket) dan subscriptions (kepemilikan paket per
-- tenant). Keduanya menyusul sebagai migrasi, bukan langsung di
-- schema.sql Bagian 1, karena billing adalah keputusan fitur yang
-- datang belakangan — persis seperti evolisi aplikasi sungguhan.
--
-- KEPUTUSAN DESAIN:
-- 1. harga_bulanan disimpan sebagai INTEGER RUPIAH PENUH, bukan
-- sen. Aman karena harga plan memang selalu bulat (Rp149.000);
-- satuan sen baru wajib bila pecahan mungkin muncul.
-- 2. subscriptions.tenant_id UNIQUE: satu tenant hanya punya SATU
-- baris langganan aktif; berpindah plan berarti memperbarui
-- baris itu (upsert), bukan menambah baris baru.
-- 3. berakhir_pada NULL berarti tanpa batas waktu — dipakai plan
-- gratis yang tidak pernah "kedaluwarsa".
CREATE TABLE IF NOT EXISTS plans (
kode TEXT PRIMARY KEY,
nama TEXT NOT NULL,
harga_bulanan INTEGER NOT NULL CHECK (harga_bulanan >= 0),
batas_proyek INTEGER NOT NULL CHECK (batas_proyek >= 1)
);
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL UNIQUE REFERENCES tenants(id),
plan_kode TEXT NOT NULL REFERENCES plans(kode),
mulai_pada TEXT NOT NULL,
berakhir_pada TEXT,
dibuat_pada TEXT NOT NULL
);
-- UNIQUE pada subscriptions.tenant_id otomatis membuat index
-- internal SQLite (sqlite_autoindex_...), sehingga lookup
-- per-tenant sudah terlayani tanpa CREATE INDEX tambahan.
-- Katalog plan. INSERT OR IGNORE membuat pemuatan ulang migrasi
-- aman: kode adalah primary key sehingga baris yang sama tidak
-- pernah terduplikasi.
INSERT OR IGNORE INTO plans (kode, nama, harga_bulanan, batas_proyek) VALUES
('free', 'Gratis', 0, 3),
('pro', 'Pro Bulanan', 149000, 25);
src/PlanRepository.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk tabel plans (katalog paket langganan).
*
* Katalog plan adalah data konfigurasi, bukan data milik tenant:
* seluruh tenant membaca daftar plan yang sama. Karena itu query
* di sini memang tidak menyaring tenant_id — kebalikan dari
* repository tabel milik tenant.
*/
final class PlanRepository
{
public function __construct(private PDO $pdo)
{
// PRAGMA foreign_keys berlaku per koneksi; constructor adalah
// titik yang pasti dilewati setiap kali repository dibuat.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Daftar seluruh plan, urut harga termurah lebih dulu.
*
* Urutan ini siap dipakai halaman harga: plan gratis tampil
* paling atas sebagai titik masuk, plan berbayar menyusul.
*
* @return list<array<string, mixed>> baris: kode, nama, harga_bulanan, batas_proyek
*/
public function listAll(): array
{
return $this->pdo
->query('SELECT kode, nama, harga_bulanan, batas_proyek FROM plans ORDER BY harga_bulanan')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Ambil satu plan berdasarkan kode; null bila tidak ditemukan.
*
* @return array<string, mixed>|null
*/
public function find(string $kode): ?array
{
$stmt = $this->pdo->prepare(
'SELECT kode, nama, harga_bulanan, batas_proyek FROM plans WHERE kode = :kode'
);
$stmt->execute(['kode' => $kode]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
}
src/SubscriptionService.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Thrown saat operasi billing melanggar batas plan.
*
* Dipakai khususnya oleh downgrade(): menurunkan plan ditolak
* bila penggunaan saat ini melebihi batas plan tujuan.
*/
final class BatasanPlanException extends RuntimeException
{
}
/**
* Siklus langganan dan gerbang fitur per tenant.
*
* Seluruh method yang membaca waktu menerima DateTimeImmutable
* dari pemanggil ("frozen now"). Tidak ada pemanggilan time()
* atau date() di dalam class — inilah yang membuat siklus 30 hari,
* masa tenggang, dan kedaluwarsa dapat diuji deterministik: tes
* cukup menjalankan waktu ke depan dengan membuat objek waktu
* baru, bukan menunggu hari berganti.
*
* Model status satu tenant pada satu titik waktu:
* - 'tidak-berlangganan' : tanpa baris subscription -> tier gratis implisit.
* - 'aktif' : dalam masa berlaku (atau plan tanpa batas).
* - 'tenggang' : lewat masa berlaku, masih dalam MASA_TENGGANG_HARI
* -> baca-saja, penulisan data ditolak.
* - 'kedaluwarsa' : lewat masa tenggang -> tier kembali ke batas plan
* gratis; data utuh, penulisan diizinkan selama masih
* di bawah batas gratis.
*/
final class SubscriptionService
{
/** Panjang periode plan berbayar, dalam hari. */
public const MASA_AKTIF_HARI = 30;
/** Masa tenggang baca-saja setelah masa berlaku habis, dalam hari. */
public const MASA_TENGGANG_HARI = 7;
/** Zona waktu yang dipakai menyimpan dan membaca seluruh waktu. */
private const ZONA_WAKTU = 'Asia/Jakarta';
public function __construct(
private PDO $pdo,
private PlanRepository $plans,
) {
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Mulai/perbarui langganan tenant ke sebuah plan.
*
* Plan gratis (harga 0) tidak memiliki masa berlaku
* (berakhir_pada NULL); plan berbayar berlaku MASA_AKTIF_HARI
* hari dihitung dari $now, sehingga memanggil ulang method ini
* saat masih aktif berarti perpanjangan. Baris lama tenant
* diperbarui lewat upsert — satu tenant selalu punya tepat
* satu baris subscription.
*
* @return array<string, mixed> baris subscription hasil operasi
*/
public function subscribe(int $tenantId, string $kodePlan, DateTimeImmutable $now): array
{
$plan = $this->plans->find($kodePlan);
if ($plan === null) {
throw new InvalidArgumentException(
"Plan tidak dikenal: '$kodePlan'"
);
}
$mulai = $now->format('Y-m-d H:i:s');
$berakhir = ((int) $plan['harga_bulanan'] > 0)
? $now->modify('+' . self::MASA_AKTIF_HARI . ' days')->format('Y-m-d H:i:s')
: null;
$stmt = $this->pdo->prepare(
'INSERT INTO subscriptions (tenant_id, plan_kode, mulai_pada, berakhir_pada, dibuat_pada)
VALUES (:tenant_id, :plan_kode, :mulai, :berakhir, :dibuat)
ON CONFLICT(tenant_id) DO UPDATE SET
plan_kode = excluded.plan_kode,
mulai_pada = excluded.mulai_pada,
berakhir_pada = excluded.berakhir_pada,
dibuat_pada = excluded.dibuat_pada'
);
$stmt->execute([
'tenant_id' => $tenantId,
'plan_kode' => $kodePlan,
'mulai' => $mulai,
'berakhir' => $berakhir,
'dibuat' => $mulai,
]);
return $this->rowByTenant($tenantId);
}
/**
* Status langganan tenant pada titik waktu $now.
*
* Lihat daftar empat status pada docblock class. Perbandingan
* waktu memakai string format 'Y-m-d H:i:s' — format ISO yang
* urut secara leksikografis — dengan seluruh nilai ditulis
* pada zona waktu yang sama (ZONA_WAKTU).
*/
public function status(int $tenantId, DateTimeImmutable $now): string
{
$row = $this->rowByTenant($tenantId);
if ($row === null) {
return 'tidak-berlangganan';
}
if ($row['berakhir_pada'] === null) {
return 'aktif';
}
$sekarang = $now->format('Y-m-d H:i:s');
if ($sekarang <= $row['berakhir_pada']) {
return 'aktif';
}
$batasTenggang = (new DateTimeImmutable($row['berakhir_pada'], new DateTimeZone(self::ZONA_WAKTU)))
->modify('+' . self::MASA_TENGGANG_HARI . ' days')
->format('Y-m-d H:i:s');
if ($sekarang <= $batasTenggang) {
return 'tenggang';
}
return 'kedaluwarsa';
}
/**
* Apakah langganan tenant aktif pada $now?
*
* Hanya status 'aktif' yang mengembalikan true — masa tenggang
* sengaja TIDAK dihitung aktif karena penulisan data harus
* sudah berhenti di sana.
*/
public function isActive(int $tenantId, DateTimeImmutable $now): bool
{
return $this->status($tenantId, $now) === 'aktif';
}
/**
* Batas jumlah proyek yang berlaku bagi tenant pada $now.
*
* Status aktif dan tenggang memakai batas plan yang tercatat;
* status kedaluwarsa dan tenant tanpa langganan memakai batas
* plan gratis — data tidak pernah dihapus, hanya pembuatannya
* yang dibatasi.
*/
public function batasProyekEfektif(int $tenantId, DateTimeImmutable $now): int
{
$status = $this->status($tenantId, $now);
if ($status === 'aktif' || $status === 'tenggang') {
$row = $this->rowByTenant($tenantId);
$plan = $this->plans->find($row['plan_kode']);
return (int) $plan['batas_proyek'];
}
return (int) $this->plans->find('free')['batas_proyek'];
}
/**
* Bolehkah tenant membuat proyek baru pada $now?
*
* Dua syarat: bukan sedang berada di masa tenggang (baca-saja),
* dan jumlah proyek saat ini masih di bawah batas efektif.
* Tenant tanpa langganan maupun yang kedaluwarsa tetap boleh
* menulis selama masih di bawah batas plan gratis.
*/
public function bolehBuatProyek(int $tenantId, DateTimeImmutable $now): bool
{
if ($this->status($tenantId, $now) === 'tenggang') {
return false;
}
return $this->jumlahProyek($tenantId) < $this->batasProyekEfektif($tenantId, $now);
}
/**
* Penjelasan mengapa tenant tidak boleh membuat proyek.
*
* Mengembalikan null bila pembuatan proyek justru diizinkan;
* pesan lengkap dengan angka dipakai antarmuka untuk 402/403
* yang informatif.
*/
public function alasanBlokir(int $tenantId, DateTimeImmutable $now): ?string
{
if ($this->bolehBuatProyek($tenantId, $now)) {
return null;
}
$status = $this->status($tenantId, $now);
$jumlah = $this->jumlahProyek($tenantId);
if ($status === 'tenggang') {
$row = $this->rowByTenant($tenantId);
return 'Masa tenggang sejak ' . $row['berakhir_pada']
. ' — data hanya dapat dibaca. Perpanjang langganan untuk membuka kembali penulisan data.';
}
$batas = $this->batasProyekEfektif($tenantId, $now);
$namaPlan = ($status === 'aktif')
? $this->plans->find($this->rowByTenant($tenantId)['plan_kode'])['nama']
: $this->plans->find('free')['nama'];
return "Batas plan $namaPlan: $batas proyek (saat ini $jumlah)."
. (($status === 'kedaluwarsa') ? ' Langganan kedaluwarsa — batas dikembalikan ke plan gratis.' : '');
}
/**
* Turunkan langganan tenant ke plan lain yang lebih rendah.
*
* Aturan gerbang: tenant dengan jumlah proyek melebihi batas
* plan tujuan TIDAK boleh menurun — hapus proyeknya lebih dulu.
* Tanpa aturan ini, downgrade akan meninggalkan tenant dengan
* lebih banyak proyek daripada batas barunya.
*/
public function downgrade(int $tenantId, string $kodePlan, DateTimeImmutable $now): void
{
$target = $this->plans->find($kodePlan);
if ($target === null) {
throw new InvalidArgumentException(
"Plan tidak dikenal: '$kodePlan'"
);
}
$jumlah = $this->jumlahProyek($tenantId);
$batas = (int) $target['batas_proyek'];
if ($jumlah > $batas) {
throw new BatasanPlanException(
'Downgrade ditolak: tenant #' . $tenantId . ' memiliki ' . $jumlah
. ' proyek, batas plan ' . $target['nama'] . ' hanya ' . $batas
. '. Hapus minimal ' . ($jumlah - $batas) . ' proyek terlebih dahulu.'
);
}
$this->subscribe($tenantId, $kodePlan, $now);
}
/**
* Jumlah proyek milik tenant — query tetap ter-scope tenant_id,
* konsisten dengan aturan isolasi Bagian 2.
*/
private function jumlahProyek(int $tenantId): int
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) FROM projects WHERE tenant_id = :tenant_id'
);
$stmt->execute(['tenant_id' => $tenantId]);
return (int) $stmt->fetchColumn();
}
/**
* Baris subscription satu tenant; null bila belum berlangganan.
*
* @return array<string, mixed>|null
*/
private function rowByTenant(int $tenantId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, tenant_id, plan_kode, mulai_pada, berakhir_pada, dibuat_pada
FROM subscriptions WHERE tenant_id = :tenant_id'
);
$stmt->execute(['tenant_id' => $tenantId]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
}