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,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ServiceExpiryReconciler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReconcileServiceExpiry extends Command
|
||||
{
|
||||
protected $signature = 'service:reconcile-expiry {--dry-run : Chỉ thống kê, không ghi dữ liệu}';
|
||||
protected $description = 'Đồng bộ module_vmware.next_due_date theo services.expires_at (nguồn hạn dùng duy nhất)';
|
||||
|
||||
public function handle(ServiceExpiryReconciler $reconciler): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$stats = $reconciler->run($dryRun);
|
||||
|
||||
$this->info(sprintf(
|
||||
'service:reconcile-expiry%s | lệch module_vmware: %d | service thiếu expires_at được bù: %d | VM không gắn dịch vụ: %d',
|
||||
$dryRun ? ' (dry-run)' : '',
|
||||
$stats['synced_vmware'],
|
||||
$stats['backfilled_services'],
|
||||
$stats['orphan_vms'],
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,14 @@ use App\Repositories\Api\Service\ServiceRepository;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceItem;
|
||||
use App\Models\Service;
|
||||
use App\Services\AutoRenewFailureNotifier;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use App\Services\RenewalInvoiceNotifier;
|
||||
use App\Support\RenewalPricing;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Support\VatNumbering;
|
||||
|
||||
class ServiceExpiry extends Command
|
||||
{
|
||||
@@ -26,7 +30,7 @@ class ServiceExpiry extends Command
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$renewalWindowDays = 7;
|
||||
$upcoming = Service::query()
|
||||
->with(['orderItem.product'])
|
||||
->with(['orderItem.product', 'customer'])
|
||||
->whereNotNull('expires_at')
|
||||
->whereBetween('expires_at', [now(), now()->addDays($renewalWindowDays)])
|
||||
->whereNotIn('status', ['terminated', 'cancelled'])
|
||||
@@ -49,7 +53,7 @@ class ServiceExpiry extends Command
|
||||
|
||||
// Note: 'invoice' is NOT eager-loaded — createRenewalInvoiceIfMissing queries fresh
|
||||
$expired = Service::query()
|
||||
->with(['orderItem.product'])
|
||||
->with(['orderItem.product', 'customer'])
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', now())
|
||||
->whereNotIn('status', ['terminated', 'cancelled'])
|
||||
@@ -103,11 +107,6 @@ class ServiceExpiry extends Command
|
||||
$this->line(" [suspended] Service #{$service->id}");
|
||||
}
|
||||
|
||||
// Optional: note auto_renew intent (auto-payment not implemented)
|
||||
if ($service->auto_renew) {
|
||||
Log::info('Service has auto_renew=true; renewal invoice will be created for manual or future auto-payment', ['service_id' => $service->id]);
|
||||
}
|
||||
|
||||
$this->createRenewalInvoiceIfMissing($service);
|
||||
});
|
||||
|
||||
@@ -122,10 +121,57 @@ class ServiceExpiry extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$this->autoPayRenewals($renewalWindowDays, $dryRun);
|
||||
|
||||
$this->info("service:expiry | Done.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function autoPayRenewals(int $windowDays, bool $dryRun): void
|
||||
{
|
||||
$services = Service::query()
|
||||
->with('customer')
|
||||
->where('auto_renew', true)
|
||||
->whereNotNull('invoice_id')
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now()->addDays($windowDays))
|
||||
->whereNotIn('status', ['terminated', 'cancelled'])
|
||||
->get();
|
||||
|
||||
foreach ($services as $service) {
|
||||
$invoice = Invoice::where('id', $service->invoice_id)
|
||||
->where('invoice_type', 2)
|
||||
->whereIn('status', InvoicePaymentService::PAYABLE_STATUSES)
|
||||
->first();
|
||||
|
||||
if (! $invoice || ! $service->customer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run:auto-pay] Service #{$service->id} invoice {$invoice->invoice_number}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $service->customer->total < InvoicePaymentService::outstanding($invoice)) {
|
||||
$notified = app(AutoRenewFailureNotifier::class)->notifyInsufficientBalance($service, $invoice);
|
||||
Log::info('Auto-renew thiếu số dư', ['service_id' => $service->id, 'invoice_id' => $invoice->id, 'notified' => $notified]);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
app(InvoicePaymentService::class)->payWithBalance($invoice, $service->customer);
|
||||
Log::info('Auto-renew paid', ['service_id' => $service->id, 'invoice_id' => $invoice->id]);
|
||||
$this->line(" [auto-paid] Service #{$service->id} invoice {$invoice->invoice_number}");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
Log::info('Auto-renew chưa trừ được tiền', ['service_id' => $service->id, 'invoice_id' => $invoice->id, 'reason' => $e->getMessage()]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('service:expiry auto-pay error', ['service_id' => $service->id, 'error' => $e->getMessage()]);
|
||||
$this->error(" [error] Auto-pay service #{$service->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Giá gia hạn lấy theo BẢNG GIÁ HIỆN HÀNH tại thời điểm xuất hoá đơn, không đóng băng theo
|
||||
* giá lúc mua — nếu không, tăng giá sẽ không bao giờ áp được cho khách cũ và khuyến mãi
|
||||
@@ -283,6 +329,8 @@ class ServiceExpiry extends Command
|
||||
InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]);
|
||||
}
|
||||
|
||||
VatNumbering::settle($invoice);
|
||||
|
||||
// Update service to point to the new renewal invoice
|
||||
$service->update(['invoice_id' => $invoice->id]);
|
||||
|
||||
@@ -291,6 +339,8 @@ class ServiceExpiry extends Command
|
||||
'order_id' => $orderItem->order_id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
]);
|
||||
app(RenewalInvoiceNotifier::class)->notify($invoice, $service);
|
||||
|
||||
$this->line(" [invoice] Renewal invoice {$invoice->invoice_number} on Order #{$orderItem->order_id} for Service #{$service->id}.");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-18
@@ -19,25 +19,10 @@ class Kernel extends ConsoleKernel
|
||||
/* Remove Session sanctum */
|
||||
$schedule->command('sanctum:prune-expired --hours=24')->daily();
|
||||
|
||||
/* Check Daily Suspend Hosting */
|
||||
$schedule->command('hosting:expiry')
|
||||
$schedule->command('service:reconcile-expiry')
|
||||
->timezone("Asia/Ho_Chi_Minh")
|
||||
->dailyAt("00:00");
|
||||
|
||||
/* Check Daily Mail Hosting Expiry */
|
||||
$schedule->command('hosting:mail')
|
||||
->timezone("Asia/Ho_Chi_Minh")
|
||||
->dailyAt("00:00");
|
||||
|
||||
/* Check Daily Mail VPS Expiry */
|
||||
$schedule->command('vps:mail')
|
||||
->timezone("Asia/Ho_Chi_Minh")
|
||||
->dailyAt("00:00");
|
||||
|
||||
/* Check Daily Suspend Hosting */
|
||||
$schedule->command('vps:expiry')
|
||||
->timezone("Asia/Ho_Chi_Minh")
|
||||
->dailyAt("09:54");
|
||||
->dailyAt("00:05")
|
||||
->withoutOverlapping();
|
||||
|
||||
/* Service lifecycle: suspend VM, renew invoice, terminate after 30 days */
|
||||
$schedule->command('service:expiry')
|
||||
|
||||
Reference in New Issue
Block a user