Từ Lý Thuyết Đến Thực Tế: Module Subscription - Thiết Kế Gói Cước và Billing Cho SaaS Multi-Tenant Laravel
Tiếp theo series Từ Lý Thuyết Đến Thực Tế, sau khi hoàn thành dự án và deploy lên VPS, câu hỏi tiếp theo nếu muốn thu phí (Subscription) thì phải thiết kế và implement như thế nào. Bài này sẽ đi trả lời câu hỏi này
Thực trạng
Ở Việt Nam đa phần ít sử dụng Stripe hay PayPal nên các tutorial thanh toán trên youtube, udemy thật sự không áp dụng được.
Tôi sẽ chọn phương án khác tích hợp cổng thanh toán dịch vụ thứ 3 vào, nên chọn cổng thanh toán có chỗ trợ xác nhận chuyển khoản qua IPN.
Folow xử lý: Người dùng chuyển khoản xong, ngân hàng xử lý, rồi Cổng Thanh Toán mới gọi webhook báo về hệ thống. Dựa trên flow này sẽ thiết kế schema.
Đầu tiên Thiết kế gói cước
Tôi giả định gói cước như bên dưới để tiện đi vào thiết kế Schema, tùy bài toán cụ thể sẽ thiết kế khác nhau.
< 5 phòng → free (miễn phí)
5–10 phòng → basic (100.000đ/tháng)
> 10 phòng → premium (150.000đ/tháng)
Về giảm giá theo thời hạn:
| Thời hạn | Giảm giá |
|---|---|
| 1 tháng | 0% |
| 3 tháng | 0% |
| 6 tháng | 20% |
| 12 tháng | 30% |
| 24 tháng | 50% |
Schema: snapshot giá tại thời điểm mua
Đây là điểm kiến trúc quan trọng nhất. Nếu tôi chỉ lưu tier và duration_months, thì khi tôi thay đổi bảng giá trong tương lai, các subscription cũ sẽ bị tính sai. Giải pháp: snapshot toàn bộ thông tin giá tại thời điểm tạo.
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
tier VARCHAR(20) NOT NULL, -- snapshot tại thời điểm mua
status VARCHAR(20) NOT NULL DEFAULT 'pending_payment',
duration_months SMALLINT NOT NULL,
base_price BIGINT NOT NULL DEFAULT 0, -- giá gốc trước giảm (VND)
discount_percent SMALLINT NOT NULL DEFAULT 0,
amount_paid BIGINT NOT NULL DEFAULT 0, -- số tiền thực tế phải trả
starts_at DATE, -- NULL cho đến khi thanh toán
expires_at DATE,
invoice_number VARCHAR(50) UNIQUE, -- mã đơn gửi cho CongThanhToan
congthanhtoan_transaction VARCHAR(100), -- mã giao dịch từ CongThanhToan IPN
room_count_snapshot SMALLINT NOT NULL DEFAULT 0, -- audit trail
notes TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
CONSTRAINT chk_sub_tier CHECK (tier IN ('free', 'basic', 'premium')),
CONSTRAINT chk_sub_status CHECK (status IN ('active', 'expired', 'pending_payment', 'cancelled')),
CONSTRAINT chk_sub_months CHECK (duration_months IN (1, 3, 6, 12, 24))
);
Ba cột snapshot quan trọng nhất:
base_price— giá trước khi giảm, để auditdiscount_percent— % giảm tại thời điểm muaamount_paid— số tiền thực tế, đây là số mà CongThanhToan sẽ verify
Và room_count_snapshot để biết tenant mua gói khi đang có bao nhiêu phòng — phục vụ báo cáo sau này.
Luồng trạng thái
pending_payment ──(CongThanhToan IPN xác nhận)──→ active
pending_payment ──(quá hạn/hủy)────────→ cancelled
active ──(cron job hàng đêm)──→ expired
Điểm đặc biệt: starts_at và expires_at chỉ được set sau khi thanh toán thành công, không phải lúc tạo record. Lý do: nếu người dùng mua ngày hôm nay nhưng 3 ngày sau mới chuyển khoản, thì thời hạn phải tính từ ngày chuyển khoản, không phải ngày đặt mua.
Service layer: business logic thuần túy
SubscriptionService chứa toàn bộ logic tính toán mà không phụ thuộc bất kỳ chi tiết nào của CongThanhToan:
class SubscriptionService
{
private const TIER_PRICES = [
'free' => 0,
'basic' => 100_000,
'premium' => 150_000,
];
private const DURATION_DISCOUNTS = [
1 => 0,
3 => 0,
6 => 20,
12 => 30,
24 => 50,
];
public function determineTier(): string
{
$counts = $this->roomRepository->countByStatus();
$roomCount = ($counts['available'] ?? 0)
+ ($counts['occupied'] ?? 0)
+ ($counts['maintenance'] ?? 0);
return match (true) {
$roomCount < 5 => 'free',
$roomCount <= 10 => 'basic',
default => 'premium',
};
}
public function calculatePrice(string $tier, int $durationMonths): array
{
$monthlyPrice = self::TIER_PRICES[$tier] ?? 0;
$discountPercent = self::DURATION_DISCOUNTS[$durationMonths];
$basePrice = $monthlyPrice * $durationMonths;
$amountPaid = (int) round($basePrice * (1 - $discountPercent / 100));
return [
'tier' => $tier,
'duration_months' => $durationMonths,
'monthly_price' => $monthlyPrice,
'base_price' => $basePrice,
'discount_percent' => $discountPercent,
'amount_paid' => $amountPaid,
'savings' => $basePrice - $amountPaid,
];
}
Hàm determineTier() là điểm gắn kết giữa số phòng thực tế và gói cước. Nó query countByStatus() chứ không dùng một giá trị cố định — nếu tenant xóa bớt phòng xuống dưới 5, lần gia hạn sau họ sẽ được xếp vào free tier.
Initiate purchase — tạo record chờ thanh toán
public function initiatePurchase(int $durationMonths): Subscription
{
$tier = $this->determineTier();
$pricing = $this->calculatePrice($tier, $durationMonths);
$counts = $this->roomRepository->countByStatus();
$roomCount = ($counts['available'] ?? 0) + ($counts['occupied'] ?? 0) + ($counts['maintenance'] ?? 0);
$tenantId = $this->tenantContext->getTenantId();
$invoiceNumber = 'SUB-' . $tenantId . '-' . time();
$id = $this->subscriptionRepository->create([
'tier' => $tier,
'duration_months' => $durationMonths,
'base_price' => $pricing['base_price'],
'discount_percent' => $pricing['discount_percent'],
'amount_paid' => $pricing['amount_paid'],
'invoice_number' => $invoiceNumber,
'room_count_snapshot' => $roomCount,
]);
return $this->subscriptionRepository->findByInvoiceNumber($invoiceNumber);
}
Format invoice_number = 'SUB-{tenantId}-{timestamp}' vừa unique vừa cho phép nhận dạng tenant ngay trong mã đơn nếu cần debug thủ công.
Repository: IPN chạy dưới admin tenant — không cần bỏ quy tắc
Trong toàn bộ hệ thống, mọi query đều phải có WHERE tenant_id = :tenant_id — đó là nguyên tắc bất di bất dịch của SqlQueryManager. Kể cả với IPN cũng không thay đổi.
Khi Cổng thanh toán gọi IPN về, không có subdomain tenant trong request — TenantMiddleware không biết đang xử lý cho tenant nào, TenantContext trống. Giải pháp là hệ thống có Admin tenant với subdomain đặc biệt admin.quanlynhatro.net.
Đây là admin tenant — tenant gốc của hệ thống, không phải tenant của bất kỳ chủ nhà cho thuê nào. Admin tenant nắm toàn quyền hệ thống: quản lý tài khoản người dùng, duyệt subscription, xem báo cáo toàn bộ, đăng ký tài khoản mới.
Thay vì để Cổng thanh toán gọi IPN về quanlynhatro.net/congthanhtoan/ipn (không có tenant context), tôi cấu hình IPN URL về admin.quanlynhatro.net/congthanhtoan/ipn. Khi request này đến:
TenantAdminMiddlewaređọcADMIN_TENANT_IDtừ config, set vàoTenantContext- IPN handler parse
tenant_idtừinvoice_number, update lạiTenantContextsang tenant của đơn hàng - Mọi query chạy với đúng
WHERE tenant_id = :tenant_idcủa tenant sở hữu subscription
Xác định Admin Tenant qua biến môi trường
Thay vì thêm bypass vào SqlQueryManager, tôi khai báo cứng admin tenant trong .env:
ADMIN_TENANT_ID=1
TenantAdminMiddleware đọc giá trị này để set TenantContext, không đọc từ subdomain động:
class TenantAdminMiddleware
{
public function handle(Request $request, Closure $next): mixed
{
$adminTenantId = (int) config('app.admin_tenant_id');
$this->tenantContext->setTenantId($adminTenantId);
return $next($request);
}
}
Khai báo trong config/app.php:
'admin_tenant_id' => (int) env('ADMIN_TENANT_ID', 1),
Cách đặt tenant qua .env thay vì đọc subdomain đảm bảo không ai có thể giả mạo bằng cách tạo subdomain tên admin — giá trị được set tại thời điểm deploy, không runtime.
findByInvoiceNumber() vẫn có WHERE tenant_id
Format invoice_number = 'SUB-{tenantId}-{timestamp}' không chỉ để debug — nó cho phép IPN handler tự xác định tenant của đơn hàng và set lại context trước khi query. Không cần bất kỳ bypass nào trong SqlQueryManager:
// IpnController::handle() — sau khi verify chữ ký
$invoiceNumber = $request->input('order_invoice_number');
$transactionId = $request->input('transaction_id');
// Parse tenant_id từ invoice_number: SUB-{tenantId}-{timestamp}
$parts = explode('-', $invoiceNumber, 3);
$tenantId = isset($parts[1]) ? (int) $parts[1] : 0;
// Set context về tenant thực sự sở hữu subscription — không phải admin
$this->tenantContext->setTenantId($tenantId);
findByInvoiceNumber() trong repository chạy hoàn toàn bình thường với WHERE tenant_id đầy đủ:
public function findByInvoiceNumber(string $invoiceNumber): ?Subscription
{
$sql = '
SELECT *
FROM subscriptions
WHERE invoice_number = :invoice_number
AND tenant_id = :tenant_id
LIMIT 1
';
$row = $this->selectOne($sql, [
':invoice_number' => $invoiceNumber,
':tenant_id' => $this->tenantContext->getTenantId(),
]);
return $row ? Subscription::hydrate([$row])->first() : null;
}
SqlQueryManager không có khái niệm admin hay non-admin — nó luôn enforce WHERE tenant_id và không cần biết ngữ cảnh nào đang gọi nó. IPN handler tự đảm bảo set đúng tenant context trước khi query, không phải SqlQueryManager tự nới lỏng quy tắc.
Tách biệt route admin khỏi tenant thông thường
// routes/web.php
// Tenant routes — enforce TenantMiddleware
Route::domain('{tenant}.quanlynhatro.net')->group(function () {
Route::middleware(['tenant', 'auth', 'check.subscription'])->group(function () {
// dashboard, invoice, room, ...
});
});
// Admin routes — TenantAdminMiddleware đọc ADMIN_TENANT_ID từ config
Route::domain('admin.quanlynhatro.net')->group(function () {
Route::middleware(['tenant.admin', 'auth.admin'])->group(function () {
Route::post('/congthanhtoan/ipn', [IpnController::class, 'handle']);
Route::get('/subscriptions', [AdminSubscriptionController::class, 'index']);
// ...
});
});
tenant.admin middleware resolve cứng sang admin tenant qua ADMIN_TENANT_ID trong .env — không đọc từ subdomain động, không bị giả mạo bằng cách đặt subdomain tên admin giả.
Kết quả: IPN handler khởi động với admin tenant context, sau đó tự parse tenant_id từ invoice_number để switch sang đúng tenant của đơn hàng. TenantContext luôn có giá trị hợp lệ, mọi query luôn có WHERE tenant_id, và SqlQueryManager không có dòng code ngoại lệ nào. Cổng thanh toán không cần biết gì về kiến trúc multi-tenant — nó chỉ gọi một URL cố định.
Tích hợp Cổng Thanh Toán: IPN handler với idempotency
Đây là phần phức tạp nhất của toàn bộ module. Cổng thanh toán có thể gọi IPN nhiều lần (retry khi server timeout). Nếu không xử lý idempotency, subscription sẽ bị activate trùng lặp.
Verify chữ ký HMAC-SHA256
public function handle(Request $request): JsonResponse
{
// 1. Verify chữ ký — từ chối ngay nếu sai
$signature = $request->header('X-CongThanhToan-Signature', '');
$payload = $request->getContent();
if (!$this->verifySignature($payload, $signature)) {
return response()->json(['code' => '01', 'message' => 'Invalid signature'], 400);
}
// 2. Parse IPN data
$invoiceNumber = $request->input('order_invoice_number');
$transactionId = $request->input('transaction_id');
$status = $request->input('status');
if ($status !== 'success') {
return response()->json(['code' => '00', 'message' => 'OK']);
}
// 3. Activate — bọc trong try/catch, luôn trả 200 để CongThanhToan không retry vô hạn
try {
$this->subscriptionService->activate($invoiceNumber, $transactionId);
} catch (\DomainException $e) {
Log::warning("CongThanhToan IPN error: {$e->getMessage()}", ['invoice' => $invoiceNumber]);
}
return response()->json(['code' => '00', 'message' => 'OK']);
}
private function verifySignature(string $payload, string $signature): bool
{
$expected = hash_hmac('sha256', $payload, config('CongThanhToan.secret_key'));
return hash_equals($expected, $signature);
}
Hai điểm quan trọng ở đây:
Luôn trả HTTP 200 dù có lỗi business logic. Nếu trả 500, CongThanhToan sẽ retry IPN. Nhưng nếu lỗi là DomainException (ví dụ: subscription đã cancelled), retry cũng không giải quyết được gì — chỉ tạo rác trong log. Ngoại lệ duy nhất là sai chữ ký — trả 400 để CongThanhToan biết payload bị tamper.
Không dùng time_equals() thông thường. hash_equals() của PHP thực hiện comparison trong constant time, tránh bị tấn công bằng cách đổi time.
Idempotency trong activate()
public function activate(string $invoiceNumber, string $congThanhToanTransactionId): Subscription
{
$subscription = $this->subscriptionRepository->findByInvoiceNumber($invoiceNumber);
if ($subscription === null) {
throw new \DomainException("Invoice {$invoiceNumber} không tồn tại");
}
// Đây là idempotency guard — CongThanhToan gọi IPN lần 2, trả về kết quả giống lần 1
if ($subscription->status === 'active') {
return $subscription;
}
if ($subscription->status !== 'pending_payment') {
throw new \DomainException("Subscription không ở trạng thái chờ thanh toán");
}
// Ngày bắt đầu = ngày IPN về, không phải ngày đặt mua
$startsAt = Carbon::today()->toDateString();
$expiresAt = Carbon::today()
->addMonths($subscription->duration_months)
->subDay()
->toDateString();
$this->subscriptionRepository->activate(
$subscription->id,
$congThanhToanTransactionId,
$startsAt,
$expiresAt,
);
return $this->subscriptionRepository->findByInvoiceNumber($invoiceNumber);
}
subDay() ở dòng tính expiresAt có lý do: nếu mua ngày 10/5 và mua 1 tháng, ngày hết hạn phải là 9/6, không phải 10/6. addMonths(1) cho ra 10/6, subDay() trừ đi 1 ngày thành 9/6.
Middleware: cổng bảo vệ không làm gãy tenant context
CheckSubscriptionMiddleware là middleware cuối cùng trong luồng thanh toán, chạy sau TenantMiddleware. Nó kiểm tra xem tenant có subscription hợp lệ không:
class CheckSubscriptionMiddleware
{
public function handle(Request $request, Closure $next): mixed
{
// Bỏ qua các route đặc biệt — tránh vòng lặp redirect
$excluded = [
'subscription.index',
'subscription.expired',
'subscription.store',
'subscription.history',
'logout',
'congThanhToan.ipn',
];
if (in_array($request->route()->getName(), $excluded, true)) {
return $next($request);
}
if (!$this->subscriptionService->isActive()) {
return redirect()->route('subscription.expired');
}
return $next($request);
}
}
Và isActive() trong service:
public function isActive(): bool
{
$roomCount = $this->roomRepository->countByStatus()['total'] ?? 0;
// Dưới 5 phòng → luôn pass, không cần subscription
if ($roomCount < 5) {
return true;
}
$active = $this->subscriptionRepository->findActive();
return $active !== null;
}
Logic < 5 phòng → luôn pass rất quan trọng. Nếu tenant đang dùng free tier (chưa bao giờ mua subscription), họ không có record nào trong bảng subscriptions. Không được block họ chỉ vì không tìm thấy record.
Cái bẫy của thiết kế middleware subscription là Vòng Lặp Chuyển Hướng: /dashboard cần subscription → redirect sang /subscription/expired → /subscription/expired cũng cần subscription → loop vô tận. Danh sách $excluded phá vòng lặp này.
Cron job: hết hạn subscription hàng đêm
Cổng thanh toán không tự động hủy subscription khi hết ngày. Cần một cron job chạy hàng đêm để cập nhật trạng thái Subscription:
// app/Console/Commands/ExpireSubscriptionsCommand.php
public function handle(): void
{
$tenantIds = $this->tenantRepository->getAllIds();
$total = 0;
foreach ($tenantIds as $tenantId) {
$this->tenantContext->setTenantId($tenantId);
$total += $this->subscriptionRepository->expireOverdue();
}
$this->info("Đã đánh dấu {$total} subscription hết hạn.");
}
Query trong repository vẫn có WHERE tenant_id như mọi query khác:
UPDATE subscriptions
SET status = 'expired',
updated_at = NOW()
WHERE status = 'active'
AND expires_at < CURRENT_DATE
AND tenant_id = :tenant_id
Command loop qua toàn bộ tenant ID và set TenantContext trước mỗi lần gọi — cùng pattern với IPN handler. Số lượng tenant trong hệ thống nhỏ nên N queries vẫn chạy trong vài millisecond. Lợi ích: expireOverdue() không có trường hợp đặc biệt nào, cùng method này có thể gọi từ HTTP request hoặc console command mà không cần biết ngữ cảnh.
Đăng ký trong scheduler:
// app/Console/Kernel.php
$schedule->command('subscriptions:expire')->dailyAt('00:05');
$schedule->command('subscriptions:send-expiry-reminders')->dailyAt('09:00');
Kết
1. Snapshot giá là bắt buộc, không phải optional. Ngày đầu tôi tính "lưu tier thôi, tính giá khi cần". Nhưng khi cần hiển thị lại lịch sử thanh toán cho tenant, sẽ không biết lúc đó giá bao nhiêu nếu không snapshot.
2. IPN idempotency không phải case đặc biệt — đó là flow bình thường. CongThanhToan document nói rõ họ retry IPN đến khi nhận được HTTP 200. Nếu server đang deploy hoặc timeout, họ sẽ gọi lại. Lưu ý case đặc biệt này
3. Format invoice_number encode tenant_id để IPN handler tự giải quyết context. SUB-{tenantId}-{timestamp} không chỉ là convention đặt tên — nó là dữ liệu cho phép IPN handler xác định và set đúng tenant context trước khi query. SqlQueryManager không cần biết gì về admin, mọi query vẫn luôn có WHERE tenant_id.
4. Đừng để route /congthanhtoan/ipn đi qua CSRF middleware. Đây là điều hiển nhiên nhưng dễ quên. Cổng thanh toán không gửi CSRF token — nếu quên chỗ này, mọi IPN đều fail với HTTP 419.
5. Free tier logic phải check số phòng, không phải sự tồn tại của subscription record. Tenant mới đăng ký không có subscription record nào. Nếu check findActive() !== null là điều kiện duy nhất, sẽ block toàn bộ tenant mới.
Module subscription là phần khá phức tạp, thứ nhất vì nó liên quan đến tiền nong, thứ hai liên kết với hệ thống khác, thứ ba nó không phải là công việc hằng ngày ở dự án outsourcing. Sau cùng cũng đã hoàn thành và đi vào hoạt động, rút kết được nhiều bài học về thiết kế và triển khai
Bài tiếp theo trong series: Deploy SaaS chỉ $6/Tháng với Docker 4-Stage Build và Những Bài Học Vận Hành Thực Tế
All rights reserved