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:
co-authored by
Claude Sonnet 5.5
parent
ea9a53f742
commit
25f23d541e
@@ -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 {}
|
||||
};
|
||||
Reference in New Issue
Block a user