feat(billing): renewals, coupons, VAT numbering, billing cycles and invoice payment tracking

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-19 16:37:56 +07:00
co-authored by Claude Sonnet 5.5
parent ea9a53f742
commit 25f23d541e
134 changed files with 4926 additions and 185 deletions
@@ -0,0 +1,71 @@
<?php
use App\Models\ActivityLog;
use App\Models\Invoice;
use App\Models\Service;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$logged = ActivityLog::where('event', 'renewed')
->where('subject_type', Service::class)
->get()
->map(fn ($log) => $log->new_values['invoice_number'] ?? null)
->filter()
->all();
Invoice::where('invoice_type', 2)
->where('status', 2)
->whereNotNull('order_id')
->with('customer')
->orderBy('id')
->each(function (Invoice $invoice) use ($logged) {
if (in_array($invoice->invoice_number, $logged, true)) {
return;
}
$service = Service::where('customer_id', $invoice->customer_id)
->whereHas('orderItem', fn ($q) => $q->where('order_id', $invoice->order_id))
->first();
if (! $service) {
return;
}
$adminId = Transaction::where('customer_id', $invoice->customer_id)
->where('type', 'invoice')
->where('trans', $invoice->invoice_number)
->value('user_id');
$admin = $adminId ? User::find($adminId) : null;
$months = (int) ($invoice->invoiceItems()->where('type', 'renewal')->value('months') ?: 1);
$log = ActivityLog::create([
'log_name' => 'Service',
'event' => 'renewed',
'description' => "Gia hạn dịch vụ #{$service->id} thêm {$months} tháng (hóa đơn #{$invoice->invoice_number})",
'subject_type' => Service::class,
'subject_id' => $service->id,
'causer_type' => $admin ? 'admin' : 'customer',
'causer_id' => $admin?->id ?? $invoice->customer_id,
'causer_name' => $admin ? ($admin->username ?? $admin->email) : $invoice->customer?->email,
'old_values' => ['expires_at' => $invoice->billing_period_start],
'new_values' => [
'expires_at' => $invoice->billing_period_end,
'months' => $months,
'invoice_number' => $invoice->invoice_number,
'amount' => (int) $invoice->total,
'payment_method' => $invoice->payment_method,
],
]);
ActivityLog::whereKey($log->id)->update(['created_at' => $invoice->paid_at ?? $invoice->updated_at]);
});
}
public function down(): void
{
}
};
@@ -0,0 +1,23 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const SCENARIOS = ['service_renewed', 'admin_service_renewed'];
public function up(): void
{
$seeder = new TemplateSeeder();
foreach (self::SCENARIOS as $scenario) {
$seeder->seedScenarioIfMissing($scenario);
}
}
public function down(): void
{
DB::table('template')->whereIn('scenario', self::SCENARIOS)->delete();
}
};
@@ -0,0 +1,47 @@
<?php
use App\Models\Invoice;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->string('tax_code', 20)->nullable();
$table->string('invoice_company')->nullable();
$table->string('invoice_address', 500)->nullable();
$table->string('invoice_email')->nullable();
});
Schema::table('invoices', function (Blueprint $table) {
$table->string('lookup_code', 16)->nullable()->unique();
$table->string('vat_serial', 20)->nullable();
$table->string('vat_number', 30)->nullable();
$table->date('vat_issued_at')->nullable();
$table->json('buyer_snapshot')->nullable();
$table->unique(['vat_serial', 'vat_number']);
});
DB::table('invoices')->whereNull('lookup_code')->orderBy('id')->select('id')->chunkById(500, function ($rows) {
foreach ($rows as $row) {
DB::table('invoices')->where('id', $row->id)->update(['lookup_code' => Invoice::newLookupCode()]);
}
});
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropUnique(['vat_serial', 'vat_number']);
$table->dropUnique(['lookup_code']);
$table->dropColumn(['lookup_code', 'vat_serial', 'vat_number', 'vat_issued_at', 'buyer_snapshot']);
});
Schema::table('customers', function (Blueprint $table) {
$table->dropColumn(['tax_code', 'invoice_company', 'invoice_address', 'invoice_email']);
});
}
};
@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('coupons', function (Blueprint $table) {
$table->id();
$table->string('code', 50)->unique();
$table->string('name', 150)->nullable();
$table->string('type', 10);
$table->unsignedBigInteger('value');
$table->unsignedBigInteger('max_discount')->nullable();
$table->unsignedBigInteger('min_order')->default(0);
$table->string('applies_to', 10)->default('new');
$table->json('product_types')->nullable();
$table->json('product_ids')->nullable();
$table->json('billing_cycles')->nullable();
$table->unsignedInteger('usage_limit')->nullable();
$table->unsignedInteger('per_customer_limit')->nullable();
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('coupon_redemptions', function (Blueprint $table) {
$table->id();
$table->foreignId('coupon_id')->constrained('coupons')->cascadeOnDelete();
$table->unsignedBigInteger('customer_id')->index();
$table->unsignedBigInteger('order_id')->nullable()->unique();
$table->unsignedBigInteger('amount');
$table->timestamp('released_at')->nullable();
$table->timestamps();
$table->index(['coupon_id', 'customer_id']);
$table->foreign('order_id')->references('id')->on('orders')->nullOnDelete();
});
Schema::table('orders', function (Blueprint $table) {
$table->unsignedBigInteger('coupon_id')->nullable()->after('discount_id');
$table->string('coupon_code', 50)->nullable()->after('coupon_id');
$table->foreign('coupon_id')->references('id')->on('coupons')->nullOnDelete();
});
Schema::table('invoices', function (Blueprint $table) {
$table->string('coupon_code', 50)->nullable()->after('discount_amount');
});
}
public function down(): void
{
Schema::table('invoices', fn (Blueprint $table) => $table->dropColumn('coupon_code'));
Schema::table('orders', function (Blueprint $table) {
$table->dropForeign(['coupon_id']);
$table->dropColumn(['coupon_id', 'coupon_code']);
});
Schema::dropIfExists('coupon_redemptions');
Schema::dropIfExists('coupons');
}
};
@@ -0,0 +1,17 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
(new TemplateSeeder())->seedScenarioIfMissing('auto_renew_failed');
}
public function down(): void
{
DB::table('template')->where('scenario', 'auto_renew_failed')->delete();
}
};
@@ -0,0 +1,16 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$seeder = new TemplateSeeder();
$seeder->reseedScenario('invoice_notification');
$seeder->reseedScenario('invoice_payment_reminder');
}
public function down(): void {}
};
@@ -0,0 +1,14 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->reseedScenario('hosting_account_created');
}
public function down(): void {}
};
@@ -0,0 +1,14 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('orders')->whereNull('date')->whereNotNull('created_at')->update(['date' => DB::raw('DATE(created_at)')]);
}
public function down(): void {}
};
@@ -30,6 +30,24 @@ final class AdminTemplates
'Thời gian' => '{{created_at}}',
]),
],
[
'scenario' => 'admin_service_renewed',
'name' => '[Admin] Dịch vụ được gia hạn',
'subject' => '[ADMIN] {{admin_name}} gia hạn dịch vụ {{product_name}} — {{customer_email}}',
'body' => T::title('Dịch vụ vừa được gia hạn')
. T::paragraph('Nhân viên <strong>{{admin_name}}</strong> vừa gia hạn dịch vụ cho khách hàng.')
. T::infoTable([
'Khách hàng' => '{{customer_name}}',
'Email' => '{{customer_email}}',
'Dịch vụ' => '{{product_name}}{{server_name_suffix}}',
'Thời gian gia hạn' => '{{months_label}}',
'Hạn sử dụng trước' => '{{old_expires_at}}',
'Hạn sử dụng mới' => '{{expires_at}}',
'Hoá đơn' => '{{invoice_number}}',
'Số tiền' => '{{amount_formatted}}',
'Thanh toán' => '{{payment_method_label}}',
]),
],
[
'scenario' => 'admin_order_notification',
'name' => '[Admin] Đơn hàng mới',
@@ -25,8 +25,9 @@ final class BillingTemplates
'Hạn thanh toán' => '{{due_date}}',
'Tổng tiền' => '{{total}} VNĐ',
'Trạng thái' => '{{status_label}}',
], '{{outstanding_row}}')
], '{{outstanding_row}}{{lookup_row}}')
. T::paragraph('{{intro_note}}')
. '{{lookup_note}}'
. '{{pay_button}}{{attachments_block}}{{note_block}}'
. T::supportLine(),
],
@@ -41,7 +42,8 @@ final class BillingTemplates
'Số hoá đơn' => '{{invoice_number}}',
'Hạn thanh toán' => '{{due_date}}',
'Tổng tiền' => '{{total}} VNĐ',
], '{{paid_row}}' . T::row('Còn phải thanh toán', '<span style="color: #dc2626;">{{outstanding}} VNĐ</span>'))
], '{{paid_row}}' . T::row('Còn phải thanh toán', '<span style="color: #dc2626;">{{outstanding}} VNĐ</span>') . '{{lookup_row}}')
. '{{lookup_note}}'
. T::button('{{login_url}}', 'THANH TOÁN NGAY')
. T::paragraph('Nếu Quý khách đã thanh toán, vui lòng bỏ qua email này.')
. T::supportLine(),
@@ -66,7 +66,7 @@ final class ServiceTemplates
[
'scenario' => 'hosting_account_created',
'name' => 'Hosting đã khởi tạo',
'subject' => 'Hosting của bạn đã sẵn sàng — {{domain_name}}',
'subject' => 'V/v: {{app_name}} gửi thông tin truy cập - {{server_name}} - {{recipient_label}}',
'body' => T::title('Hosting đã sẵn sàng')
. T::greeting('{{full_name}}')
. T::paragraph('Gói hosting cho tên miền <strong>{{domain_name}}</strong> tại <strong>{{app_name}}</strong> đã được khởi tạo thành công. Dưới đây là thông tin truy cập:')
@@ -125,6 +125,39 @@ final class ServiceTemplates
. T::button('{{login_url}}', 'GIA HẠN NGAY')
. T::supportLine(),
],
[
'scenario' => 'service_renewed',
'name' => 'Dịch vụ đã được gia hạn',
'subject' => 'Dịch vụ {{product_name}} đã được gia hạn',
'body' => T::title('Gia hạn dịch vụ thành công')
. T::greeting('{{client_name}}')
. T::paragraph('Dịch vụ <strong>{{product_name}}</strong>{{server_name_suffix}} của Quý khách vừa được gia hạn thêm <strong>{{months_label}}</strong>.')
. T::infoTable([
'Dịch vụ' => '{{product_name}}',
'Hạn sử dụng trước' => '{{old_expires_at}}',
'Hạn sử dụng mới' => '<span style="color: #16a34a;">{{expires_at}}</span>',
'Hoá đơn' => '{{invoice_number}}',
], '{{price_row}}')
. T::button('{{login_url}}', 'XEM DỊCH VỤ')
. T::supportLine(),
],
[
'scenario' => 'auto_renew_failed',
'name' => 'Tự động gia hạn thất bại (thiếu số dư)',
'subject' => 'Chưa thể tự động gia hạn dịch vụ {{product_name}}',
'body' => T::title('Tự động gia hạn chưa thành công')
. T::greeting('{{client_name}}')
. T::paragraph('Dịch vụ <strong>{{product_name}}</strong>{{server_name_suffix}} đã bật tự động gia hạn nhưng số dư tài khoản hiện chưa đủ để thanh toán hoá đơn gia hạn.')
. T::infoTable([
'Hoá đơn' => '{{invoice_number}}',
'Ngày hết hạn' => '<span style="color: #dc2626;">{{expires_at}}</span>',
'Cần thanh toán' => '{{outstanding}} VNĐ',
'Số dư hiện có' => '{{balance_formatted}} VNĐ',
])
. T::paragraph('Quý khách vui lòng nạp thêm tiền, hệ thống sẽ tự trừ ở lần kiểm tra kế tiếp. Nếu không, dịch vụ có thể bị tạm ngưng khi quá hạn.')
. T::button('{{topup_url}}', 'NẠP TIỀN NGAY')
. T::supportLine(),
],
[
'scenario' => 'vm_creation_failed',
'name' => 'Khởi tạo VPS thất bại',
@@ -46,8 +46,14 @@ final class TemplateVariables
'suspended_at' => ['Thời gian tạm ngưng', 'string', '08/10/2026 09:00'],
'server_name_suffix' => ['[Tự động] Tên máy chủ kèm theo', 'html', ''],
'expires_at' => ['Ngày hết hạn', 'string', '31/12/2026'],
'months_label' => ['Thời gian gia hạn', 'string', '12 tháng'],
'old_expires_at' => ['Ngày hết hạn trước khi gia hạn', 'string', '31/12/2026'],
'payment_method_label' => ['Hình thức thanh toán', 'string', 'Đã thu ngoài hệ thống'],
'admin_name' => ['Nhân viên thực hiện', 'string', 'Nguyễn Văn B'],
'price_row' => ['[Tự động] Dòng số tiền gia hạn', 'html', ''],
'invoice_url' => ['Link đơn hàng', 'url', 'https://tgs.com.vn/don-hang'],
'balance_formatted' => ['Số dư hiện có', 'string', '200.000'],
'topup_url' => ['Link nạp tiền', 'url', 'https://tgs.com.vn/nap-tien'],
'ip' => ['Địa chỉ IP', 'string', '103.0.0.10'],
'ip_count' => ['Số địa chỉ IP', 'string', '2'],
'ip_rows' => ['[Tự động] Danh sách địa chỉ IP', 'html', ''],
@@ -70,6 +76,8 @@ final class TemplateVariables
'name_server_1' => ['Name server 1', 'string', 'ns9.tgs.com.vn'],
'name_server_2' => ['Name server 2', 'string', 'ns10.tgs.com.vn'],
'kyc_block' => ['[Tự động] Khối xác thực eKYC', 'html', ''],
'server_name' => ['Máy chủ hosting', 'string', 'Hosting-01'],
'recipient_label' => ['[Tự động] Họ tên khách (trống thì tên công ty, rồi "Công ty")', 'string', 'Nguyễn Văn A'],
'hosting_plan' => ['Gói hosting', 'string', 'Premium Hosting 1'],
'server_host_row' => ['[Tự động] Dòng máy chủ / FTP host', 'html', ''],
'ftp_username_row' => ['[Tự động] Dòng tài khoản FTP', 'html', ''],
@@ -95,6 +103,8 @@ final class TemplateVariables
'status_label' => ['[Tự động] Trạng thái hoá đơn', 'html', ''],
'outstanding_row' => ['[Tự động] Dòng đã trả / còn thiếu', 'html', ''],
'intro_note' => ['Ghi chú đính kèm', 'string', 'File PDF hóa đơn đã được đính kèm trong email này.'],
'lookup_row' => ['[Tự động] Dòng mã tra cứu hoá đơn', 'html', ''],
'lookup_note' => ['[Tự động] Hướng dẫn tra cứu hoá đơn', 'html', ''],
'pay_button' => ['[Tự động] Nút thanh toán', 'html', ''],
'attachments_block' => ['[Tự động] Danh sách file đính kèm', 'html', ''],
'note_block' => ['[Tự động] Ghi chú hoá đơn', 'html', ''],