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 {}
};