From 25f23d541e2e21e3938d77015437eca4769dede8 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Mon, 19 Oct 2026 16:37:56 +0700 Subject: [PATCH] feat(billing): renewals, coupons, VAT numbering, billing cycles and invoice payment tracking Co-Authored-By: Claude Sonnet 5.5 --- .../Commands/ReconcileServiceExpiry.php | 28 +++ app/Console/Commands/ServiceExpiry.php | 64 +++++- app/Console/Kernel.php | 21 +- app/Enums/Product/ProductBillingCycle.php | 8 +- app/Helpers/OrderHelper.php | 6 +- .../Api/Admin/Coupon/CouponController.php | 72 +++++++ .../Concerns/ManagesCustomerBilling.php | 3 +- .../Concerns/ManagesCustomerServices.php | 125 ++++++++++- .../CustomerBillingProfileController.php | 29 +++ .../EmailTemplate/EmailHealthController.php | 15 ++ .../Concerns/HandlesInvoiceAttachments.php | 2 +- .../Concerns/SendsInvoiceNotifications.php | 41 ++++ .../Api/Admin/Invoice/InvoiceController.php | 11 +- .../Admin/Invoice/InvoiceVatController.php | 82 +++++++ .../Product/ProductBillingCycleController.php | 71 ++++++ .../Api/Admin/Product/ProductController.php | 18 +- .../Api/Admin/Service/ServiceController.php | 4 +- .../Admin/Setting/VatNumberingController.php | 41 ++++ .../Api/Client/BillingProfileController.php | 25 +++ .../Client/Domain/DomainOrderController.php | 8 +- .../Api/Client/InvoiceLookupController.php | 40 ++++ .../Api/Service/CouponPreviewController.php | 44 ++++ .../Api/Service/OrderControllerRefactor.php | 30 ++- .../Api/Service/VmRebuildController.php | 3 + app/Http/Requests/Api/Admin/CouponRequest.php | 58 +++++ .../Requests/Api/BillingProfileRequest.php | 32 +++ .../Api/Client/InvoiceLookupRequest.php | 21 ++ .../Api/Order/CouponPreviewRequest.php | 27 +++ .../Requests/Api/Order/StoreOrderRequest.php | 5 +- .../Resources/Api/Admin/InvoiceResource.php | 3 + .../Resources/Api/Admin/OrderResource.php | 3 + .../Resources/Api/Admin/ProductResource.php | 6 + .../Service/ServiceClientDetailResource.php | 1 + .../Service/Order/InvoiceDetailResource.php | 28 ++- .../Api/Service/Order/OrderDetailResource.php | 37 ++++ .../Resources/Api/Service/ServiceResource.php | 11 +- .../Hosting/ProvisionHostingAccountJob.php | 2 + .../Customer/HostingAccountCreatedJob.php | 5 +- .../Mail/SendInformationVirtualMachine.php | 26 +-- app/Jobs/Mail/SendInvoiceMailJob.php | 21 ++ app/Mail/AutoRenewFailedMail.php | 47 ++++ .../DomainOrderReceivedNotification.php | 11 + app/Mail/Customer/HostingAccountCreated.php | 27 ++- app/Mail/InvoiceNotificationMail.php | 3 + app/Mail/ServiceRenewedMail.php | 26 +++ app/Models/Coupon.php | 55 +++++ app/Models/CouponRedemption.php | 31 +++ app/Models/Customer.php | 4 + app/Models/Invoice.php | 22 +- app/Models/InvoiceAttachment.php | 1 + app/Models/Order.php | 7 + app/Models/Service.php | 7 + app/Observers/OrderObserver.php | 17 ++ app/Providers/RouteServiceProvider.php | 5 + .../Api/Invoice/InvoiceRepository.php | 4 +- .../Api/Service/ServiceRepository.php | 10 +- .../ServiceClient/ServiceClientRepository.php | 10 +- .../Concerns/FiltersBillingRecords.php | 48 +++++ .../Invoice/InvoiceRepository.php | 7 + app/Repositories/Order/OrderRepository.php | 7 + app/Services/AdminPaymentNotifier.php | 7 +- .../Api/Domain/DomainOrderService.php | 3 + app/Services/Api/Order/OrderQueryService.php | 81 +++++-- app/Services/Api/Order/OrderService.php | 71 ++++-- app/Services/AutoRenewFailureNotifier.php | 77 +++++++ .../Concerns/ActivatesPaidInvoices.php | 17 ++ app/Services/Coupon/CouponService.php | 136 ++++++++++++ .../Customer/CustomerBillingService.php | 13 +- .../CustomerServiceProvisioningService.php | 19 +- app/Services/InvoicePdfService.php | 29 +++ app/Services/InvoiceReminderService.php | 3 + app/Services/Order/ServiceOrderRecorder.php | 3 + app/Services/OrderService.php | 2 + app/Services/Plesk/PleskHostingService.php | 5 +- .../Product/ProductCategorySampleService.php | 3 +- app/Services/RenewalInvoiceNotifier.php | 42 ++++ app/Services/ServiceExpiryReconciler.php | 63 ++++++ app/Services/ServiceRenewalNotifier.php | 106 +++++++++ .../Billing/BillingDuplicateDetector.php | 202 ++++++++++++++++++ app/Support/BillingCycleCatalog.php | 122 +++++++++++ app/Support/CustomerBillingProfile.php | 30 +++ app/Support/EmailHealth.php | 50 +++++ app/Support/EmailScenarioRegistry.php | 12 ++ app/Support/InvoiceBuyer.php | 25 +++ app/Support/InvoiceLookup.php | 72 +++++++ app/Support/MailSettings.php | 10 + app/Support/ProductBillingCycles.php | 110 ++++++++++ app/Support/RenewalPeriodGuard.php | 52 +++++ app/Support/RenewalPricing.php | 46 ++-- app/Support/ServiceExpirySync.php | 23 ++ app/Support/VatCalculator.php | 44 ++++ app/Support/VatNumbering.php | 68 ++++++ app/Support/VatSettings.php | 63 ++++++ ...9_000001_backfill_service_renewal_logs.php | 71 ++++++ ..._000001_seed_service_renewed_templates.php | 23 ++ ...6_10_19_100001_add_vat_invoice_columns.php | 47 ++++ ...026_10_19_100001_create_coupons_tables.php | 65 ++++++ ...200001_seed_auto_renew_failed_template.php | 17 ++ ...reseed_invoice_emails_with_lookup_code.php | 16 ++ ...reseed_hosting_account_created_subject.php | 14 ++ ...2026_10_19_500001_backfill_orders_date.php | 14 ++ .../seeders/EmailTemplates/AdminTemplates.php | 18 ++ .../EmailTemplates/BillingTemplates.php | 6 +- .../EmailTemplates/ServiceTemplates.php | 35 ++- .../EmailTemplates/TemplateVariables.php | 10 + resources/views/pdf/invoice.blade.php | 2 +- resources/views/pdf/invoice/buyer.blade.php | 8 +- resources/views/pdf/invoice/footer.blade.php | 4 +- resources/views/pdf/invoice/header.blade.php | 4 +- resources/views/pdf/invoice/items.blade.php | 17 +- routes/api/admin/billing.php | 8 + routes/api/admin/catalog.php | 19 ++ routes/api/admin/customers.php | 8 + routes/api/admin/system.php | 11 + routes/client.php | 18 ++ .../Billing/BillingDuplicateDetectorTest.php | 167 +++++++++++++++ tests/Feature/Api/Admin/CouponAdminTest.php | 97 +++++++++ .../Admin/Customer/AdminRenewServiceTest.php | 157 ++++++++++++++ tests/Feature/Api/Admin/EmailHealthTest.php | 42 ++++ .../Api/Admin/Invoice/InvoiceSendMailTest.php | 77 +++++++ tests/Feature/Api/Admin/InvoiceVatTest.php | 66 ++++++ .../Api/Admin/Service/RenewalReminderTest.php | 26 ++- tests/Feature/Api/Admin/VatAutomationTest.php | 177 +++++++++++++++ .../Feature/Api/Client/BillingProfileTest.php | 54 +++++ .../Feature/Api/Client/InvoiceLookupTest.php | 131 ++++++++++++ .../Feature/Api/Client/OrderInvoicesTest.php | 69 ++++++ tests/Feature/Api/Service/CouponOrderTest.php | 188 ++++++++++++++++ .../Feature/Api/Service/RenewServiceTest.php | 19 +- tests/Feature/Console/ServiceExpiryTest.php | 82 +++++++ .../Console/ServiceExpiryUnificationTest.php | 133 ++++++++++++ tests/Feature/CustomBillingCycleTest.php | 34 +++ .../Mail/HostingAccountCreatedInvoiceTest.php | 70 ++++++ tests/Feature/ProductBillingCyclesTest.php | 25 +++ tests/Unit/EmailTemplateRendererTest.php | 27 +++ 134 files changed, 4926 insertions(+), 185 deletions(-) create mode 100644 app/Console/Commands/ReconcileServiceExpiry.php create mode 100644 app/Http/Controllers/Api/Admin/Coupon/CouponController.php create mode 100644 app/Http/Controllers/Api/Admin/Customer/CustomerBillingProfileController.php create mode 100644 app/Http/Controllers/Api/Admin/EmailTemplate/EmailHealthController.php create mode 100644 app/Http/Controllers/Api/Admin/Invoice/InvoiceVatController.php create mode 100644 app/Http/Controllers/Api/Admin/Product/ProductBillingCycleController.php create mode 100644 app/Http/Controllers/Api/Admin/Setting/VatNumberingController.php create mode 100644 app/Http/Controllers/Api/Client/BillingProfileController.php create mode 100644 app/Http/Controllers/Api/Client/InvoiceLookupController.php create mode 100644 app/Http/Controllers/Api/Service/CouponPreviewController.php create mode 100644 app/Http/Requests/Api/Admin/CouponRequest.php create mode 100644 app/Http/Requests/Api/BillingProfileRequest.php create mode 100644 app/Http/Requests/Api/Client/InvoiceLookupRequest.php create mode 100644 app/Http/Requests/Api/Order/CouponPreviewRequest.php create mode 100644 app/Mail/AutoRenewFailedMail.php create mode 100644 app/Mail/ServiceRenewedMail.php create mode 100644 app/Models/Coupon.php create mode 100644 app/Models/CouponRedemption.php create mode 100644 app/Services/AutoRenewFailureNotifier.php create mode 100644 app/Services/Coupon/CouponService.php create mode 100644 app/Services/RenewalInvoiceNotifier.php create mode 100644 app/Services/ServiceExpiryReconciler.php create mode 100644 app/Services/ServiceRenewalNotifier.php create mode 100644 app/Support/Billing/BillingDuplicateDetector.php create mode 100644 app/Support/BillingCycleCatalog.php create mode 100644 app/Support/CustomerBillingProfile.php create mode 100644 app/Support/EmailHealth.php create mode 100644 app/Support/InvoiceBuyer.php create mode 100644 app/Support/InvoiceLookup.php create mode 100644 app/Support/ProductBillingCycles.php create mode 100644 app/Support/RenewalPeriodGuard.php create mode 100644 app/Support/ServiceExpirySync.php create mode 100644 app/Support/VatCalculator.php create mode 100644 app/Support/VatNumbering.php create mode 100644 app/Support/VatSettings.php create mode 100644 database/migrations/2026_10_19_000001_backfill_service_renewal_logs.php create mode 100644 database/migrations/2026_10_19_000001_seed_service_renewed_templates.php create mode 100644 database/migrations/2026_10_19_100001_add_vat_invoice_columns.php create mode 100644 database/migrations/2026_10_19_100001_create_coupons_tables.php create mode 100644 database/migrations/2026_10_19_200001_seed_auto_renew_failed_template.php create mode 100644 database/migrations/2026_10_19_300001_reseed_invoice_emails_with_lookup_code.php create mode 100644 database/migrations/2026_10_19_400001_reseed_hosting_account_created_subject.php create mode 100644 database/migrations/2026_10_19_500001_backfill_orders_date.php create mode 100644 tests/Feature/Api/Admin/Billing/BillingDuplicateDetectorTest.php create mode 100644 tests/Feature/Api/Admin/CouponAdminTest.php create mode 100644 tests/Feature/Api/Admin/Customer/AdminRenewServiceTest.php create mode 100644 tests/Feature/Api/Admin/EmailHealthTest.php create mode 100644 tests/Feature/Api/Admin/InvoiceVatTest.php create mode 100644 tests/Feature/Api/Admin/VatAutomationTest.php create mode 100644 tests/Feature/Api/Client/BillingProfileTest.php create mode 100644 tests/Feature/Api/Client/InvoiceLookupTest.php create mode 100644 tests/Feature/Api/Client/OrderInvoicesTest.php create mode 100644 tests/Feature/Api/Service/CouponOrderTest.php create mode 100644 tests/Feature/Console/ServiceExpiryUnificationTest.php create mode 100644 tests/Feature/CustomBillingCycleTest.php create mode 100644 tests/Feature/Mail/HostingAccountCreatedInvoiceTest.php create mode 100644 tests/Feature/ProductBillingCyclesTest.php diff --git a/app/Console/Commands/ReconcileServiceExpiry.php b/app/Console/Commands/ReconcileServiceExpiry.php new file mode 100644 index 0000000..55e7a34 --- /dev/null +++ b/app/Console/Commands/ReconcileServiceExpiry.php @@ -0,0 +1,28 @@ +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; + } +} diff --git a/app/Console/Commands/ServiceExpiry.php b/app/Console/Commands/ServiceExpiry.php index 37dc71d..bab48ac 100644 --- a/app/Console/Commands/ServiceExpiry.php +++ b/app/Console/Commands/ServiceExpiry.php @@ -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}."); } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index ca7b9fc..22b7a72 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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') diff --git a/app/Enums/Product/ProductBillingCycle.php b/app/Enums/Product/ProductBillingCycle.php index c8be702..12a8c2f 100644 --- a/app/Enums/Product/ProductBillingCycle.php +++ b/app/Enums/Product/ProductBillingCycle.php @@ -2,6 +2,8 @@ namespace App\Enums\Product; +use App\Support\BillingCycleCatalog; + enum ProductBillingCycle: string { case ONE_TIME = 'onetime'; @@ -40,14 +42,16 @@ enum ProductBillingCycle: string public static function vietnameseLabelFor(?string $code): string { - return self::tryFrom((string) $code)?->vietnameseLabel() ?? ($code ?: 'N/A'); + return self::tryFrom((string) $code)?->vietnameseLabel() + ?? BillingCycleCatalog::labelForCode($code) + ?? ($code ?: 'N/A'); } public static function vietnameseLabelForMonths(int $months): string { $cycle = collect(self::cases())->first(fn (self $case) => $case->months() === $months && $months > 0); - return $cycle?->vietnameseLabel() ?? $months . ' tháng'; + return $cycle?->vietnameseLabel() ?? BillingCycleCatalog::labelForMonths($months); } public function months(): int diff --git a/app/Helpers/OrderHelper.php b/app/Helpers/OrderHelper.php index c618841..b4d80ac 100644 --- a/app/Helpers/OrderHelper.php +++ b/app/Helpers/OrderHelper.php @@ -2,7 +2,7 @@ namespace App\Helpers; -use App\Enums\Product\ProductBillingCycle; +use App\Support\BillingCycleCatalog; use App\Models\Invoice; use App\Models\Order; use Illuminate\Support\Str; @@ -37,9 +37,9 @@ class OrderHelper */ public static function cycleToMonths(?string $cycle): int { - $enum = ProductBillingCycle::tryFrom($cycle ?? '') + $months = BillingCycleCatalog::monthsFor($cycle) ?? throw new \InvalidArgumentException("Unknown billing cycle: " . ($cycle ?? 'null')); - return $enum === ProductBillingCycle::ONE_TIME ? 1 : $enum->months(); + return $months ?: 1; } } diff --git a/app/Http/Controllers/Api/Admin/Coupon/CouponController.php b/app/Http/Controllers/Api/Admin/Coupon/CouponController.php new file mode 100644 index 0000000..fc17e8e --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Coupon/CouponController.php @@ -0,0 +1,72 @@ +query('filter', '')); + + $paginator = Coupon::query() + ->withCount('activeRedemptions as used_count') + ->withSum('activeRedemptions as discounted_total', 'amount') + ->when($search !== '', fn ($q) => $q->where(fn ($w) => $w + ->where('code', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"))) + ->orderByDesc('id') + ->paginate(min((int) $request->query('per_page', 15), 100)); + + return response()->json([ + 'error' => false, + 'data' => $paginator->items(), + 'meta' => [ + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + ], + 'product_types' => array_map( + fn (ProductTypeEnums $type) => ['value' => $type->value, 'label' => $type->label()], + ProductTypeEnums::cases(), + ), + ]); + } + + public function store(CouponRequest $request): JsonResponse + { + $coupon = Coupon::create($request->validated()); + + return response()->json(['error' => false, 'message' => 'Đã tạo mã giảm giá.', 'data' => $coupon], 201); + } + + public function update(CouponRequest $request, int $id): JsonResponse + { + $coupon = Coupon::findOrFail($id); + $coupon->update($request->validated()); + + return response()->json(['error' => false, 'message' => 'Đã cập nhật mã giảm giá.', 'data' => $coupon->fresh()]); + } + + public function destroy(int $id): JsonResponse + { + $coupon = Coupon::findOrFail($id); + + if ($coupon->redemptions()->exists()) { + return response()->json([ + 'error' => true, + 'message' => 'Mã đã được sử dụng, không thể xoá. Hãy tắt mã thay vì xoá.', + ], 422); + } + + $coupon->delete(); + + return response()->json(['error' => false, 'message' => 'Đã xoá mã giảm giá.']); + } +} diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerBilling.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerBilling.php index 39eb465..e6d6579 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerBilling.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerBilling.php @@ -21,7 +21,8 @@ trait ManagesCustomerBilling 'date' => 'nullable|date', 'due_date' => 'nullable|date', 'date_paid' => 'nullable|date', - 'note' => 'nullable|string', + 'note' => 'nullable|string|max:2000', + 'amount_paid' => 'nullable|integer|min:0', 'sub_total' => 'nullable|integer|min:0', 'tax' => 'nullable|integer|min:0', 'total' => 'nullable|integer|min:0', diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php index 5611edf..e28f2c3 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php @@ -2,10 +2,20 @@ namespace App\Http\Controllers\Api\Admin\Customer\Concerns; +use App\Models\ActivityLog; +use App\Models\Invoice; +use App\Models\InvoiceAttachment; use App\Models\Service; -use App\Support\AutoEmailGate; +use App\Services\Api\Order\OrderQueryService; +use App\Services\ServiceRenewalNotifier; +use App\Services\ServiceRenewalReminderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\Rule; +use App\Support\BillingCycleCatalog; +use App\Support\RenewalPeriodGuard; trait ManagesCustomerServices { @@ -44,7 +54,7 @@ trait ManagesCustomerServices 'requirements' => 'nullable|string', 'feedback' => 'nullable|string', 'price' => 'nullable|numeric|min:0', - 'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially', + 'billing_cycle' => ['nullable', Rule::in(BillingCycleCatalog::codes())], 'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id', 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', @@ -84,12 +94,113 @@ trait ManagesCustomerServices return response()->json(['error' => false, 'message' => 'Đã đổi port thành công', 'data' => $service]); } - public function notifyServiceRenewal(string $uuid, int $serviceId): JsonResponse + public function notifyServiceRenewal( + string $uuid, + int $serviceId, + ServiceRenewalReminderService $reminders, + ): JsonResponse { - AutoEmailGate::markManual(); - $this->serviceProvisioning->notifyRenewal($uuid, $serviceId); + $customer = $this->resolveCustomer($uuid); + $service = Service::query() + ->where('customer_id', $customer->id) + ->where('id', $serviceId) + ->first(); - return response()->json(['error' => false, 'message' => 'Đã gửi thông báo gia hạn đến khách hàng.']); + abort_if(! $service, 404, 'Không tìm thấy dịch vụ'); + abort_if(! $customer->email, 422, 'Khách hàng chưa có email'); + abort_if(! $service->expires_at, 422, 'Dịch vụ chưa có ngày hết hạn'); + + $reminders->send([$service->id], Auth::guard('admin')->user()); + + return response()->json([ + 'error' => false, + 'message' => 'Đã đưa email nhắc gia hạn vào hàng đợi gửi và ghi nhận lịch sử.', + ]); + } + + public function serviceRenewals(string $uuid, int $serviceId): JsonResponse + { + $service = Service::where('customer_id', $this->resolveCustomer($uuid)->id)->findOrFail($serviceId); + + $renewals = ActivityLog::where('subject_type', Service::class) + ->where('subject_id', $service->id) + ->where('event', 'renewed') + ->latest('id') + ->limit(100) + ->get(['id', 'causer_type', 'causer_name', 'old_values', 'new_values', 'created_at']); + + return response()->json(['error' => false, 'data' => $renewals]); + } + + public function renewService(string $uuid, int $serviceId, Request $request): JsonResponse + { + $data = $request->validate([ + 'months' => 'required|integer|min:1|max:120', + 'payment' => 'required|in:balance,manual', + 'note' => 'nullable|string|max:500', + 'proof' => ['nullable', ...array_slice(explode('|', InvoiceAttachment::UPLOAD_RULE), 1)], + ]); + + $customer = $this->resolveCustomer($uuid); + $current = Service::where('customer_id', $customer->id)->find($serviceId); + $previousExpiresAt = $current?->expires_at; + + if ($current && $violation = RenewalPeriodGuard::violation($current, (int) $data['months'])) { + return response()->json(['error' => true, 'message' => $violation], 422); + } + + $result = app(OrderQueryService::class)->renewServiceByAdmin( + $serviceId, + $customer, + (int) $data['months'], + $data['payment'], + (int) auth()->id(), + (string) ($data['note'] ?? ''), + ); + + if (! $result['ok']) { + return response()->json( + ['error' => true, 'message' => $result['message'], 'data' => $result['data'] ?? null], + $result['code'], + ); + } + + $this->notifyRenewal($serviceId, (int) $result['data']['invoice_id'], (int) $data['months'], $previousExpiresAt, $data['payment']); + + if ($data['payment'] === 'manual' && $request->hasFile('proof')) { + $this->storeRenewalProof($request->file('proof'), (int) $result['data']['invoice_id']); + } + + return response()->json(['error' => false, 'message' => $result['message'], 'data' => $result['data']]); + } + + private function notifyRenewal(int $serviceId, int $invoiceId, int $months, ?\Carbon\CarbonInterface $previousExpiresAt, string $payment): void + { + $admin = auth()->user(); + + app(ServiceRenewalNotifier::class)->notifyAdminRenewal( + Service::with(['customer', 'product'])->findOrFail($serviceId), + Invoice::findOrFail($invoiceId), + $months, + $previousExpiresAt, + $payment, + (string) ($admin?->fullname ?? $admin?->username ?? 'Admin'), + $admin?->id, + ); + } + + private function storeRenewalProof(UploadedFile $file, int $invoiceId): void + { + InvoiceAttachment::create([ + 'invoice_id' => $invoiceId, + 'type' => InvoiceAttachment::TYPE_PAYMENT_PROOF, + 'filename' => $file->getClientOriginalName(), + 'path' => $file->store("invoice-attachments/{$invoiceId}", 'public'), + 'disk' => 'public', + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + 'uploaded_by' => auth()->user()?->username ?? 'admin', + ]); } public function createService(string $uuid, Request $request): JsonResponse @@ -103,7 +214,7 @@ trait ManagesCustomerServices 'started_at' => 'nullable|date', 'expires_at' => 'nullable|date', 'note' => 'nullable|string', - 'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially', + 'billing_cycle' => ['nullable', Rule::in(BillingCycleCatalog::codes())], 'payment_method' => 'required|in:transfer,balance', 'amount_paid' => 'nullable|numeric|min:0', 'proof_images' => 'nullable|array', diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerBillingProfileController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerBillingProfileController.php new file mode 100644 index 0000000..e91cba4 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerBillingProfileController.php @@ -0,0 +1,29 @@ +json([ + 'error' => false, + 'data' => CustomerBillingProfile::payload(Customer::where('uuid', $uuid)->firstOrFail()), + ]); + } + + public function update(BillingProfileRequest $request, string $uuid): JsonResponse + { + return response()->json([ + 'error' => false, + 'message' => 'Đã lưu thông tin xuất hóa đơn', + 'data' => CustomerBillingProfile::save(Customer::where('uuid', $uuid)->firstOrFail(), $request->validated()), + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailHealthController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailHealthController.php new file mode 100644 index 0000000..bbeb72c --- /dev/null +++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailHealthController.php @@ -0,0 +1,15 @@ +json(['error' => false, 'data' => EmailHealth::report()]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php b/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php index d9eb2f5..32ec5ff 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php +++ b/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php @@ -32,7 +32,7 @@ trait HandlesInvoiceAttachments $request->validate([ 'file' => InvoiceAttachment::UPLOAD_RULE, - 'type' => 'nullable|in:' . InvoiceAttachment::TYPE_PAYMENT_PROOF . ',' . InvoiceAttachment::TYPE_DEPLOYMENT, + 'type' => 'nullable|in:' . implode(',', [InvoiceAttachment::TYPE_PAYMENT_PROOF, InvoiceAttachment::TYPE_DEPLOYMENT, InvoiceAttachment::TYPE_CONTRACT]), ]); $file = $request->file('file'); diff --git a/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php b/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php index c724dbc..5a72fbd 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php +++ b/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php @@ -5,10 +5,12 @@ namespace App\Http\Controllers\Api\Admin\Invoice\Concerns; use App\Jobs\Mail\SendInvoiceMailJob; use App\Mail\InvoiceNotificationMail; use App\Models\Invoice; +use App\Models\InvoiceAttachment; use App\Services\InvoiceReminderService; use App\Support\AutoEmailGate; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Queue; @@ -56,6 +58,26 @@ trait SendsInvoiceNotifications ]); } + public function pdf(Request $request, int $id): Response|JsonResponse + { + $invoice = $this->invoiceRepository->find($id); + if (!$invoice) { + return response()->json(['error' => true, 'message' => "Can't find invoice #$id"], 404); + } + + $vat = $request->query('variant') === 'vat'; + if ($vat && !$invoice->vat_number) { + return response()->json(['error' => true, 'message' => 'Hoá đơn chưa được cấp số VAT'], 422); + } + + $fileName = $vat ? $this->invoicePdfService->vatFileName($invoice) : $this->invoicePdfService->generateFileName($invoice); + + return response($this->invoicePdfService->generate($invoice), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="' . $fileName . '"', + ]); + } + public function previewMail(Request $request, int $id): JsonResponse { $invoice = $this->invoiceForMail($request, $id); @@ -72,6 +94,8 @@ trait SendsInvoiceNotifications 'subject' => $mail->envelope()->subject, 'html' => $mail->render(), 'attachment_filename' => $this->invoicePdfService->generateFileName($invoice), + 'vat_attachment_filename' => $invoice->vat_number ? $this->invoicePdfService->vatFileName($invoice) : null, + 'contracts' => $this->contractsOf($invoice)->map(fn (InvoiceAttachment $file) => ['id' => $file->id, 'filename' => $file->filename])->values(), ], ]); } @@ -95,6 +119,8 @@ trait SendsInvoiceNotifications $request->input('subject'), $request->filled('html') ? $request->input('html') : null, Auth::guard('admin')->id(), + $request->boolean('attach_vat'), + $this->contractsOf($invoice)->whereIn('id', (array) $request->input('contract_ids', []))->pluck('id')->all(), )); return response()->json([ @@ -119,6 +145,9 @@ trait SendsInvoiceNotifications 'type' => 'nullable|string|in:invoice,renewal', 'subject' => 'nullable|required_with:html|string|max:255', 'html' => 'nullable|required_with:subject|string|max:500000', + 'attach_vat' => 'nullable|boolean', + 'contract_ids' => 'nullable|array|max:10', + 'contract_ids.*' => 'integer', ]); $invoice = $this->invoiceRepository->find($id); @@ -131,6 +160,18 @@ trait SendsInvoiceNotifications return response()->json(['error' => true, 'message' => 'Customer email not found'], 422); } + if ($request->boolean('attach_vat') && !$invoice->vat_number) { + return response()->json(['error' => true, 'message' => 'Hoá đơn chưa được cấp số VAT, không thể đính kèm hoá đơn VAT'], 422); + } + return $invoice; } + + private function contractsOf(Invoice $invoice) + { + return InvoiceAttachment::query() + ->where('invoice_id', $invoice->id) + ->where('type', InvoiceAttachment::TYPE_CONTRACT) + ->get(); + } } diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php index b6b31fd..dec41da 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php +++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php @@ -42,7 +42,9 @@ class InvoiceController extends Controller public function index(Request $request): JsonResponse { - $query = $this->invoiceRepository->filter( + $perPage = min((int) $request->get('per_page', 15), 100); + + $data = $this->invoiceRepository->filter( $request->all(), [ 'payment', @@ -54,13 +56,14 @@ class InvoiceController extends Controller [], ['*'], true - ); + )->paginate($perPage); return response()->json([ 'error' => false, 'data' => [ - 'items' => InvoiceResource::collection($query->get())->resolve(), - 'total' => $query->count(), + 'items' => InvoiceResource::collection($data->items())->resolve(), + 'total' => $data->total(), + 'per_page' => $data->perPage(), ], ]); } diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceVatController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceVatController.php new file mode 100644 index 0000000..6b7babe --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceVatController.php @@ -0,0 +1,82 @@ +json(['error' => false, 'data' => $this->payload(Invoice::with('customer')->findOrFail($id))]); + } + + public function update(Request $request, int $id): JsonResponse + { + $invoice = Invoice::with('customer')->findOrFail($id); + + $data = $request->validate([ + 'vat_serial' => 'nullable|string|max:20|required_with:vat_number', + 'vat_number' => [ + 'nullable', 'string', 'max:30', 'required_with:vat_serial', + Rule::unique('invoices', 'vat_number') + ->where('vat_serial', $request->input('vat_serial')) + ->ignore($invoice->id), + ], + 'vat_issued_at' => 'nullable|date|required_with:vat_number', + 'refresh_buyer' => 'sometimes|boolean', + ]); + + $invoice->fill([ + 'vat_serial' => $data['vat_serial'] ?? null, + 'vat_number' => $data['vat_number'] ?? null, + 'vat_issued_at' => $data['vat_issued_at'] ?? null, + ]); + + if (!$invoice->vat_number) { + $invoice->buyer_snapshot = null; + } elseif (!$invoice->buyer_snapshot || ($data['refresh_buyer'] ?? false)) { + $invoice->buyer_snapshot = InvoiceBuyer::fromCustomer($invoice->customer); + } + + $invoice->save(); + + return response()->json(['error' => false, 'message' => 'Đã lưu thông tin hóa đơn VAT', 'data' => $this->payload($invoice)]); + } + + public function issue(int $id): JsonResponse + { + $invoice = Invoice::findOrFail($id); + + if ($invoice->vat_number) { + return response()->json(['error' => true, 'message' => 'Hóa đơn đã có số VAT'], 422); + } + + if ((int) $invoice->status === InvoiceStatus::CANCELLED->value) { + return response()->json(['error' => true, 'message' => 'Không thể cấp số VAT cho hóa đơn đã huỷ'], 422); + } + + $invoice = VatNumbering::issue($invoice); + + return response()->json(['error' => false, 'message' => 'Đã cấp số hóa đơn VAT', 'data' => $this->payload($invoice)]); + } + + private function payload(Invoice $invoice): array + { + return [ + 'lookup_code' => $invoice->lookup_code, + 'vat_serial' => $invoice->vat_serial, + 'vat_number' => $invoice->vat_number, + 'vat_issued_at' => $invoice->vat_issued_at?->toDateString(), + 'buyer' => InvoiceBuyer::forInvoice($invoice), + 'buyer_locked' => (bool) $invoice->buyer_snapshot, + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Product/ProductBillingCycleController.php b/app/Http/Controllers/Api/Admin/Product/ProductBillingCycleController.php new file mode 100644 index 0000000..977d297 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Product/ProductBillingCycleController.php @@ -0,0 +1,71 @@ +json(['error' => false, 'data' => $this->payload(ProductBillingCycles::all())]); + } + + public function update(Request $request): JsonResponse + { + $validated = $request->validate(ProductBillingCycles::rules()); + + return response()->json([ + 'error' => false, + 'message' => 'Đã lưu chu kỳ thanh toán.', + 'data' => $this->payload(ProductBillingCycles::save($validated['cycles'])), + ]); + } + + public function updateCustom(Request $request): JsonResponse + { + $validated = $request->validate([ + 'months' => 'present|array', + 'months.*' => 'integer|min:1|max:' . BillingCycleCatalog::MAX_MONTHS . '|not_in:' . implode(',', BillingCycleCatalog::builtInMonths()), + ]); + + $removed = array_diff(BillingCycleCatalog::customMonths(), $validated['months']); + foreach ($removed as $months) { + $code = BillingCycleCatalog::codeFor($months); + if (ProductPricings::where('billing_cycle', $code)->exists() || OrderItem::where('billing_cycle', $code)->exists()) { + return response()->json([ + 'error' => true, + 'message' => 'Không thể xoá chu kỳ ' . BillingCycleCatalog::labelForMonths($months) . ' vì đang được dùng ở sản phẩm hoặc đơn hàng.', + ], 422); + } + } + + BillingCycleCatalog::saveCustomMonths($validated['months']); + + return response()->json([ + 'error' => false, + 'message' => 'Đã lưu chu kỳ tuỳ chỉnh.', + 'data' => $this->payload(ProductBillingCycles::all()), + ]); + } + + /** + * @param array> $cycles + */ + private function payload(array $cycles): array + { + return [ + 'types' => ProductBillingCycles::typeOptions(), + 'cycles' => $cycles, + 'custom_months' => BillingCycleCatalog::customMonths(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index e9fe766..4aff253 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -12,6 +12,8 @@ use App\Repositories\Admin\Product\AdminProductRepositoryInterface; use App\Models\Os; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use App\Support\BillingCycleCatalog; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Maatwebsite\Excel\Excel as ExcelWriter; use Maatwebsite\Excel\Facades\Excel; @@ -26,7 +28,7 @@ class ProductController extends Controller public function index(Request $request): JsonResponse { - $perPage = min((int) $request->get('per_page', 15), 100); + $perPage = min((int) $request->get('per_page', 15), 200); $paginator = $this->products->paginate( $request->only('filter', 'type', 'is_active'), $perPage @@ -93,6 +95,8 @@ class ProductController extends Controller 'price_biennially' => 'nullable|numeric|min:0', 'price_triennially' => 'nullable|numeric|min:0', 'price_onetime' => 'nullable|numeric|min:0', + 'custom_prices' => 'nullable|array', + 'custom_prices.*' => 'nullable|numeric|min:0', 'features' => 'nullable|array', ]); @@ -164,6 +168,8 @@ class ProductController extends Controller 'price_biennially' => 'nullable|numeric|min:0', 'price_triennially' => 'nullable|numeric|min:0', 'price_onetime' => 'nullable|numeric|min:0', + 'custom_prices' => 'nullable|array', + 'custom_prices.*' => 'nullable|numeric|min:0', 'features' => 'nullable|array', ]); @@ -214,12 +220,18 @@ class ProductController extends Controller 'onetime' => ['price_onetime', ['one_time']], ]; + foreach (array_keys($data['custom_prices'] ?? []) as $cycle) { + if (in_array($cycle, BillingCycleCatalog::codes(), true)) { + $cycles[$cycle] = ['custom_prices.' . $cycle, []]; + } + } + foreach ($cycles as $cycle => [$field, $legacyCycles]) { - if (!array_key_exists($field, $data)) { + if (!Arr::has($data, $field)) { continue; } - $price = $data[$field]; + $price = Arr::get($data, $field); if ($price !== null && $price !== '') { ProductPricings::updateOrCreate( diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php index e14b87b..7e9b1f1 100644 --- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php @@ -16,6 +16,8 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Collection; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; +use App\Support\BillingCycleCatalog; class ServiceController extends Controller { @@ -61,7 +63,7 @@ class ServiceController extends Controller { $data = $request->validate([ 'domain' => 'required|string|max:255', - 'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially', + 'billing_cycle' => ['nullable', Rule::in(BillingCycleCatalog::codes())], ]); $pricing = DomainPricing::matchDomain($data['domain']); diff --git a/app/Http/Controllers/Api/Admin/Setting/VatNumberingController.php b/app/Http/Controllers/Api/Admin/Setting/VatNumberingController.php new file mode 100644 index 0000000..1fae95d --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Setting/VatNumberingController.php @@ -0,0 +1,41 @@ +json(['error' => false, 'data' => VatSettings::numbering()]); + } + + public function update(Request $request): JsonResponse + { + $data = $request->validate([ + 'serial' => 'required|string|max:20|regex:/^[A-Za-z0-9]+$/', + 'next_number' => 'required|integer|min:1', + 'padding' => 'required|integer|min:1|max:12', + 'auto_issue' => 'required|boolean', + ]); + + $data['serial'] = strtoupper($data['serial']); + + $lastIssued = Invoice::query()->where('vat_serial', $data['serial'])->whereNotNull('vat_number')->max('vat_number'); + if ($lastIssued !== null && $data['next_number'] <= (int) $lastIssued) { + return response()->json([ + 'error' => true, + 'message' => "Số tiếp theo phải lớn hơn số đã cấp gần nhất ({$lastIssued}) của ký hiệu {$data['serial']}", + ], 422); + } + + VatSettings::saveNumbering($data); + + return response()->json(['error' => false, 'message' => 'Đã lưu cấu hình đánh số hóa đơn VAT', 'data' => VatSettings::numbering()]); + } +} diff --git a/app/Http/Controllers/Api/Client/BillingProfileController.php b/app/Http/Controllers/Api/Client/BillingProfileController.php new file mode 100644 index 0000000..1054247 --- /dev/null +++ b/app/Http/Controllers/Api/Client/BillingProfileController.php @@ -0,0 +1,25 @@ +json(['error' => false, 'data' => CustomerBillingProfile::payload(auth('client')->user())]); + } + + public function update(BillingProfileRequest $request): JsonResponse + { + return response()->json([ + 'error' => false, + 'message' => 'Đã lưu thông tin xuất hóa đơn', + 'data' => CustomerBillingProfile::save(auth('client')->user(), $request->validated()), + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php b/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php index d24eb54..7c318c9 100644 --- a/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php +++ b/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php @@ -71,7 +71,11 @@ class DomainOrderController extends Controller 'type' => 'domain_order_new', 'title' => 'Đơn đăng ký tên miền mới', 'body' => "Khách hàng {$customer->fullname} vừa đặt mua tên miền {$domainName} (#{$orderNum})", - 'link' => '/domain-orders', + 'link' => '/domain-orders/' . $result['order']->id, + 'data' => [ + 'order_id' => $result['order']->id, + 'order_num' => $orderNum, + ], ]); $setting = Setting::where('key', 'setting')->first(); @@ -105,7 +109,7 @@ class DomainOrderController extends Controller $orderUrl = rtrim(config('app.frontend_url', ''), '/') . '/don-hang'; Mail::to($customer->email)->send( - new DomainOrderReceivedNotification([...$mailData, 'order_url' => $orderUrl]) + new DomainOrderReceivedNotification([...$mailData, 'order_url' => $orderUrl, 'invoice_id' => $result['invoice']->id]) ); } catch (\Throwable $e) { Log::error('DomainOrderController: không thể gửi email khách hàng: ' . $e->getMessage()); diff --git a/app/Http/Controllers/Api/Client/InvoiceLookupController.php b/app/Http/Controllers/Api/Client/InvoiceLookupController.php new file mode 100644 index 0000000..f601a6a --- /dev/null +++ b/app/Http/Controllers/Api/Client/InvoiceLookupController.php @@ -0,0 +1,40 @@ +validated('invoice_number'), $request->validated('lookup_code')); + + if (!$invoice) { + return response()->json(['error' => true, 'message' => self::NOT_FOUND], 404); + } + + return response()->json(['error' => false, 'data' => InvoiceLookup::payload($invoice)]); + } + + public function pdf(InvoiceLookupRequest $request, InvoicePdfService $pdfService): Response|JsonResponse + { + $invoice = InvoiceLookup::find($request->validated('invoice_number'), $request->validated('lookup_code')); + + if (!$invoice) { + return response()->json(['error' => true, 'message' => self::NOT_FOUND], 404); + } + + return response($pdfService->generate($invoice), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="' . $pdfService->generateFileName($invoice) . '"', + ]); + } +} diff --git a/app/Http/Controllers/Api/Service/CouponPreviewController.php b/app/Http/Controllers/Api/Service/CouponPreviewController.php new file mode 100644 index 0000000..c9ff7e2 --- /dev/null +++ b/app/Http/Controllers/Api/Service/CouponPreviewController.php @@ -0,0 +1,44 @@ +validated(); + $data['product_id'] = Product::where('uuid', $data['product_id'])->value('id'); + + try { + $quote = $orders->quote($data); + } catch (\InvalidArgumentException $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 400); + } + + $applied = $coupons->preview( + $data['coupon_code'], + $request->user(), + $quote['product'], + $data['billing_cycle'], + $quote['sub_total'], + ); + + return response()->json([ + 'success' => true, + 'data' => [ + 'code' => $applied['coupon']->code, + 'name' => $applied['coupon']->name, + 'sub_total' => $quote['sub_total'], + 'discount' => $applied['discount'], + 'total' => $quote['sub_total'] - $applied['discount'], + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php index 53420e2..e2f9899 100644 --- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php +++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php @@ -11,6 +11,8 @@ use App\Http\Requests\Api\Order\StoreOrderRequest; use App\Http\Resources\Api\Service\Order\InvoiceDetailResource; use App\Http\Resources\Api\Service\Order\InvoiceSummaryResource; use App\Http\Resources\Api\Service\Order\OrderDetailResource; +use Illuminate\Support\Facades\Storage; +use App\Models\InvoiceAttachment; use App\Http\Resources\Api\Service\Order\OrderItemResource; use App\Http\Resources\Api\Service\Order\OrderSummaryResource; use App\Http\Resources\Api\Service\Order\ServiceSummaryResource; @@ -193,8 +195,9 @@ class OrderControllerRefactor extends Controller public function getClientInvoiceDetailByOrderNum(Request $request, string $orderNumber): JsonResponse { $order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNumber); + $invoice = $order ? $this->orderQueryService->pickOrderInvoice($order, $request->query('invoice')) : null; - if (!$order?->invoice) { + if (!$invoice) { return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404); } @@ -202,7 +205,7 @@ class OrderControllerRefactor extends Controller return response()->json([ 'success' => true, - 'data' => new InvoiceDetailResource($order->invoice->setRelation('order', $order)), + 'data' => new InvoiceDetailResource($invoice->setRelation('order', $order)), ]); } @@ -292,12 +295,14 @@ class OrderControllerRefactor extends Controller ): Response { $order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNum); - if (!$order?->invoice) { + $invoice = $order ? $this->orderQueryService->pickOrderInvoice($order, $request->query('invoice')) : null; + + if (!$invoice) { abort(404, 'Hóa đơn không tồn tại.'); } - $pdf = $pdfService->generate($order->invoice); - $filename = $pdfService->generateFileName($order->invoice); + $pdf = $pdfService->generate($invoice); + $filename = $pdfService->generateFileName($invoice); return response($pdf, 200, [ 'Content-Type' => 'application/pdf', @@ -305,6 +310,21 @@ class OrderControllerRefactor extends Controller ]); } + public function downloadOrderContract(Request $request, string $orderNum, int $attachmentId) + { + $order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNum); + + $attachment = $order?->invoices + ->flatMap->attachments + ->first(fn ($file) => $file->id === $attachmentId && $file->type === InvoiceAttachment::TYPE_CONTRACT); + + if (!$attachment) { + abort(404, 'Hợp đồng không tồn tại.'); + } + + return Storage::disk($attachment->disk)->response($attachment->path, $attachment->filename); + } + public function getClientServices(Request $request): JsonResponse { $services = $this->orderQueryService->getCustomerServices((int) $request->user()->id); diff --git a/app/Http/Controllers/Api/Service/VmRebuildController.php b/app/Http/Controllers/Api/Service/VmRebuildController.php index 53cb9e2..48c89ca 100644 --- a/app/Http/Controllers/Api/Service/VmRebuildController.php +++ b/app/Http/Controllers/Api/Service/VmRebuildController.php @@ -19,6 +19,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; use Throwable; +use App\Support\VatNumbering; class VmRebuildController extends Controller { @@ -315,6 +316,8 @@ class VmRebuildController extends Controller InvoiceItem::create($line + ['invoice_id' => $invoice->id]); } + VatNumbering::settle($invoice); + return $invoice; }); } catch (Throwable $e) { diff --git a/app/Http/Requests/Api/Admin/CouponRequest.php b/app/Http/Requests/Api/Admin/CouponRequest.php new file mode 100644 index 0000000..d272de9 --- /dev/null +++ b/app/Http/Requests/Api/Admin/CouponRequest.php @@ -0,0 +1,58 @@ +route('id'); + $isPercent = $this->input('type') === Coupon::TYPE_PERCENT; + + return [ + 'code' => ['required', 'string', 'max:50', 'regex:/^[A-Za-z0-9_-]+$/', Rule::unique('coupons', 'code')->ignore($id)], + 'name' => 'nullable|string|max:150', + 'type' => ['required', Rule::in(Coupon::TYPES)], + 'value' => ['required', 'integer', 'min:1', $isPercent ? 'max:99' : 'max:1000000000'], + 'max_discount' => 'nullable|integer|min:1', + 'min_order' => 'nullable|integer|min:0', + 'applies_to' => ['required', Rule::in(Coupon::APPLIES_TO)], + 'product_types' => 'nullable|array', + 'product_types.*' => ['string', Rule::in(array_column(ProductTypeEnums::cases(), 'value'))], + 'product_ids' => 'nullable|array', + 'product_ids.*' => ['integer', Rule::exists('products', 'id')], + 'billing_cycles' => 'nullable|array', + 'billing_cycles.*' => ['string', Rule::in([...BillingCycleCatalog::codes(), 'onetime'])], + 'usage_limit' => 'nullable|integer|min:1', + 'per_customer_limit' => 'nullable|integer|min:1', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date|after_or_equal:starts_at', + 'is_active' => 'boolean', + ]; + } + + public function messages(): array + { + return [ + 'code.unique' => 'Mã giảm giá đã tồn tại.', + 'code.regex' => 'Mã chỉ gồm chữ, số, gạch ngang và gạch dưới.', + 'value.max' => 'Giảm theo % tối đa 99.', + ]; + } + + public function attributes(): array + { + return ['ends_at' => 'ngày kết thúc']; + } +} diff --git a/app/Http/Requests/Api/BillingProfileRequest.php b/app/Http/Requests/Api/BillingProfileRequest.php new file mode 100644 index 0000000..148e9dc --- /dev/null +++ b/app/Http/Requests/Api/BillingProfileRequest.php @@ -0,0 +1,32 @@ + 'nullable|string|max:255|required_with:tax_code', + 'tax_code' => ['nullable', 'string', 'regex:/^\d{10}(-\d{3})?$/'], + 'invoice_address' => 'nullable|string|max:500', + 'invoice_email' => 'nullable|email|max:255', + ]; + } + + public function messages(): array + { + return [ + 'tax_code.regex' => 'Mã số thuế phải gồm 10 số hoặc 13 số (dạng 0123456789-001)', + 'invoice_company.required_with' => 'Vui lòng nhập tên đơn vị khi có mã số thuế', + 'invoice_email.email' => 'Email nhận hóa đơn không hợp lệ', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/InvoiceLookupRequest.php b/app/Http/Requests/Api/Client/InvoiceLookupRequest.php new file mode 100644 index 0000000..0242f70 --- /dev/null +++ b/app/Http/Requests/Api/Client/InvoiceLookupRequest.php @@ -0,0 +1,21 @@ + 'required|string|max:50', + 'lookup_code' => 'required|string|max:16', + ]; + } +} diff --git a/app/Http/Requests/Api/Order/CouponPreviewRequest.php b/app/Http/Requests/Api/Order/CouponPreviewRequest.php new file mode 100644 index 0000000..7e3e250 --- /dev/null +++ b/app/Http/Requests/Api/Order/CouponPreviewRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:50'], + 'product_id' => ['required', Rule::exists('products', 'uuid')->whereNull('deleted_at')], + 'billing_cycle' => ['required', Rule::in([...BillingCycleCatalog::codes(), 'onetime'])], + 'addons' => 'array', + 'addons.*.addon_id' => 'nullable|exists:product_addon,id', + 'addons.*.qty' => 'nullable|integer|min:0|max:1000000', + ]; + } +} diff --git a/app/Http/Requests/Api/Order/StoreOrderRequest.php b/app/Http/Requests/Api/Order/StoreOrderRequest.php index 92e8ab6..989d972 100644 --- a/app/Http/Requests/Api/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Api/Order/StoreOrderRequest.php @@ -5,7 +5,7 @@ namespace App\Http\Requests\Api\Order; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use App\Enums\Product\ProductBillingCycle; +use App\Support\BillingCycleCatalog; use App\Enums\Product\ProductTypeEnums; use App\Models\Product; @@ -35,11 +35,12 @@ class StoreOrderRequest extends FormRequest { return [ 'product_id' => ['required', Rule::exists('products', 'uuid')->whereNull('deleted_at')], - 'billing_cycle'=> ['required', Rule::enum(ProductBillingCycle::class)], + 'billing_cycle'=> ['required', Rule::in([...BillingCycleCatalog::codes(), 'onetime'])], 'server_name' => $this->isHostingOrder() ? ['required', 'string', 'max:253', 'regex:'.self::DOMAIN_PATTERN] : 'nullable|string|max:255', 'os_id' => 'nullable|integer', + 'coupon_code' => 'nullable|string|max:50', 'addons' => 'array', 'addons.*.addon_id' => 'nullable|exists:product_addon,id', 'addons.*.qty' => 'nullable|integer|min:0|max:1000000', diff --git a/app/Http/Resources/Api/Admin/InvoiceResource.php b/app/Http/Resources/Api/Admin/InvoiceResource.php index d15636b..47a7aa4 100644 --- a/app/Http/Resources/Api/Admin/InvoiceResource.php +++ b/app/Http/Resources/Api/Admin/InvoiceResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\Admin; +use App\Support\Billing\BillingDuplicateDetector; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -12,6 +13,7 @@ class InvoiceResource extends JsonResource return [ 'id' => $this->id, 'invoice_number' => $this->invoice_number, + 'duplicate' => BillingDuplicateDetector::forInvoice($this->id), 'order_num' => $this->whenLoaded('order', fn() => $this->order?->order_num), 'order_items' => $this->when( $this->relationLoaded('order') && $this->order?->relationLoaded('orderItems'), @@ -25,6 +27,7 @@ class InvoiceResource extends JsonResource ]), 'sub_total' => $this->sub_total, 'discount_amount' => $this->discount_amount, + 'coupon_code' => $this->coupon_code, 'tax' => $this->tax, 'total' => $this->total, // Công nợ: đã thu bao nhiêu / còn thiếu bao nhiêu. Kế toán cần thấy ngay trên diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php index b3e743f..562e661 100644 --- a/app/Http/Resources/Api/Admin/OrderResource.php +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\Admin; +use App\Support\Billing\BillingDuplicateDetector; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -12,6 +13,7 @@ class OrderResource extends JsonResource return [ 'id' => $this->id, 'order_num' => $this->order_num, + 'duplicate' => BillingDuplicateDetector::forOrder($this->id), 'customer' => $this->whenLoaded('customer', fn() => [ 'id' => $this->customer->id, 'uuid' => $this->customer->uuid, @@ -95,6 +97,7 @@ class OrderResource extends JsonResource ])), 'sub_total' => $this->sub_total, 'discount_amount' => $this->discount_amount, + 'coupon_code' => $this->coupon_code, 'total' => $this->total, // Order has no payment_id FK — `payment` relation is always null. Fall back to // the raw payment_method string instead of letting whenLoaded() mask it with null. diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php index b36ff0a..d355de2 100644 --- a/app/Http/Resources/Api/Admin/ProductResource.php +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\Admin; +use App\Support\BillingCycleCatalog; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -37,6 +38,8 @@ class ProductResource extends JsonResource 'bandwidth' => $this->bandwidth, 'domain_count' => $this->domain_count, 'features' => $this->features, + 'short_description' => $this->short_description, + 'description' => $this->description, 'setup_fee' => $this->setup_fee, 'min_price' => $minPrice, 'billing_type' => $this->whenLoaded('pricings', function () { @@ -52,6 +55,9 @@ class ProductResource extends JsonResource 'price_annually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'annually'))->price), 'price_biennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'biennially'))->price), 'price_triennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'triennially'))->price), + 'custom_prices' => $this->whenLoaded('pricings', fn () => $this->pricings + ->filter(fn ($pricing) => str_starts_with((string) $pricing->billing_cycle, BillingCycleCatalog::CODE_PREFIX)) + ->mapWithKeys(fn ($pricing) => [$pricing->billing_cycle => $pricing->price])), 'price_onetime' => $this->whenLoaded('pricings', fn () => optional($this->pricings->first(fn ($pricing) => in_array($pricing->billing_cycle, ['onetime', 'one_time'], true)))->price), 'is_active' => $this->is_active, 'created_at' => $this->created_at, diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php index 00d55e8..2bc3946 100644 --- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -112,6 +112,7 @@ class ServiceClientDetailResource extends JsonResource ->where('is_active', 1) ->value('price') ?? 0), 'addon_total' => \App\Support\RenewalPricing::addonTotal($this->resource, 1), + 'renewal_options' => \App\Support\RenewalPricing::options($product), ] : null, 'vm' => $vm ? [ 'id' => $vm->id, diff --git a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php index eda9889..aa0598d 100644 --- a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php +++ b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources\Api\Service\Order; use App\Enums\Invoice\InvoiceStatus; +use App\Enums\Order\OrderStatus; use App\Models\InvoiceAttachment; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -20,7 +21,10 @@ class InvoiceDetailResource extends JsonResource 'status_text' => InvoiceStatus::tryFrom((int) $this->status)?->label() ?? '-', 'sub_total' => (float) $this->sub_total, 'discount_amount' => (float) ($this->discount_amount ?? 0), + 'coupon_code' => $this->coupon_code, 'tax' => (float) $this->tax, + 'lookup_code' => $this->lookup_code, + 'vat_number' => $this->vat_number, 'total' => (float) $this->total, // Công nợ: đã thu / còn thiếu. Dịch vụ chỉ kích hoạt khi outstanding = 0. 'amount_paid' => (float) $this->amount_paid, @@ -37,7 +41,10 @@ class InvoiceDetailResource extends JsonResource ]), 'order' => $this->whenLoaded('order', fn () => [ 'order_num' => $this->order->order_num, + 'status' => (int) $this->order->status, + 'status_text' => OrderStatus::tryFrom((int) $this->order->status)?->label() ?? '-', 'total' => (float) $this->order->total, + 'created_at' => $this->order->created_at?->toISOString(), ]), 'items' => $this->whenLoaded('order', fn () => $this->order->orderItems->map(fn ($item) => [ 'product_name' => $item->product?->name, @@ -47,8 +54,9 @@ class InvoiceDetailResource extends JsonResource 'months' => $item->months, 'os_id' => $item->os_id, ])->values()->all()), + 'service' => $this->whenLoaded('order', fn () => $this->resolveService()), 'attachments' => $this->whenLoaded('attachments', fn () => $this->attachments - ->where('type', '!=', InvoiceAttachment::TYPE_DEPLOYMENT) + ->whereNotIn('type', [InvoiceAttachment::TYPE_DEPLOYMENT, InvoiceAttachment::TYPE_CONTRACT]) ->values() ->map(fn ($a) => [ 'filename' => $a->filename, @@ -62,6 +70,24 @@ class InvoiceDetailResource extends JsonResource ]; } + private function resolveService(): ?array + { + $items = $this->order->orderItems; + $item = $this->service_id + ? $items->first(fn ($i) => (int) $i->service?->id === (int) $this->service_id) + : null; + $item ??= $items->first(); + + return $item ? [ + 'product_id' => $item->product_id, + 'name' => $item->product?->name, + 'billing_cycle' => $item->billing_cycle, + 'months' => $item->months, + 'server_name' => $item->server_name, + 'product_type' => $item->product?->product_type, + ] : null; + } + private function resolveVmware(\App\Models\ModuleVmware $vm): array { return [ diff --git a/app/Http/Resources/Api/Service/Order/OrderDetailResource.php b/app/Http/Resources/Api/Service/Order/OrderDetailResource.php index 8d37b8f..d7d9b8a 100644 --- a/app/Http/Resources/Api/Service/Order/OrderDetailResource.php +++ b/app/Http/Resources/Api/Service/Order/OrderDetailResource.php @@ -4,6 +4,8 @@ namespace App\Http\Resources\Api\Service\Order; use App\Enums\Invoice\InvoiceStatus; use App\Enums\Order\OrderStatus; +use App\Models\InvoiceAttachment; +use App\Services\InvoicePaymentService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -11,6 +13,8 @@ class OrderDetailResource extends JsonResource { use ResolvesPaymentMethod; + private const INVOICE_TYPE_LABELS = [1 => 'Đơn mới', 2 => 'Gia hạn', 3 => 'Nâng cấp']; + public function toArray(Request $request): array { return [ @@ -37,6 +41,39 @@ class OrderDetailResource extends JsonResource 'paid_at' => $this->invoice->paid_at?->toISOString(), 'payment_method' => $this->resolvePaymentMethod($this->invoice), ]), + 'invoices' => $this->whenLoaded('invoices', fn () => $this->invoices + ->sortByDesc('id') + ->values() + ->map(fn ($invoice) => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'invoice_type' => (int) $invoice->invoice_type, + 'type_text' => self::INVOICE_TYPE_LABELS[(int) $invoice->invoice_type] ?? 'Hóa đơn', + 'status' => (int) $invoice->status, + 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-', + 'total' => (float) $invoice->total, + 'amount_paid' => (float) $invoice->amount_paid, + 'due_date' => $invoice->due_date?->toISOString(), + 'paid_at' => $invoice->paid_at?->toISOString(), + 'billing_period_start' => $invoice->billing_period_start, + 'billing_period_end' => $invoice->billing_period_end, + 'payable' => in_array((int) $invoice->status, InvoicePaymentService::PAYABLE_STATUSES, true), + 'payment_url' => $invoice->setRelation('order', $this->resource)->clientPath(), + ])), + 'contracts' => $this->whenLoaded('invoices', fn () => $this->invoices + ->flatMap(fn ($invoice) => $invoice->relationLoaded('attachments') + ? $invoice->attachments + ->where('type', InvoiceAttachment::TYPE_CONTRACT) + ->map(fn ($attachment) => [ + 'id' => $attachment->id, + 'filename' => $attachment->filename, + 'mime_type' => $attachment->mime_type, + 'size' => (int) $attachment->size, + 'invoice_number' => $invoice->invoice_number, + 'uploaded_at' => $attachment->created_at?->toISOString(), + ]) + : collect()) + ->values()), 'items' => $this->whenLoaded('orderItems') ? OrderItemResource::collection($this->orderItems) : [], ]; } diff --git a/app/Http/Resources/Api/Service/ServiceResource.php b/app/Http/Resources/Api/Service/ServiceResource.php index d5c370c..0cd147b 100644 --- a/app/Http/Resources/Api/Service/ServiceResource.php +++ b/app/Http/Resources/Api/Service/ServiceResource.php @@ -2,6 +2,8 @@ namespace App\Http\Resources\Api\Service; +use App\Support\BillingCycleCatalog; +use App\Support\ProductBillingCycles; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -77,15 +79,16 @@ class ServiceResource extends JsonResource ]; })->values(), 'pricing' => $this->pricings - ->sortBy(fn($p) => $periodMonths[$p->billing_cycle] ?? 999) + ->filter(fn($p) => ProductBillingCycles::allows($this->product_type, $p->billing_cycle)) + ->sortBy(fn($p) => $periodMonths[$p->billing_cycle] ?? BillingCycleCatalog::monthsFor($p->billing_cycle) ?? 999) ->values() ->map(fn($p) => [ 'id' => $p->id, - 'name' => $billingCycleLabels[$p->billing_cycle] ?? $p->billing_cycle, + 'name' => $billingCycleLabels[$p->billing_cycle] ?? BillingCycleCatalog::labelForCode($p->billing_cycle) ?? $p->billing_cycle, 'price' => (int) $p->price, 'price_sale' => $p->price_sale ? (int) $p->price_sale : null, - 'period' => $periodMonths[$p->billing_cycle] ?? 1, - 'period_unit' => $periodUnits[$p->billing_cycle] ?? 'month', + 'period' => $periodMonths[$p->billing_cycle] ?? BillingCycleCatalog::monthsFor($p->billing_cycle) ?? 1, + 'period_unit' => $periodUnits[$p->billing_cycle] ?? (BillingCycleCatalog::monthsFor($p->billing_cycle) ? $p->billing_cycle : 'month'), ]), $this->mergeWhen($this->product_type === 'hosting', fn () => [ 'service_plan' => $this->servicePlan?->summary(), diff --git a/app/Jobs/Hosting/ProvisionHostingAccountJob.php b/app/Jobs/Hosting/ProvisionHostingAccountJob.php index 58d2df1..46930bf 100644 --- a/app/Jobs/Hosting/ProvisionHostingAccountJob.php +++ b/app/Jobs/Hosting/ProvisionHostingAccountJob.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Jobs\Hosting; +use App\Models\Invoice; use App\Models\OrderItem; use App\Models\ServicePlan; use App\Services\Plesk\PleskHostingService; @@ -103,6 +104,7 @@ class ProvisionHostingAccountJob implements ShouldQueue 'dns_manager_server_id' => $server->id, 'domain_name' => $domainName, 'hosting_plan' => $hostingPlan, + 'invoice_id' => Invoice::where('order_id', $this->item->order_id)->where('invoice_type', 1)->orderBy('id')->value('id'), ]); if ($result['error']) { diff --git a/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php b/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php index bdf0278..a01f76a 100644 --- a/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php +++ b/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php @@ -3,6 +3,7 @@ namespace App\Jobs\Mail\Customer; use App\Mail\Customer\HostingAccountCreated; +use App\Services\InvoicePdfService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -34,8 +35,10 @@ class HostingAccountCreatedJob implements ShouldQueue } try { + $invoice = app(InvoicePdfService::class)->attachment($this->data['invoice_id'] ?? null); + Mail::to($this->data['customer_email']) - ->send(new HostingAccountCreated($this->data)); + ->send(new HostingAccountCreated($this->data, $invoice['pdf'] ?? null, $invoice['file_name'] ?? null)); } catch (\Throwable $e) { Log::error('Không thể gửi email thông báo khởi tạo hosting: ' . $e->getMessage(), [ 'customer_email' => $this->data['customer_email'] ?? null, diff --git a/app/Jobs/Mail/SendInformationVirtualMachine.php b/app/Jobs/Mail/SendInformationVirtualMachine.php index b784ca3..609c5c1 100644 --- a/app/Jobs/Mail/SendInformationVirtualMachine.php +++ b/app/Jobs/Mail/SendInformationVirtualMachine.php @@ -4,7 +4,6 @@ namespace App\Jobs\Mail; use App\Http\Services\Telegram; use App\Mail\Customer\VmCredentials; -use App\Models\Invoice; use App\Services\InvoicePdfService; use Carbon\Carbon; use Illuminate\Bus\Queueable; @@ -33,28 +32,9 @@ class SendInformationVirtualMachine implements ShouldQueue public function handle(): void { try { - // Generate PDF inside the job — never pass binary blobs through queue payload - $invoice = null; - $invoicePdf = null; - $invoicePdfFileName = null; - - if ($this->invoiceId) { - try { - $invoice = Invoice::with(['invoiceItems.product', 'customer', 'order.orderItems']) - ->find($this->invoiceId); - - if ($invoice) { - $pdfService = app(InvoicePdfService::class); - $invoicePdf = $pdfService->generate($invoice); - $invoicePdfFileName = $pdfService->generateFileName($invoice); - } - } catch (\Throwable $e) { - Log::warning('SendInformationVirtualMachine: PDF generation failed, sending without attachment', [ - 'invoice_id' => $this->invoiceId, - 'error' => $e->getMessage(), - ]); - } - } + $attachment = app(InvoicePdfService::class)->attachment($this->invoiceId); + $invoicePdf = $attachment['pdf'] ?? null; + $invoicePdfFileName = $attachment['file_name'] ?? null; $email = new VmCredentials($this->data, $invoicePdf, $invoicePdfFileName); diff --git a/app/Jobs/Mail/SendInvoiceMailJob.php b/app/Jobs/Mail/SendInvoiceMailJob.php index 5bee4af..34e000f 100644 --- a/app/Jobs/Mail/SendInvoiceMailJob.php +++ b/app/Jobs/Mail/SendInvoiceMailJob.php @@ -5,6 +5,7 @@ namespace App\Jobs\Mail; use App\Mail\GenericTemplateMail; use App\Mail\InvoiceNotificationMail; use App\Models\Invoice; +use App\Models\InvoiceAttachment; use App\Models\Mail as MailLog; use App\Services\Email\EmailTemplateService; use App\Services\InvoicePdfService; @@ -31,6 +32,8 @@ class SendInvoiceMailJob implements ShouldQueue public readonly ?string $subject, public readonly ?string $html, public readonly ?int $sentBy, + public readonly bool $attachVat = false, + public readonly array $contractIds = [], ) {} public function handle(InvoicePdfService $pdfService, EmailTemplateService $templates): void @@ -47,6 +50,7 @@ class SendInvoiceMailJob implements ShouldQueue $mail = $this->html !== null ? $this->editedMail($templates, $pdf, $pdfFileName) : InvoiceNotificationMail::forInvoice($invoice, $this->type, $this->message, $pdf, $pdfFileName); + $this->attachExtras($mail, $invoice, $pdfService); $sent = Mail::to($invoice->customer->email)->send($mail); $this->log($templates, $invoice, $mail, $sent === null ? 'failed' : 'sent'); } catch (Throwable $e) { @@ -64,6 +68,23 @@ class SendInvoiceMailJob implements ShouldQueue } } + private function attachExtras(Mailable $mail, Invoice $invoice, InvoicePdfService $pdfService): void + { + if ($this->attachVat && $invoice->vat_number) { + $mail->attachData($pdfService->generate($invoice), $pdfService->vatFileName($invoice), ['mime' => 'application/pdf']); + } + + $contracts = InvoiceAttachment::query() + ->where('invoice_id', $invoice->id) + ->where('type', InvoiceAttachment::TYPE_CONTRACT) + ->whereIn('id', $this->contractIds) + ->get(); + + foreach ($contracts as $contract) { + $mail->attachFromStorageDisk($contract->disk, $contract->path, $contract->filename, ['mime' => $contract->mime_type]); + } + } + private function editedMail(EmailTemplateService $templates, string $pdf, string $pdfFileName): GenericTemplateMail { return (new GenericTemplateMail( diff --git a/app/Mail/AutoRenewFailedMail.php b/app/Mail/AutoRenewFailedMail.php new file mode 100644 index 0000000..fc8a843 --- /dev/null +++ b/app/Mail/AutoRenewFailedMail.php @@ -0,0 +1,47 @@ +service->server_name; + $frontend = MailGlobals::frontendUrl(); + + return [ + 'client_name' => $this->customer->fullname ?? '', + 'product_name' => $this->service->product?->name ?? 'Dịch vụ', + 'server_name_suffix' => $serverName !== '' ? ' (' . e($serverName) . ')' : '', + 'expires_at' => $this->service->expires_at?->format('d/m/Y') ?? 'N/A', + 'invoice_number' => $this->invoice->invoice_number, + 'outstanding' => number_format(InvoicePaymentService::outstanding($this->invoice), 0, ',', '.'), + 'balance_formatted' => number_format((int) $this->customer->total, 0, ',', '.'), + 'topup_url' => $frontend . '/nap-tien', + 'invoice_url' => $frontend . $this->invoice->clientPath(), + ]; + } +} diff --git a/app/Mail/Customer/DomainOrderReceivedNotification.php b/app/Mail/Customer/DomainOrderReceivedNotification.php index 559bb53..9238489 100644 --- a/app/Mail/Customer/DomainOrderReceivedNotification.php +++ b/app/Mail/Customer/DomainOrderReceivedNotification.php @@ -3,8 +3,10 @@ namespace App\Mail\Customer; use App\Mail\TemplateMailable; +use App\Services\InvoicePdfService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Mail\Mailables\Attachment; use Illuminate\Queue\SerializesModels; class DomainOrderReceivedNotification extends TemplateMailable implements ShouldQueue @@ -15,6 +17,15 @@ class DomainOrderReceivedNotification extends TemplateMailable implements Should protected array $data, ) {} + public function attachments(): array + { + $invoice = app(InvoicePdfService::class)->attachment($this->data['invoice_id'] ?? null); + + return $invoice + ? [Attachment::fromData(fn () => $invoice['pdf'], $invoice['file_name'])->withMime('application/pdf')] + : []; + } + protected function scenario(): string { return 'domain_order_received'; diff --git a/app/Mail/Customer/HostingAccountCreated.php b/app/Mail/Customer/HostingAccountCreated.php index bd3c32f..82ef371 100644 --- a/app/Mail/Customer/HostingAccountCreated.php +++ b/app/Mail/Customer/HostingAccountCreated.php @@ -6,17 +6,26 @@ use App\Mail\TemplateMailable; use App\Support\EmailTemplateShell; use App\Support\MailGlobals; use Illuminate\Bus\Queueable; +use Illuminate\Mail\Mailables\Attachment; use Illuminate\Queue\SerializesModels; class HostingAccountCreated extends TemplateMailable { use Queueable, SerializesModels; - public function __construct(public array $data) {} + public function __construct( + public array $data, + public readonly ?string $invoicePdf = null, + public readonly ?string $invoicePdfFileName = null, + ) {} public function attachments(): array { - return []; + if ($this->invoicePdf === null || $this->invoicePdfFileName === null) { + return []; + } + + return [Attachment::fromData(fn () => $this->invoicePdf, $this->invoicePdfFileName)->withMime('application/pdf')]; } protected function scenario(): string @@ -47,6 +56,8 @@ class HostingAccountCreated extends TemplateMailable 'full_name' => $data['customer_name'] ?? 'Quý khách', 'domain_name' => $data['domain_name'] ?? '', 'hosting_plan' => $data['hosting_plan'] ?? '', + 'server_name' => $data['server_name'] ?? '', + 'recipient_label' => $this->recipientLabel($data), 'server_host_row' => $row('Máy chủ / FTP Host', $data['server_host'] ?? null), 'ftp_username_row' => $row('Tài khoản FTP', $data['ftp_username'] ?? null), 'ftp_password_row' => $row('Mật khẩu FTP', $data['ftp_password'] ?? null), @@ -56,4 +67,16 @@ class HostingAccountCreated extends TemplateMailable 'plesk_block' => $pleskBlock, ]; } + + private function recipientLabel(array $data): string + { + foreach (['customer_name', 'customer_company'] as $key) { + $value = trim((string) ($data[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + return 'Công ty'; + } } diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php index 11d085f..c20b091 100644 --- a/app/Mail/InvoiceNotificationMail.php +++ b/app/Mail/InvoiceNotificationMail.php @@ -5,6 +5,7 @@ namespace App\Mail; use App\Enums\Invoice\InvoiceStatus; use App\Models\Invoice; use App\Services\InvoicePaymentService; +use App\Support\InvoiceLookup; use App\Support\MailGlobals; use App\Support\EmailTemplateShell; use Carbon\Carbon; @@ -91,6 +92,8 @@ class InvoiceNotificationMail extends TemplateMailable ? 'File PDF hóa đơn gia hạn đã được đính kèm trong email này. Vui lòng hoàn tất thanh toán trước hạn để dịch vụ không bị gián đoạn.' : 'File PDF hóa đơn đã được đính kèm trong email này để Quý khách tiện lưu trữ và đối chiếu.', 'pay_button' => $this->payButton(), + 'lookup_row' => InvoiceLookup::emailRow($this->invoice), + 'lookup_note' => InvoiceLookup::emailNote($this->invoice), 'attachments_block' => '', 'note_block' => $this->noteBlock(), ]; diff --git a/app/Mail/ServiceRenewedMail.php b/app/Mail/ServiceRenewedMail.php new file mode 100644 index 0000000..a4ab7b0 --- /dev/null +++ b/app/Mail/ServiceRenewedMail.php @@ -0,0 +1,26 @@ +scenarioKey; + } + + protected function variables(): array + { + return $this->data; + } +} diff --git a/app/Models/Coupon.php b/app/Models/Coupon.php new file mode 100644 index 0000000..4a44468 --- /dev/null +++ b/app/Models/Coupon.php @@ -0,0 +1,55 @@ + 'integer', + 'max_discount' => 'integer', + 'min_order' => 'integer', + 'usage_limit' => 'integer', + 'per_customer_limit' => 'integer', + 'product_types' => 'array', + 'product_ids' => 'array', + 'billing_cycles' => 'array', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'is_active' => 'boolean', + ]; + + public static function normalizeCode(string $code): string + { + return strtoupper(trim($code)); + } + + protected function code(): Attribute + { + return Attribute::set(fn (string $value) => self::normalizeCode($value)); + } + + public function redemptions(): HasMany + { + return $this->hasMany(CouponRedemption::class); + } + + public function activeRedemptions(): HasMany + { + return $this->redemptions()->whereNull('released_at'); + } +} diff --git a/app/Models/CouponRedemption.php b/app/Models/CouponRedemption.php new file mode 100644 index 0000000..a44ebba --- /dev/null +++ b/app/Models/CouponRedemption.php @@ -0,0 +1,31 @@ + 'integer', + 'released_at' => 'datetime', + ]; + + public function coupon(): BelongsTo + { + return $this->belongsTo(Coupon::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 56b6f2e..5fd3abc 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -81,6 +81,10 @@ class Customer extends Authenticatable implements JWTSubject 'birthday', 'province_code', 'ward_code', + 'tax_code', + 'invoice_company', + 'invoice_address', + 'invoice_email', ]; /** diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index a2cbf51..3614098 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -4,11 +4,13 @@ namespace App\Models; use App\Enums\Invoice\InvoiceStatus; use App\Models\Concerns\Markable; +use App\Support\VatNumbering; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; class Invoice extends Model { @@ -23,12 +25,26 @@ class Invoice extends Model 'due_date' => 'date', 'paid_at' => 'datetime', 'auto_renew' => 'boolean', + 'vat_issued_at' => 'date', + 'buyer_snapshot' => 'array', ]; + public static function newLookupCode(): string + { + return Str::upper(Str::random(10)); + } + protected static function booted(): void { static::creating(function (Invoice $invoice) { $invoice->date ??= now()->toDateString(); + $invoice->lookup_code ??= static::newLookupCode(); + }); + + static::updated(function (Invoice $invoice) { + if ($invoice->wasChanged('status')) { + VatNumbering::issueIfAuto($invoice); + } }); } @@ -40,7 +56,11 @@ class Invoice extends Model /** The order that originated this invoice. */ public function clientPath(): string { - return '/hoa-don/' . ($this->order?->order_num ?? $this->invoice_number); + $path = '/hoa-don/' . ($this->order?->order_num ?? $this->invoice_number); + + return $this->order && (int) $this->invoice_type !== 1 + ? $path . '?invoice=' . $this->invoice_number + : $path; } /** @param iterable $services */ diff --git a/app/Models/InvoiceAttachment.php b/app/Models/InvoiceAttachment.php index b2ea55e..17bf530 100644 --- a/app/Models/InvoiceAttachment.php +++ b/app/Models/InvoiceAttachment.php @@ -9,6 +9,7 @@ class InvoiceAttachment extends Model { public const TYPE_PAYMENT_PROOF = 'payment_proof'; public const TYPE_DEPLOYMENT = 'deployment'; + public const TYPE_CONTRACT = 'contract'; public const UPLOAD_RULE = 'required|file|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx|max:51200'; protected $table = 'invoice_attachments'; diff --git a/app/Models/Order.php b/app/Models/Order.php index 662a92f..80579fe 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -22,6 +22,13 @@ class Order extends Model 'paid_at' => 'datetime', ]; + protected static function booted(): void + { + static::creating(function (Order $order) { + $order->date ??= now()->toDateString(); + }); + } + public function customer() { return $this->belongsTo(Customer::class); diff --git a/app/Models/Service.php b/app/Models/Service.php index 54e6a55..dc18f39 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\Concerns\Markable; +use App\Support\ServiceExpirySync; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -25,6 +26,12 @@ class Service extends Model $service->type = Product::query()->where('id', $service->product_id)->value('product_type'); } }); + + static::saved(function (Service $service) { + if ($service->wasRecentlyCreated || $service->wasChanged(['expires_at', 'vm_id'])) { + ServiceExpirySync::toVmware($service); + } + }); } protected $table = 'services'; diff --git a/app/Observers/OrderObserver.php b/app/Observers/OrderObserver.php index 36ec351..34bcae9 100644 --- a/app/Observers/OrderObserver.php +++ b/app/Observers/OrderObserver.php @@ -2,8 +2,10 @@ namespace App\Observers; +use App\Enums\Order\OrderStatus; use App\Models\Order; use App\Repositories\Log\LogRepositoryInterface; +use App\Services\Coupon\CouponService; class OrderObserver { @@ -31,6 +33,8 @@ class OrderObserver */ public function updated(Order $order): void { + $this->syncCouponRedemption($order); + if ($order->getDirty()) { foreach ($order->getDirty() as $attribute => $value) { { if ($attribute !== 'updated_at') { @@ -53,6 +57,10 @@ class OrderObserver */ public function deleted(Order $order): void { + if ($order->coupon_id) { + app(CouponService::class)->setReleased($order, true); + } + $this->logRepository->create([ 'agent' => request()->userAgent(), 'client_ip' => request()->getClientIp(), @@ -77,4 +85,13 @@ class OrderObserver { // } + + private function syncCouponRedemption(Order $order): void + { + if (! $order->coupon_id || ! $order->wasChanged('status')) { + return; + } + + app(CouponService::class)->setReleased($order, (int) $order->status === OrderStatus::CANCELLED->value); + } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 09ba0a9..2624f2d 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -74,6 +74,11 @@ class RouteServiceProvider extends ServiceProvider return Limit::perMinute($key ? 300 : 60)->by($key ?: $request->ip()); }); + RateLimiter::for('invoice-lookup', fn (Request $request) => [ + Limit::perMinute(10)->by('invoice-lookup|' . strtoupper((string) $request->input('invoice_number'))), + Limit::perMinute(30)->by('invoice-lookup|ip|' . $request->ip()), + ]); + // Auth endpoint của khách (login/verify/forgot...). portal-client gọi API từ Next server nên // IP gần như luôn là IP của Next → khoá theo route + email, không theo IP (tránh chặn chung mọi khách). RateLimiter::for('client-auth', function (Request $request) { diff --git a/app/Repositories/Api/Invoice/InvoiceRepository.php b/app/Repositories/Api/Invoice/InvoiceRepository.php index 3936731..3969cc6 100644 --- a/app/Repositories/Api/Invoice/InvoiceRepository.php +++ b/app/Repositories/Api/Invoice/InvoiceRepository.php @@ -8,6 +8,7 @@ use App\Models\Invoice; use App\Models\InvoiceItem; use App\Models\Order; use App\Repositories\Api\BaseApiRepository; +use App\Support\VatNumbering; class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryInterface { @@ -37,6 +38,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn // sub_total là tổng TRƯỚC giảm giá, để sub_total - discount_amount = total. 'sub_total' => (int) $order->total + $discount, 'discount_amount' => $discount, + 'coupon_code' => $order->coupon_code, 'tax' => 0, 'total' => $order->total, 'status' => InvoiceStatus::UNPAID->value, @@ -78,7 +80,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn } } - return $invoice; + return VatNumbering::settle($invoice); } // ------------------------------------------------------------------------- diff --git a/app/Repositories/Api/Service/ServiceRepository.php b/app/Repositories/Api/Service/ServiceRepository.php index d3ee75e..8905512 100644 --- a/app/Repositories/Api/Service/ServiceRepository.php +++ b/app/Repositories/Api/Service/ServiceRepository.php @@ -2,6 +2,7 @@ namespace App\Repositories\Api\Service; +use App\Support\ProductBillingCycles; use App\Models\Product; use App\Repositories\Api\BaseApiRepository; @@ -35,8 +36,13 @@ class ServiceRepository extends BaseApiRepository implements ServiceRepositoryIn public function getPricing(int $productId, string $billingCycle) { - $pricing = $this->model->find($productId) - ->pricings() + $product = $this->model->find($productId); + + if (!ProductBillingCycles::allows($product->product_type, $billingCycle)) { + return null; + } + + $pricing = $product->pricings() ->where('billing_cycle', $billingCycle) ->where('is_active', 1) ->first(); diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php index 060c864..1758134 100644 --- a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php +++ b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php @@ -2,6 +2,7 @@ namespace App\Repositories\Api\ServiceClient; +use App\Support\ProductBillingCycles; use App\Models\Service; use App\Repositories\Api\BaseApiRepository; @@ -46,8 +47,13 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient public function getPricing(int $productId, string $billingCycle) { - $pricing = $this->model->find($productId) - ->pricings() + $product = $this->model->find($productId); + + if (!ProductBillingCycles::allows($product->product_type, $billingCycle)) { + return null; + } + + $pricing = $product->pricings() ->where('billing_cycle', $billingCycle) ->where('is_active', 1) ->first(); diff --git a/app/Repositories/Concerns/FiltersBillingRecords.php b/app/Repositories/Concerns/FiltersBillingRecords.php index a75cb93..a14e177 100644 --- a/app/Repositories/Concerns/FiltersBillingRecords.php +++ b/app/Repositories/Concerns/FiltersBillingRecords.php @@ -2,6 +2,7 @@ namespace App\Repositories\Concerns; +use App\Models\InvoiceAttachment; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; @@ -28,6 +29,53 @@ trait FiltersBillingRecords ->when($to, fn (Builder $q) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay())); } + /** @param callable(): array $duplicates */ + private function whereIsDuplicate(Builder $query, mixed $flag, callable $duplicates): Builder + { + if (!filter_var($flag, FILTER_VALIDATE_BOOLEAN)) { + return $query; + } + + return $query->whereIn($query->getModel()->getQualifiedKeyName(), array_keys($duplicates())); + } + + /** + * @param array $columns + */ + private function applySort(Builder $query, mixed $sortBy, mixed $sortOrder, array $columns): Builder + { + $direction = strtolower((string) $sortOrder) === 'asc' ? 'asc' : 'desc'; + $table = $query->getModel()->getTable(); + + if ($sortBy === 'customer') { + return $query + ->orderBy( + \App\Models\Customer::query()->select('fullname')->whereColumn('customers.id', "{$table}.customer_id")->limit(1), + $direction + ) + ->orderByDesc("{$table}.id"); + } + + if ($sortBy === 'outstanding' && in_array($sortBy, $columns, true)) { + return $query + ->orderByRaw("GREATEST({$table}.total - COALESCE({$table}.amount_paid, 0), 0) {$direction}") + ->orderByDesc("{$table}.id"); + } + + if (!in_array($sortBy, $columns, true)) { + return $query; + } + + return $query->orderBy("{$table}.{$sortBy}", $direction)->orderByDesc("{$table}.id"); + } + + private function whereContractFileLike(Builder $query, string $attachmentsRelation, string $search): Builder + { + return $query->orWhereHas($attachmentsRelation, fn (Builder $files) => $files + ->where('type', InvoiceAttachment::TYPE_CONTRACT) + ->where('filename', 'like', "%{$search}%")); + } + private function whereOrderItemNameLike(Builder $query, string $orderItemsRelation, string $search): Builder { return $query->orWhereHas($orderItemsRelation, fn (Builder $items) => $items diff --git a/app/Repositories/Invoice/InvoiceRepository.php b/app/Repositories/Invoice/InvoiceRepository.php index 0dba6c7..bc35158 100644 --- a/app/Repositories/Invoice/InvoiceRepository.php +++ b/app/Repositories/Invoice/InvoiceRepository.php @@ -2,6 +2,7 @@ namespace App\Repositories\Invoice; +use App\Support\Billing\BillingDuplicateDetector; use App\Models\Invoice; use App\Repositories\BaseRepository; use App\Repositories\Concerns\FiltersBillingRecords; @@ -63,6 +64,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter } $model = $this->whereStatus($model, $data['status'] ?? null, ['unpaid' => 1, 'paid' => 2, 'cancel' => 3, 'overdue' => 4, 'refunded' => 5]); $model = $this->whereCreatedBetween($model, $data['date_from'] ?? null, $data['date_to'] ?? null); + $model = $this->whereIsDuplicate($model, $data['duplicates'] ?? null, BillingDuplicateDetector::invoices(...)); if (isset($data['filter']) && $data['filter'] !== '') { $search = $data['filter']; $model = $model->where(function ($query) use ($search) { @@ -73,6 +75,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter ->orWhere('email', 'like', "%{$search}%"); }); $this->whereOrderItemNameLike($query, 'order.orderItems', $search); + $this->whereContractFileLike($query, 'attachments', $search); }); } if (isset($data['relations'])) { @@ -96,6 +99,10 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter }); } } + $model = $this->applySort($model, $data['sort_by'] ?? null, $data['sort_order'] ?? null, [ + 'invoice_number', 'sub_total', 'tax', 'total', 'status', 'created_at', 'due_date', 'paid_at', 'customer', 'outstanding', + ]); + if (count($withCount) > 0) { $model = $model->withCount($withCount); } diff --git a/app/Repositories/Order/OrderRepository.php b/app/Repositories/Order/OrderRepository.php index 65e320b..ce7c21c 100644 --- a/app/Repositories/Order/OrderRepository.php +++ b/app/Repositories/Order/OrderRepository.php @@ -2,6 +2,7 @@ namespace App\Repositories\Order; +use App\Support\Billing\BillingDuplicateDetector; use App\Models\Order; use App\Repositories\BaseRepository; use App\Repositories\Concerns\FiltersBillingRecords; @@ -59,6 +60,7 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface } $model = $this->whereStatus($model, $data['status'] ?? null, ['active' => 1, 'pending' => 0, 'processing' => 3, 'cancel' => 2]); $model = $this->whereCreatedBetween($model, $data['date_from'] ?? null, $data['date_to'] ?? null); + $model = $this->whereIsDuplicate($model, $data['duplicates'] ?? null, BillingDuplicateDetector::orders(...)); if (! empty($data['customer_id'])) { $model = $model->where('customer_id', $data['customer_id']); } @@ -75,9 +77,14 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface ->orWhere('email', 'like', "%{$search}%"); }); $this->whereOrderItemNameLike($query, 'orderItems', $search); + $this->whereContractFileLike($query, 'invoices.attachments', $search); }); } + $model = $this->applySort($model, $data['sort_by'] ?? null, $data['sort_order'] ?? null, [ + 'order_num', 'sub_total', 'discount_amount', 'total', 'status', 'created_at', 'updated_at', 'customer', + ]); + if (count($withCount) > 0) { $model = $model->withCount($withCount); } diff --git a/app/Services/AdminPaymentNotifier.php b/app/Services/AdminPaymentNotifier.php index 5272ed4..a379761 100644 --- a/app/Services/AdminPaymentNotifier.php +++ b/app/Services/AdminPaymentNotifier.php @@ -15,6 +15,9 @@ class AdminPaymentNotifier $invoice->loadMissing(['customer', 'order']); $outstanding = InvoicePaymentService::outstanding($invoice); $reference = $invoice->order?->order_num ?? $invoice->invoice_number; + $detailLink = $invoice->order + ? '/order-invoice/' . $invoice->order->order_num + : '/order-invoice'; $label = $paymentType === 'order' ? 'đơn hàng' : 'hóa đơn'; $amountFormatted = number_format($amount, 0, ',', '.') . ' VNĐ'; @@ -22,10 +25,12 @@ class AdminPaymentNotifier 'type' => 'payment_received', 'title' => 'Khách hàng thanh toán ' . $label, 'body' => "{$invoice->customer->fullname} đã thanh toán {$amountFormatted} cho {$label} #{$reference}.", - 'link' => '/order-invoice', + 'link' => $detailLink, 'data' => [ 'invoice_id' => $invoice->id, 'order_id' => $invoice->order_id, + 'order_num' => $invoice->order?->order_num, + 'invoice_number' => $invoice->invoice_number, 'amount' => $amount, 'outstanding' => $outstanding, ], diff --git a/app/Services/Api/Domain/DomainOrderService.php b/app/Services/Api/Domain/DomainOrderService.php index 8b541ee..e8412da 100644 --- a/app/Services/Api/Domain/DomainOrderService.php +++ b/app/Services/Api/Domain/DomainOrderService.php @@ -22,6 +22,7 @@ use App\Models\Transaction; use App\Repositories\Api\Domain\DomainOrderRepository; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use App\Support\VatNumbering; class DomainOrderService { @@ -120,6 +121,8 @@ class DomainOrderService 'total_price' => $price, ]); + VatNumbering::settle($invoice); + Transaction::create([ 'uuid' => (string) Str::uuid(), 'public_code' => Transaction::newPublicCode('DOM'), diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php index 486e47b..c619487 100644 --- a/app/Services/Api/Order/OrderQueryService.php +++ b/app/Services/Api/Order/OrderQueryService.php @@ -23,6 +23,7 @@ use App\Support\RenewalPricing; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use App\Support\VatNumbering; class OrderQueryService { @@ -113,6 +114,7 @@ class OrderQueryService 'invoice.customer', 'invoice.payment', 'orderItems.product', + 'invoices.attachments', 'customer', 'vmWare.ipAddress', 'vmWare.os', @@ -122,6 +124,18 @@ class OrderQueryService ->first(); } + public function pickOrderInvoice(Order $order, ?string $invoiceNumber): ?Invoice + { + if (! $invoiceNumber) { + return $order->invoice; + } + + return $order->invoices() + ->where('invoice_number', $invoiceNumber) + ->with(['invoiceItems', 'attachments', 'customer', 'payment']) + ->first(); + } + public function getCustomerServices(int $customerId) { return Service::query() @@ -171,6 +185,36 @@ class OrderQueryService } public function renewService(int $serviceId, Customer $customer, int $months): array + { + $issued = $this->issueRenewal($serviceId, $customer, $months); + if (! $issued['ok']) { + return $issued; + } + + return $this->payRenewalWithBalance($issued['invoice'], $customer, $serviceId); + } + + public function renewServiceByAdmin(int $serviceId, Customer $customer, int $months, string $payment, int $adminId, string $note = ''): array + { + $issued = $this->issueRenewal($serviceId, $customer, $months); + if (! $issued['ok']) { + return $issued; + } + + if ($payment === 'balance') { + return $this->payRenewalWithBalance($issued['invoice'], $customer, $serviceId); + } + + try { + $invoice = $this->invoicePaymentService->markPaidByAdmin($issued['invoice'], $adminId, 'admin', null, $note); + } catch (\InvalidArgumentException $e) { + return ['ok' => false, 'message' => $e->getMessage(), 'code' => 422]; + } + + return $this->renewalResult($invoice, $serviceId); + } + + private function issueRenewal(int $serviceId, Customer $customer, int $months): array { $service = Service::query() ->with(['orderItem.product']) @@ -188,25 +232,23 @@ class OrderQueryService return ['ok' => false, 'message' => 'Không thể xác định giá gia hạn dịch vụ.', 'code' => 400]; } - $cycleMonths = OrderHelper::cycleToMonths($orderItem->billing_cycle); - $validMonths = RenewalPricing::validMonths($orderItem->billing_cycle); + $product = $service->product ?? $orderItem->product; + $option = RenewalPricing::option($product, $months); + + if ($option === null) { + $validMonths = array_column(RenewalPricing::options($product), 'months'); - if (! in_array($months, $validMonths, true)) { return [ 'ok' => false, - 'message' => 'Số tháng gia hạn không hợp lệ với chu kỳ của dịch vụ này. Vui lòng chọn: ' . implode(', ', $validMonths) . ' tháng.', + 'message' => $validMonths === [] + ? 'Sản phẩm hiện không còn bán, không thể gia hạn.' + : 'Thời hạn gia hạn không hợp lệ với sản phẩm này. Vui lòng chọn: ' . implode(', ', $validMonths) . ' tháng.', 'code' => 422, ]; } - $cycles = intdiv($months, $cycleMonths); - - $currentPrice = $this->productRepo->getPricing((int) ($service->product_id ?: $orderItem->product_id), $orderItem->billing_cycle); - if ($currentPrice === null) { - return ['ok' => false, 'message' => 'Sản phẩm hiện không còn bán, không thể gia hạn.', 'code' => 400]; - } - - $renewalPrice = (int) $currentPrice * $cycles; + $cycles = $months / OrderHelper::cycleToMonths($orderItem->billing_cycle); + $renewalPrice = $option['price']; $invoice = DB::transaction(function () use ($service, $orderItem, $renewalPrice, $cycles, $months) { $service = Service::query()->whereKey($service->id)->lockForUpdate()->first(); @@ -219,6 +261,11 @@ class OrderQueryService return $this->issueRenewalInvoice($service, $orderItem, $renewalPrice, $cycles, $months, $pending); }); + return ['ok' => true, 'invoice' => $invoice]; + } + + private function payRenewalWithBalance(Invoice $invoice, Customer $customer, int $serviceId): array + { try { $invoice = $this->invoicePaymentService->payWithBalance($invoice, $customer); } catch (\InvalidArgumentException $e) { @@ -230,11 +277,16 @@ class OrderQueryService ]; } + return $this->renewalResult($invoice, $serviceId); + } + + private function renewalResult(Invoice $invoice, int $serviceId): array + { return [ 'ok' => true, 'message' => 'Gia hạn dịch vụ thành công.', 'data' => $this->renewalPayload($invoice) + [ - 'expires_at' => $service->fresh()->expires_at?->toISOString(), + 'expires_at' => Service::find($serviceId)?->expires_at?->toISOString(), ], ]; } @@ -257,7 +309,7 @@ class OrderQueryService return (int) $invoice->invoiceItems()->where('type', 'renewal')->value('months'); } - private function issueRenewalInvoice(Service $service, OrderItem $orderItem, int $renewalPrice, int $cycles, int $months, ?Invoice $draft): Invoice + private function issueRenewalInvoice(Service $service, OrderItem $orderItem, int $renewalPrice, float $cycles, int $months, ?Invoice $draft): Invoice { $price = $renewalPrice + RenewalPricing::addonTotal($service, $cycles); $periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now(); @@ -295,6 +347,7 @@ class OrderQueryService InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]); } + VatNumbering::settle($invoice); $service->update(['invoice_id' => $invoice->id]); return $invoice; diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index 58e6ef3..74ba0e8 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -12,6 +12,7 @@ use App\Repositories\Api\Order\OrderRepositoryInterface; use App\Repositories\Api\Service\ServiceRepository; use App\Services\InvoicePaymentService; use App\Services\AdminPaymentNotifier; +use App\Services\Coupon\CouponService; use App\Support\AddonQuantityValidator; use Illuminate\Support\Facades\DB; @@ -23,6 +24,7 @@ class OrderService protected InvoiceRepositoryInterface $invoiceRepo, protected InvoicePaymentService $invoicePaymentService, protected AdminPaymentNotifier $adminPaymentNotifier, + protected CouponService $couponService, ) {} // ========================================================================= @@ -41,40 +43,43 @@ class OrderService throw new \InvalidArgumentException('Bạn có đơn hàng chưa thanh toán. Vui lòng thanh toán trước khi tạo đơn mới.'); } - $product = $this->productRepo->find($data['product_id']); - if (!$product) { - throw new \InvalidArgumentException('Sản phẩm không tồn tại.'); - } + ['product' => $product, 'base_price' => $basePrice, 'addons_meta' => $addonsMeta, 'sub_total' => $subTotal] = $this->quote($data); + $couponCode = $data['coupon_code'] ?? null; - $basePrice = $this->productRepo->getPricing($product->id, $data['billing_cycle']); - if (is_null($basePrice)) { - throw new \InvalidArgumentException('Không tìm thấy giá cho chu kỳ thanh toán này.'); - } + $expectedTotal = $couponCode + ? $subTotal - $this->couponService->preview($couponCode, $customer, $product, $data['billing_cycle'], $subTotal)['discount'] + : $subTotal; - // Resolve addons → enriched meta with price & subtotal - $addonsMeta = $this->resolveAddons($data['addons'] ?? [], $product); - $addonsPrice = array_sum(array_column($addonsMeta, 'subtotal')); - $total = $basePrice + $addonsPrice; - - if ($customer->total < $total) { + if ($customer->total < $expectedTotal) { throw new \InvalidArgumentException(sprintf( 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), - number_format($total) + number_format($expectedTotal) )); } - return DB::transaction(function () use ($data, $customer, $product, $basePrice, $total, $addonsMeta) { + return DB::transaction(function () use ($data, $customer, $product, $basePrice, $subTotal, $addonsMeta, $couponCode) { + $applied = $couponCode + ? $this->couponService->lockAndApply($couponCode, $customer, $product, $data['billing_cycle'], $subTotal) + : null; + $discount = $applied['discount'] ?? 0; + $order = $this->orderRepo->create([ 'order_num' => OrderHelper::generateOrderNumber(), 'customer_id' => $customer->id, - 'sub_total' => $basePrice, - 'discount_amount' => 0, - 'total' => $total, + 'sub_total' => $subTotal, + 'discount_amount' => $discount, + 'coupon_id' => $applied['coupon']->id ?? null, + 'coupon_code' => $applied['coupon']->code ?? null, + 'total' => $subTotal - $discount, 'status' => OrderStatus::PENDING->value, 'client_ip' => request()?->ip(), ]); + if ($applied) { + $this->couponService->record($applied['coupon'], $order, $discount); + } + $this->orderRepo->createItem($order->id, [ 'product_id' => $product->id, 'billing_cycle' => $data['billing_cycle'], @@ -95,6 +100,34 @@ class OrderService }); } + /** + * Giá niêm yết (chu kỳ + addon) tính phía server; nguồn duy nhất cho create() và preview mã giảm giá. + * + * @return array{product: Product, base_price: int, addons_meta: array, sub_total: int} + * @throws \InvalidArgumentException + */ + public function quote(array $data): array + { + $product = $this->productRepo->find($data['product_id']); + if (!$product) { + throw new \InvalidArgumentException('Sản phẩm không tồn tại.'); + } + + $basePrice = $this->productRepo->getPricing($product->id, $data['billing_cycle']); + if (is_null($basePrice)) { + throw new \InvalidArgumentException('Không tìm thấy giá cho chu kỳ thanh toán này.'); + } + + $addonsMeta = $this->resolveAddons($data['addons'] ?? [], $product); + + return [ + 'product' => $product, + 'base_price' => $basePrice, + 'addons_meta' => $addonsMeta, + 'sub_total' => $basePrice + array_sum(array_column($addonsMeta, 'subtotal')), + ]; + } + // ========================================================================= // pay — Step 2: Customer pays the pending order // ========================================================================= diff --git a/app/Services/AutoRenewFailureNotifier.php b/app/Services/AutoRenewFailureNotifier.php new file mode 100644 index 0000000..dbd8762 --- /dev/null +++ b/app/Services/AutoRenewFailureNotifier.php @@ -0,0 +1,77 @@ +customer; + if (! $customer) { + return false; + } + + $dedupeKey = sprintf('auto-renew-failed:%d:%s', $invoice->id, now()->toDateString()); + if (! Cache::add($dedupeKey, true, now()->endOfDay())) { + return false; + } + + $invoice->loadMissing('order'); + $name = $service->server_name ?: $service->product?->name ?: 'Dịch vụ'; + + Notification::notifyCustomer($customer, [ + 'type' => AutoRenewFailedMail::SCENARIO, + 'title' => 'Tự động gia hạn chưa thành công', + 'body' => sprintf( + 'Số dư không đủ để tự động gia hạn %s. Cần %s VNĐ, hiện có %s VNĐ. Nạp thêm tiền để hệ thống tự trừ ở lần kiểm tra kế tiếp.', + $name, + number_format(InvoicePaymentService::outstanding($invoice), 0, ',', '.'), + number_format((int) $customer->total, 0, ',', '.'), + ), + 'link' => '/nap-tien', + 'data' => ['invoice_number' => $invoice->invoice_number, 'service_id' => $service->id], + ]); + + if (filled($customer->email) && AutoEmailGate::allows(AutoRenewFailedMail::SCENARIO)) { + $this->sendMail($service, $invoice); + } + + return true; + } + + private function sendMail(Service $service, Invoice $invoice): void + { + try { + $template = $this->templates->loadTemplate(AutoRenewFailedMail::SCENARIO); + } catch (ModelNotFoundException) { + return; + } + + $service->loadMissing('product'); + $mail = new AutoRenewFailedMail($service->customer, $service, $invoice); + + MailLog::create([ + 'template_id' => $template->id, + 'subject' => $mail->envelope()->subject, + 'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'), + 'to' => $service->customer->email, + 'customer_id' => $service->customer_id, + 'message' => $mail->render(), + ]); + + Mail::to($service->customer->email)->queue($mail); + } +} diff --git a/app/Services/Concerns/ActivatesPaidInvoices.php b/app/Services/Concerns/ActivatesPaidInvoices.php index f56b091..ad7a257 100644 --- a/app/Services/Concerns/ActivatesPaidInvoices.php +++ b/app/Services/Concerns/ActivatesPaidInvoices.php @@ -14,6 +14,7 @@ use App\Models\Invoice; use App\Models\OrderItem; use App\Models\Service; use App\Models\ServiceAddon; +use App\Services\ActivityLogger; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -92,6 +93,7 @@ trait ActivatesPaidInvoices ? $service->expires_at : now(); $expiresAt = Carbon::parse($baseDate)->addMonths($months); + $previousExpiresAt = $service->expires_at; $service->update([ 'status' => 'active', @@ -99,6 +101,21 @@ trait ActivatesPaidInvoices 'suspended_at' => null, ]); + app(ActivityLogger::class)->log( + event: 'renewed', + logName: 'Service', + description: "Gia hạn dịch vụ #{$service->id} thêm {$months} tháng (hóa đơn #{$invoice->invoice_number})", + subject: $service, + oldValues: ['expires_at' => $previousExpiresAt?->toDateTimeString()], + newValues: [ + 'expires_at' => $expiresAt->toDateTimeString(), + 'months' => $months, + 'invoice_number' => $invoice->invoice_number, + 'amount' => (int) $invoice->total, + 'payment_method' => $invoice->payment_method, + ], + ); + if ($service->vm_id) { dispatch(new VMJobs((int) $service->vm_id, 'Start', false))->delay(now()->addSeconds(5)); } diff --git a/app/Services/Coupon/CouponService.php b/app/Services/Coupon/CouponService.php new file mode 100644 index 0000000..e0f0816 --- /dev/null +++ b/app/Services/Coupon/CouponService.php @@ -0,0 +1,136 @@ +first(); + + return $this->evaluate($coupon, $customer, $product, $billingCycle, $subTotal, $context); + } + + /** + * @return array{coupon: Coupon, discount: int} + */ + public function lockAndApply(string $code, Customer $customer, Product $product, string $billingCycle, int $subTotal, string $context = Coupon::APPLIES_NEW): array + { + $coupon = Coupon::where('code', Coupon::normalizeCode($code))->lockForUpdate()->first(); + + return $this->evaluate($coupon, $customer, $product, $billingCycle, $subTotal, $context); + } + + public function record(Coupon $coupon, Order $order, int $discount): CouponRedemption + { + return CouponRedemption::create([ + 'coupon_id' => $coupon->id, + 'customer_id' => $order->customer_id, + 'order_id' => $order->id, + 'amount' => $discount, + ]); + } + + public function setReleased(Order $order, bool $released): void + { + CouponRedemption::where('order_id', $order->id) + ->update(['released_at' => $released ? now() : null]); + } + + /** + * @return array{coupon: Coupon, discount: int} + */ + private function evaluate(?Coupon $coupon, Customer $customer, Product $product, string $billingCycle, int $subTotal, string $context): array + { + if (! $coupon || ! $coupon->is_active) { + $this->fail('Mã giảm giá không hợp lệ.'); + } + + if ($coupon->starts_at?->isFuture()) { + $this->fail('Mã giảm giá chưa đến thời gian áp dụng.'); + } + + if ($coupon->ends_at?->isPast()) { + $this->fail('Mã giảm giá đã hết hạn.'); + } + + if (! in_array($coupon->applies_to, [$context, Coupon::APPLIES_ALL], true)) { + $this->fail($context === Coupon::APPLIES_NEW + ? 'Mã giảm giá này không áp dụng cho đơn mới.' + : 'Mã giảm giá này chỉ áp dụng cho đơn mới.'); + } + + if (! $this->inScope($coupon, $product, $billingCycle)) { + $this->fail('Mã giảm giá không áp dụng cho sản phẩm hoặc chu kỳ thanh toán này.'); + } + + if ($subTotal < $coupon->min_order) { + $this->fail(sprintf('Đơn hàng tối thiểu %s VNĐ để áp dụng mã này.', number_format($coupon->min_order))); + } + + $this->assertUsageLeft($coupon, $customer); + + $discount = $this->amountFor($coupon, $subTotal); + if ($discount <= 0) { + $this->fail('Đơn hàng không đủ điều kiện áp dụng mã giảm giá.'); + } + + return ['coupon' => $coupon, 'discount' => $discount]; + } + + private function inScope(Coupon $coupon, Product $product, string $billingCycle): bool + { + return $this->matches($coupon->product_types, $product->product_type) + && $this->matches($coupon->product_ids, $product->id) + && $this->matches($coupon->billing_cycles, $billingCycle); + } + + private function matches(?array $allowed, string|int|null $value): bool + { + return empty($allowed) || in_array($value, $allowed, false); + } + + private function assertUsageLeft(Coupon $coupon, Customer $customer): void + { + $redemptions = $coupon->activeRedemptions(); + + if ($coupon->usage_limit !== null && (clone $redemptions)->count() >= $coupon->usage_limit) { + $this->fail('Mã giảm giá đã hết lượt sử dụng.'); + } + + if ($coupon->per_customer_limit !== null + && (clone $redemptions)->where('customer_id', $customer->id)->count() >= $coupon->per_customer_limit) { + $this->fail('Bạn đã dùng hết số lần cho phép của mã giảm giá này.'); + } + } + + private function amountFor(Coupon $coupon, int $subTotal): int + { + $discount = $coupon->type === Coupon::TYPE_PERCENT + ? intdiv($subTotal * $coupon->value, 100) + : $coupon->value; + + if ($coupon->max_discount !== null) { + $discount = min($discount, $coupon->max_discount); + } + + return min($discount, $subTotal - self::MIN_PAYABLE); + } + + private function fail(string $message): never + { + throw ValidationException::withMessages(['coupon_code' => $message]); + } +} diff --git a/app/Services/Customer/CustomerBillingService.php b/app/Services/Customer/CustomerBillingService.php index 2db7407..b6b0232 100644 --- a/app/Services/Customer/CustomerBillingService.php +++ b/app/Services/Customer/CustomerBillingService.php @@ -54,6 +54,13 @@ class CustomerBillingService unset($data['date_paid']); } + if (array_key_exists('amount_paid', $data) && $data['amount_paid'] === null) { + unset($data['amount_paid']); + } + if (isset($data['amount_paid']) && $data['amount_paid'] > (int) ($data['total'] ?? $invoice->total)) { + abort(422, 'Số tiền đã thu không được lớn hơn tổng hóa đơn'); + } + // Đi qua repository (không gọi $invoice->update() trực tiếp) để cache // của invoiceRepository->filter() (dùng ở getInvoices) được xoá theo, // nếu không danh sách hóa đơn sẽ hiển thị dữ liệu cũ cho tới khi cache hết hạn. @@ -128,13 +135,17 @@ class CustomerBillingService { $customer = $this->resolveCustomer($uuid); - return $this->orderRepository->filter(['client' => $customer->id], [ + $orders = $this->orderRepository->filter(['client' => $customer->id], [ 'customer', 'user:id,username', 'payment', 'invoices', ...OrderItemServiceResource::relationsFor('orderItems'), ]); + + $orders->each(fn ($order) => $order->invoices->each(fn ($invoice) => $invoice->date_paid = $invoice->paid_at)); + + return $orders; } /** diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index a7487d1..74ec7ce 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -3,7 +3,6 @@ namespace App\Services\Customer; use App\Helpers\OrderHelper; -use App\Mail\ServiceRenewalReminderMail; use App\Models\Customer; use App\Models\ModuleVmware; use App\Models\Order; @@ -21,7 +20,6 @@ use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; class CustomerServiceProvisioningService @@ -43,6 +41,7 @@ class CustomerServiceProvisioningService ['customer_uuid' => $uuid], [ 'product', + 'product.pricings' => fn ($query) => $query->where('is_active', 1)->select('id', 'product_id', 'billing_cycle'), 'orderItem', 'invoice', 'vmware.os.osGroup', @@ -186,22 +185,6 @@ class CustomerServiceProvisioningService $service->update(['vm_id' => $vmId]); } - /** - * Gửi email nhắc gia hạn dịch vụ cho khách hàng. - */ - public function notifyRenewal(string $uuid, int $serviceId): void - { - $customer = $this->resolveCustomer($uuid); - - $service = Service::where('customer_id', $customer->id)->where('id', $serviceId) - ->with(['product', 'orderItem']) - ->first(); - abort_if(!$service, 404, 'Không tìm thấy dịch vụ'); - abort_if(!$customer->email, 422, 'Khách hàng chưa có email'); - - Mail::to($customer->email)->send(new ServiceRenewalReminderMail($customer, $service)); - } - /** * Create a service for a customer. For VPS products, accepts a * `virtual_machine_id` from the vCenter inventory and auto-creates the diff --git a/app/Services/InvoicePdfService.php b/app/Services/InvoicePdfService.php index 0f2dd49..f37c8cf 100644 --- a/app/Services/InvoicePdfService.php +++ b/app/Services/InvoicePdfService.php @@ -4,7 +4,9 @@ namespace App\Services; use App\Models\Invoice; use App\Models\Setting; +use App\Support\InvoiceBuyer; use Barryvdh\DomPDF\Facade\Pdf; +use Illuminate\Support\Facades\Log; class InvoicePdfService { @@ -19,6 +21,7 @@ class InvoicePdfService 'invoice' => $invoice, 'invoiceItems' => $invoice->invoiceItems, 'customer' => $invoice->customer, + 'buyer' => InvoiceBuyer::forInvoice($invoice), 'order' => $invoice->order, 'companyName' => $settingValue->company ?? 'Thế Giới Số', 'companyAddress' => $settingValue->address ?? '', @@ -46,6 +49,32 @@ class InvoicePdfService ->output(); } + public function vatFileName(Invoice $invoice): string + { + return 'Hoa-don-VAT-' . $invoice->vat_serial . '-' . $invoice->vat_number . '.pdf'; + } + + /** @return array{pdf: string, file_name: string}|null */ + public function attachment(?int $invoiceId): ?array + { + if (!$invoiceId) { + return null; + } + + try { + $invoice = Invoice::find($invoiceId); + + return $invoice ? ['pdf' => $this->generate($invoice), 'file_name' => $this->generateFileName($invoice)] : null; + } catch (\Throwable $e) { + Log::warning('InvoicePdfService: PDF generation failed, sending without attachment', [ + 'invoice_id' => $invoiceId, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + public function generateFileName(Invoice $invoice): string { return 'Hoa-don-' . $invoice->invoice_number . '.pdf'; diff --git a/app/Services/InvoiceReminderService.php b/app/Services/InvoiceReminderService.php index 6dd8cec..a6deec4 100644 --- a/app/Services/InvoiceReminderService.php +++ b/app/Services/InvoiceReminderService.php @@ -7,6 +7,7 @@ use App\Models\Invoice; use App\Services\Email\EmailTemplateService; use App\Support\AutoEmailGate; use App\Support\EmailTemplateShell; +use App\Support\InvoiceLookup; use Carbon\Carbon; /** @@ -116,6 +117,8 @@ class InvoiceReminderService 'paid_row' => (int) ($invoice->amount_paid ?? 0) > 0 ? EmailTemplateShell::row('Đã thanh toán', number_format((int) $invoice->amount_paid, 0, ',', '.') . ' VNĐ') : '', + 'lookup_row' => InvoiceLookup::emailRow($invoice), + 'lookup_note' => InvoiceLookup::emailNote($invoice), 'login_url' => rtrim((string) config('app.frontend_url', ''), '/') . '/dang-nhap', 'support_email' => 'support@tgs.com.vn', 'support_phone' => '(028) 7309. 7379', diff --git a/app/Services/Order/ServiceOrderRecorder.php b/app/Services/Order/ServiceOrderRecorder.php index 504fa0f..5697051 100644 --- a/app/Services/Order/ServiceOrderRecorder.php +++ b/app/Services/Order/ServiceOrderRecorder.php @@ -14,6 +14,7 @@ use App\Models\Product; use App\Models\Service; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use App\Support\VatNumbering; class ServiceOrderRecorder { @@ -84,6 +85,8 @@ class ServiceOrderRecorder 'total_price' => $price, ]); + VatNumbering::settle($invoice); + return compact('order', 'orderItem', 'invoice'); } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 5595b83..a9cf2e2 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -29,6 +29,7 @@ use Carbon\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use App\Support\VatNumbering; class OrderService { @@ -205,6 +206,7 @@ class OrderService 'total_price' => $item['price'] * $item['quantity'], ], $productItems); $this->invoiceItemRepository->insert($invoiceItemData); + VatNumbering::settle($invoice); $now = now(); OrderItem::insert(array_map(fn ($item) => [ diff --git a/app/Services/Plesk/PleskHostingService.php b/app/Services/Plesk/PleskHostingService.php index da75e8e..bc5afaf 100644 --- a/app/Services/Plesk/PleskHostingService.php +++ b/app/Services/Plesk/PleskHostingService.php @@ -112,7 +112,7 @@ class PleskHostingService 'status' => 'active', ]); - $this->notify($customer, $server, $account, $data['domain_name'], $ftpPassword, $pleskLogin, $pleskPassword); + $this->notify($customer, $server, $account, $data['domain_name'], $ftpPassword, $pleskLogin, $pleskPassword, $data['invoice_id'] ?? null); return ['error' => false, 'data' => $account, 'ftp_password' => $ftpPassword]; } @@ -125,10 +125,12 @@ class PleskHostingService string $ftpPassword, string $pleskLogin, ?string $pleskPassword, + ?int $invoiceId = null, ): void { $shared = [ 'customer_name' => $customer->fullname, 'customer_email' => $customer->email, + 'customer_company' => $customer->company, 'domain_name' => $domainName, 'hosting_plan' => $account->hosting_plan, 'server_name' => $server->name, @@ -143,6 +145,7 @@ class PleskHostingService dispatch(new HostingAccountCreatedJob(array_merge($shared, [ 'server_host' => $server->publicHost(), + 'invoice_id' => $invoiceId, ]))); dispatch(new HostingAccountCreatedAdminNotificationJob(array_merge($shared, [ diff --git a/app/Services/Product/ProductCategorySampleService.php b/app/Services/Product/ProductCategorySampleService.php index 74b491f..46f8379 100644 --- a/app/Services/Product/ProductCategorySampleService.php +++ b/app/Services/Product/ProductCategorySampleService.php @@ -3,6 +3,7 @@ namespace App\Services\Product; use App\Models\ProductCategories; +use App\Support\BillingCycleCatalog; use Illuminate\Support\Facades\DB; final class ProductCategorySampleService @@ -86,6 +87,6 @@ final class ProductCategorySampleService private function recurringCycles(): array { - return ['monthly', 'quarterly', 'semi_annually', 'annually', 'biennially', 'triennially']; + return BillingCycleCatalog::builtInCodes(); } } diff --git a/app/Services/RenewalInvoiceNotifier.php b/app/Services/RenewalInvoiceNotifier.php new file mode 100644 index 0000000..93060d7 --- /dev/null +++ b/app/Services/RenewalInvoiceNotifier.php @@ -0,0 +1,42 @@ +customer; + if (! $customer) { + return; + } + + $name = $service->server_name ?: $service->orderItem?->product?->name ?: 'Dịch vụ'; + $invoice->setRelation('order', $service->orderItem?->order); + + Notification::notifyCustomer($customer, [ + 'type' => 'renewal_invoice', + 'title' => 'Hóa đơn gia hạn dịch vụ', + 'body' => sprintf( + 'Dịch vụ %s hết hạn ngày %s. Hóa đơn gia hạn #%s (%s VNĐ) đang chờ thanh toán.', + $name, + $service->expires_at?->format('d/m/Y') ?? '-', + $invoice->invoice_number, + number_format((int) $invoice->total, 0, ',', '.'), + ), + 'link' => $invoice->clientPath(), + 'data' => ['invoice_number' => $invoice->invoice_number, 'service_id' => $service->id], + ]); + + if ($customer->email && AutoEmailGate::allows(InvoiceNotificationMail::SCENARIO)) { + SendInvoiceMailJob::dispatch($invoice->id, 'renewal', null, null, null, null)->afterCommit(); + } + } +} diff --git a/app/Services/ServiceExpiryReconciler.php b/app/Services/ServiceExpiryReconciler.php new file mode 100644 index 0000000..0a535d4 --- /dev/null +++ b/app/Services/ServiceExpiryReconciler.php @@ -0,0 +1,63 @@ + 0, 'backfilled_services' => 0, 'orphan_vms' => $this->orphanVmCount()]; + + Service::query() + ->with('vmware') + ->whereNotNull('vm_id') + ->whereNotIn('status', ['terminated', 'cancelled']) + ->chunkById(200, function ($services) use (&$stats, $dryRun) { + foreach ($services as $service) { + $this->reconcile($service, $stats, $dryRun); + } + }); + + return $stats; + } + + private function reconcile(Service $service, array &$stats, bool $dryRun): void + { + $vm = $service->vmware; + if (! $vm) { + return; + } + + if ($service->expires_at) { + if ((int) $vm->next_due_date !== $service->expires_at->timestamp) { + $stats['synced_vmware']++; + $dryRun || ServiceExpirySync::toVmware($service); + } + + return; + } + + if ($vm->next_due_date) { + $stats['backfilled_services']++; + $dryRun || $service->update(['expires_at' => Carbon::createFromTimestamp((int) $vm->next_due_date, config('app.timezone'))]); + } + } + + private function orphanVmCount(): int + { + return ModuleVmware::query() + ->whereNotIn('id', Service::query()->whereNotNull('vm_id')->select('vm_id')) + ->where(fn ($query) => $query + ->whereNull('service_id') + ->orWhereNotIn('service_id', Service::query()->select('id'))) + ->count(); + } +} diff --git a/app/Services/ServiceRenewalNotifier.php b/app/Services/ServiceRenewalNotifier.php new file mode 100644 index 0000000..cb16305 --- /dev/null +++ b/app/Services/ServiceRenewalNotifier.php @@ -0,0 +1,106 @@ +loadMissing(['customer', 'product']); + $customer = $service->customer; + $serverName = (string) $service->server_name; + + $data = [ + 'client_name' => $customer->fullname ?? '', + 'customer_name' => $customer->fullname ?? '', + 'customer_email' => $customer->email ?? '', + 'product_name' => $service->product?->name ?? 'Dịch vụ', + 'server_name_suffix' => $serverName !== '' ? ' (' . e($serverName) . ')' : '', + 'months_label' => "{$months} tháng", + 'old_expires_at' => $previousExpiresAt?->format('d/m/Y') ?? 'N/A', + 'expires_at' => $service->expires_at?->format('d/m/Y') ?? 'N/A', + 'invoice_number' => $invoice->invoice_number, + 'amount_formatted' => number_format((float) $invoice->total, 0, ',', '.') . ' VNĐ', + 'payment_method_label' => $payment === 'balance' ? 'Trừ số dư tài khoản' : 'Đã thu ngoài hệ thống', + 'admin_name' => $adminName, + 'price_row' => EmailTemplateShell::row('Số tiền gia hạn', number_format((float) $invoice->total, 0, ',', '.') . ' VNĐ'), + ]; + + AutoEmailGate::markManual(); + + $this->safely(fn () => $this->sendToCustomer($service, $data, $adminId)); + $this->safely(fn () => $this->sendToAdmin($data)); + } + + private function sendToCustomer(Service $service, array $data, ?int $adminId): void + { + $customer = $service->customer; + if (! filled($customer?->email)) { + return; + } + + $mail = new ServiceRenewedMail(self::CUSTOMER_SCENARIO, $data); + $template = $this->templates->loadTemplate(self::CUSTOMER_SCENARIO); + + MailLog::create([ + 'template_id' => $template->id, + 'subject' => $mail->envelope()->subject, + 'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'), + 'to' => $customer->email, + 'customer_id' => $customer->id, + 'sent_by' => $adminId, + 'message' => $mail->render(), + ]); + + Mail::to($customer->email)->queue($mail); + } + + private function sendToAdmin(array $data): void + { + $adminEmail = json_decode((string) Setting::where('key', 'setting')->value('value'), true)['admin_email'] ?? null; + if (! $adminEmail) { + Log::warning('ServiceRenewalNotifier: admin_email not configured in settings.'); + + return; + } + + Mail::to($adminEmail)->queue(new ServiceRenewedMail(self::ADMIN_SCENARIO, $data)); + } + + private function safely(callable $send): void + { + try { + $send(); + } catch (\Throwable $e) { + Log::error('ServiceRenewalNotifier: failed to send email.', ['error' => $e->getMessage()]); + } + } +} diff --git a/app/Support/Billing/BillingDuplicateDetector.php b/app/Support/Billing/BillingDuplicateDetector.php new file mode 100644 index 0000000..ba7bcf1 --- /dev/null +++ b/app/Support/Billing/BillingDuplicateDetector.php @@ -0,0 +1,202 @@ + 'Đặt lặp trong thời gian ngắn', + self::REASON_SAME_SERVICE => 'Cùng khách, sản phẩm và tên dịch vụ', + self::REASON_DOMAIN_TAKEN => 'Tên miền đã có ở đơn khác', + self::REASON_SAME_PERIOD => 'Cùng đơn, loại và kỳ thanh toán', + ]; + + private static ?array $orders = null; + + private static ?array $invoices = null; + + public static function flush(): void + { + self::$orders = self::$invoices = null; + } + + public static function forOrder(int $id): ?array + { + return self::orders()[$id] ?? null; + } + + public static function forInvoice(int $id): ?array + { + return self::invoices()[$id] ?? null; + } + + /** @return array */ + public static function orders(): array + { + return self::$orders ??= self::detectOrders(); + } + + /** @return array */ + public static function invoices(): array + { + return self::$invoices ??= self::detectInvoices(); + } + + private static function detectOrders(): array + { + $lines = DB::table('orders as o') + ->join('order_items as oi', 'oi.order_id', '=', 'o.id') + ->whereNull('o.deleted_at') + ->where('o.status', '!=', OrderStatus::CANCELLED->value) + ->get([ + 'o.id', 'o.order_num', 'o.customer_id', 'o.total', 'o.created_at', + 'oi.product_id', 'oi.billing_cycle', 'oi.domain_name', 'oi.server_name', + ]); + + $orders = $lines->groupBy('id')->map(function (Collection $items) { + $first = $items->first(); + return (object) [ + 'id' => $first->id, + 'ref' => $first->order_num, + 'customer' => $first->customer_id, + 'total' => $first->total, + 'at' => Carbon::parse($first->created_at), + 'fingerprint' => $items->map(fn ($i) => $i->product_id . ':' . $i->billing_cycle . ':' . self::normalize($i->server_name ?: $i->domain_name))->sort()->implode('|'), + 'service_keys' => $items->map(fn ($i) => $first->customer_id . '|' . $i->product_id . '|' . self::normalize($i->server_name ?: $i->domain_name)) + ->filter(fn ($key) => !str_ends_with($key, '|'))->unique()->values(), + 'domains' => $items->map(fn ($i) => self::normalize($i->domain_name))->filter()->unique()->values(), + ]; + }); + + $found = self::resubmits($orders->groupBy(fn ($o) => $o->customer . '|' . $o->total . '|' . $o->fingerprint)); + + $found += self::sharedKeys($orders, 'service_keys', self::REASON_SAME_SERVICE, $found); + $found += self::sharedKeys($orders, 'domains', self::REASON_DOMAIN_TAKEN, $found); + + return $found; + } + + private static function detectInvoices(): array + { + $invoices = DB::table('invoices') + ->whereNull('deleted_at') + ->where('status', '!=', InvoiceStatus::CANCELLED->value) + ->get(['id', 'invoice_number', 'order_id', 'customer_id', 'invoice_type', 'total', 'billing_period_start', 'created_at']); + + $found = []; + + $samePeriod = $invoices->filter(fn ($i) => $i->order_id && in_array((int) $i->invoice_type, [1, 2], true)) + ->groupBy(fn ($i) => $i->order_id . '|' . $i->invoice_type . '|' . ($i->billing_period_start ?? '')) + ->filter(fn (Collection $group) => $group->count() > 1); + foreach ($samePeriod as $group) { + self::link($found, $group->pluck('id', 'invoice_number')->flip()->all(), self::REASON_SAME_PERIOD); + } + + $candidates = $invoices->groupBy(fn ($i) => $i->customer_id . '|' . $i->invoice_type . '|' . $i->total) + ->filter(fn (Collection $group) => $group->count() > 1) + ->flatten(1); + + $itemsByInvoice = DB::table('invoice_items') + ->whereIn('invoice_id', $candidates->pluck('id')) + ->get(['invoice_id', 'product_id', 'description', 'total_price']) + ->groupBy('invoice_id'); + + $fingerprinted = $candidates->map(fn ($i) => (object) [ + 'id' => $i->id, + 'ref' => $i->invoice_number, + 'at' => Carbon::parse($i->created_at), + 'group' => $i->customer_id . '|' . $i->invoice_type . '|' . $i->total . '|' . ($i->billing_period_start ?? '') . '|' . ($itemsByInvoice[$i->id] ?? collect()) + ->map(fn ($item) => $item->product_id . ':' . $item->total_price . ':' . self::normalize($item->description))->sort()->implode('|'), + ]); + + $resubmits = self::resubmits($fingerprinted->groupBy('group')); + + return $found + array_diff_key($resubmits, $found); + } + + private static function resubmits(Collection $groups): array + { + $found = []; + + foreach ($groups->filter(fn (Collection $group) => $group->count() > 1) as $group) { + $sorted = $group->sortBy('at')->values(); + $cluster = [$sorted->first()]; + + foreach ($sorted->slice(1) as $row) { + if (end($cluster)->at->diffInHours($row->at, true) <= self::RESUBMIT_WINDOW_HOURS) { + $cluster[] = $row; + continue; + } + self::linkCluster($found, $cluster); + $cluster = [$row]; + } + self::linkCluster($found, $cluster); + } + + return $found; + } + + private static function linkCluster(array &$found, array $cluster): void + { + if (count($cluster) > 1) { + self::link($found, collect($cluster)->pluck('id', 'ref')->flip()->all(), self::REASON_RESUBMIT); + } + } + + private static function sharedKeys(Collection $orders, string $field, string $reason, array $already): array + { + $found = []; + $byKey = []; + + foreach ($orders as $order) { + if (isset($already[$order->id])) { + continue; + } + foreach ($order->{$field} as $key) { + $byKey[$key][] = $order; + } + } + + foreach ($byKey as $group) { + $group = collect($group)->unique('id'); + $distinctCustomers = $group->pluck('customer')->unique()->count(); + $qualifies = $reason === self::REASON_DOMAIN_TAKEN ? $distinctCustomers > 1 : $group->count() > 1; + + if ($qualifies) { + self::link($found, $group->pluck('id', 'ref')->flip()->all(), $reason); + } + } + + return $found; + } + + /** @param array $members id => ref */ + private static function link(array &$found, array $members, string $reason): void + { + foreach ($members as $id => $ref) { + $found[$id] = [ + 'reason' => $reason, + 'label' => self::LABELS[$reason], + 'with' => array_values(array_map('strval', array_diff_key($members, [$id => true]))), + ]; + } + } + + private static function normalize(?string $value): string + { + return mb_strtolower(trim((string) $value)); + } +} diff --git a/app/Support/BillingCycleCatalog.php b/app/Support/BillingCycleCatalog.php new file mode 100644 index 0000000..5de227c --- /dev/null +++ b/app/Support/BillingCycleCatalog.php @@ -0,0 +1,122 @@ + + */ + public static function builtInCodes(): array + { + return array_values(array_map( + fn (ProductBillingCycle $cycle) => $cycle->value, + array_filter(ProductBillingCycle::cases(), fn (ProductBillingCycle $cycle) => $cycle->months() > 0) + )); + } + + /** + * @return list + */ + public static function builtInMonths(): array + { + return array_values(array_filter(array_map( + fn (ProductBillingCycle $cycle) => $cycle->months(), + ProductBillingCycle::cases() + ))); + } + + /** + * @return list + */ + public static function customMonths(): array + { + $saved = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true); + + return self::normalizeMonths(is_array($saved) ? $saved : []); + } + + /** + * @param array $months + * @return list + */ + public static function saveCustomMonths(array $months): array + { + $normalized = self::normalizeMonths($months); + Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($normalized)]); + + return $normalized; + } + + /** + * @return list + */ + public static function codes(): array + { + $codes = [...self::builtInCodes(), ...array_map(self::codeFor(...), self::customMonths())]; + usort($codes, fn (string $a, string $b) => self::monthsFor($a) <=> self::monthsFor($b)); + + return $codes; + } + + public static function codeFor(int $months): string + { + return self::CODE_PREFIX . $months; + } + + public static function monthsFor(?string $code): ?int + { + if ($code === null || $code === '') { + return null; + } + + $builtIn = ProductBillingCycle::tryFrom($code); + if ($builtIn) { + return $builtIn->months(); + } + + return preg_match('/^' . self::CODE_PREFIX . '(\d{1,3})$/', $code, $m) && (int) $m[1] > 0 + ? (int) $m[1] + : null; + } + + public static function labelForMonths(int $months): string + { + return $months > 0 && $months % 12 === 0 ? ($months / 12) . ' năm' : $months . ' tháng'; + } + + public static function labelForCode(?string $code): ?string + { + $months = self::monthsFor($code); + + return $months ? self::labelForMonths($months) : null; + } + + /** + * @param array $months + * @return list + */ + private static function normalizeMonths(array $months): array + { + $valid = array_filter( + array_map('intval', $months), + fn (int $m) => $m >= 1 && $m <= self::MAX_MONTHS && !in_array($m, self::builtInMonths(), true) + ); + + $unique = array_values(array_unique($valid)); + sort($unique); + + return $unique; + } +} diff --git a/app/Support/CustomerBillingProfile.php b/app/Support/CustomerBillingProfile.php new file mode 100644 index 0000000..78ba7a7 --- /dev/null +++ b/app/Support/CustomerBillingProfile.php @@ -0,0 +1,30 @@ + $customer->invoice_company, + 'tax_code' => $customer->tax_code, + 'invoice_address' => $customer->invoice_address, + 'invoice_email' => $customer->invoice_email, + ]; + } + + public static function save(Customer $customer, array $input): array + { + $customer->update(array_map( + fn ($value) => is_string($value) && trim($value) === '' ? null : $value, + array_intersect_key($input, array_flip(self::FIELDS)) + array_fill_keys(self::FIELDS, null), + )); + + return self::payload($customer->fresh()); + } +} diff --git a/app/Support/EmailHealth.php b/app/Support/EmailHealth.php new file mode 100644 index 0000000..52ca2c5 --- /dev/null +++ b/app/Support/EmailHealth.php @@ -0,0 +1,50 @@ +get(['key', 'scenario', 'status']); + $disabled = AutoEmailGate::disabledScenarios(); + $mailEnabled = AutoEmailGate::mailEnabled(); + + $scenarios = array_map(function (string $key) use ($templates, $disabled, $mailEnabled) { + $matches = $templates->filter(fn ($template) => $template->key === $key || $template->scenario === $key); + $hasTemplate = $matches->isNotEmpty(); + $templateActive = $matches->contains(fn ($template) => (int) $template->status === 1); + $autoEnabled = ! in_array($key, $disabled, true); + + return [ + 'key' => $key, + 'label' => EmailScenarioRegistry::SCENARIOS[$key]['label'], + 'has_template' => $hasTemplate, + 'template_active' => $templateActive, + 'auto_enabled' => $autoEnabled, + 'ready' => $mailEnabled && $hasTemplate && $templateActive && $autoEnabled, + ]; + }, self::LIFECYCLE_SCENARIOS); + + $smtpConfigured = MailSettings::isConfigured(); + $smtpDelivering = MailSettings::isDelivering(); + + return [ + 'mail_enabled' => $mailEnabled, + 'smtp_configured' => $smtpConfigured, + 'smtp_delivering' => $smtpDelivering, + 'scenarios' => $scenarios, + 'ready' => $mailEnabled && $smtpConfigured && $smtpDelivering && ! in_array(false, array_column($scenarios, 'ready'), true), + ]; + } +} diff --git a/app/Support/EmailScenarioRegistry.php b/app/Support/EmailScenarioRegistry.php index 5d0034f..0de8981 100644 --- a/app/Support/EmailScenarioRegistry.php +++ b/app/Support/EmailScenarioRegistry.php @@ -93,6 +93,14 @@ final class EmailScenarioRegistry 'label' => 'Nhắc gia hạn dịch vụ', 'category' => self::BUSINESS, 'group' => 'SERVICE', 'required_variables' => ['product_name', 'expires_at'], ], + 'service_renewed' => [ + 'label' => 'Dịch vụ đã được gia hạn', 'category' => self::BUSINESS, 'group' => 'SERVICE', + 'required_variables' => ['product_name', 'expires_at'], + ], + 'auto_renew_failed' => [ + 'label' => 'Tự động gia hạn thất bại (thiếu số dư)', 'category' => self::BUSINESS, 'group' => 'SERVICE', + 'required_variables' => ['product_name', 'invoice_number'], + ], 'vm_creation_failed' => [ 'label' => 'Khởi tạo VPS thất bại', 'category' => self::BUSINESS, 'group' => 'SERVICE', 'required_variables' => ['order_num'], @@ -157,6 +165,10 @@ final class EmailScenarioRegistry 'label' => '[Admin] Khách hàng thanh toán', 'category' => self::INTERNAL, 'group' => 'BILLING', 'required_variables' => ['reference', 'amount_formatted'], ], + 'admin_service_renewed' => [ + 'label' => '[Admin] Dịch vụ được gia hạn', 'category' => self::INTERNAL, 'group' => 'SERVICE', + 'required_variables' => ['product_name', 'expires_at'], + ], 'admin_domain_order_new' => [ 'label' => '[Admin] Đơn đăng ký tên miền mới', 'category' => self::INTERNAL, 'group' => 'ORDER', 'required_variables' => ['domain_name', 'order_num'], diff --git a/app/Support/InvoiceBuyer.php b/app/Support/InvoiceBuyer.php new file mode 100644 index 0000000..69061f8 --- /dev/null +++ b/app/Support/InvoiceBuyer.php @@ -0,0 +1,25 @@ + $customer?->fullname ?? '', + 'company' => $customer?->invoice_company ?: ($customer?->company ?? ''), + 'tax_code' => $customer?->tax_code ?? '', + 'address' => $customer?->invoice_address ?: ($customer?->address ?? ''), + 'email' => $customer?->invoice_email ?: ($customer?->email ?? ''), + ]; + } + + public static function forInvoice(Invoice $invoice): array + { + return $invoice->buyer_snapshot ?: self::fromCustomer($invoice->customer); + } +} diff --git a/app/Support/InvoiceLookup.php b/app/Support/InvoiceLookup.php new file mode 100644 index 0000000..482b7f5 --- /dev/null +++ b/app/Support/InvoiceLookup.php @@ -0,0 +1,72 @@ +where('invoice_number', $invoiceNumber) + ->where('status', '!=', InvoiceStatus::CANCELLED->value) + ->first(); + + if (!$invoice || !hash_equals((string) $invoice->lookup_code, strtoupper(trim($lookupCode)))) { + return null; + } + + return $invoice; + } + + public static function emailRow(Invoice $invoice): string + { + return $invoice->lookup_code ? EmailTemplateShell::row('Mã tra cứu', '' . e($invoice->lookup_code) . '') : ''; + } + + public static function emailNote(Invoice $invoice): string + { + if (!$invoice->lookup_code) { + return ''; + } + + $url = MailGlobals::frontendUrl() . '/tra-cuu-hoa-don'; + + return EmailTemplateShell::paragraph( + 'Quý khách có thể tra cứu và tải hoá đơn tại ' . e($url) . ' bằng số hoá đơn ' + . e($invoice->invoice_number) . ' và mã tra cứu ' . e($invoice->lookup_code) . '.' + ); + } + + public static function payload(Invoice $invoice): array + { + $invoice->loadMissing(['invoiceItems', 'customer']); + $buyer = InvoiceBuyer::forInvoice($invoice); + + return [ + 'invoice_number' => $invoice->invoice_number, + 'vat_serial' => $invoice->vat_serial, + 'vat_number' => $invoice->vat_number, + 'vat_issued_at' => $invoice->vat_issued_at?->toDateString(), + 'date' => $invoice->date?->toDateString(), + 'status' => $invoice->status, + 'status_text' => $invoice->status_label, + 'sub_total' => (int) $invoice->sub_total, + 'discount_amount' => (int) $invoice->discount_amount, + 'tax' => (int) $invoice->tax, + 'total' => (int) $invoice->total, + 'buyer' => [ + 'company' => $buyer['company'], + 'tax_code' => $buyer['tax_code'], + ], + 'items' => $invoice->invoiceItems->map(fn ($item) => [ + 'description' => $item->description, + 'quantity' => (float) ($item->quantity ?? 1), + 'unit_price' => (int) $item->unit_price, + 'total_price' => (int) $item->total_price, + ])->values(), + ]; + } +} diff --git a/app/Support/MailSettings.php b/app/Support/MailSettings.php index fa8cf6f..74c5f31 100644 --- a/app/Support/MailSettings.php +++ b/app/Support/MailSettings.php @@ -44,6 +44,16 @@ final class MailSettings Config::set('mail', $config); } + public static function isConfigured(): bool + { + return filled(self::resolve()['mailer']['host'] ?? null); + } + + public static function isDelivering(): bool + { + return ! in_array(config('mail.default'), self::NON_DELIVERING_MAILERS, true); + } + public static function transport(): ?string { return self::resolve()['transport'] ?? null; diff --git a/app/Support/ProductBillingCycles.php b/app/Support/ProductBillingCycles.php new file mode 100644 index 0000000..be52d24 --- /dev/null +++ b/app/Support/ProductBillingCycles.php @@ -0,0 +1,110 @@ +> + */ + public static function all(): array + { + $saved = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true); + $saved = is_array($saved) ? $saved : []; + + $result = []; + foreach (self::types() as $type) { + $result[$type] = self::normalize($saved[$type] ?? self::defaults($type)); + } + + return $result; + } + + /** + * @return list + */ + public static function forType(?string $type): array + { + return self::all()[$type] ?? BillingCycleCatalog::codes(); + } + + public static function allows(?string $type, string $cycle): bool + { + return $cycle === 'onetime' || in_array($cycle, self::forType($type), true); + } + + /** + * @return array + */ + public static function rules(): array + { + return [ + 'cycles' => 'required|array', + 'cycles.*' => 'array', + 'cycles.*.*' => ['string', Rule::in(BillingCycleCatalog::codes())], + ]; + } + + /** + * @param array> $cycles + * @return array> + */ + public static function save(array $cycles): array + { + $merged = array_merge(self::all(), array_intersect_key($cycles, array_flip(self::types()))); + $normalized = array_map(self::normalize(...), $merged); + + Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($normalized)]); + + return $normalized; + } + + /** + * @return list + */ + public static function typeOptions(): array + { + return array_map( + fn (string $type) => ['type' => $type, 'label' => ProductTypeEnums::from($type)->label()], + self::types() + ); + } + + /** + * @return list + */ + private static function types(): array + { + return array_values(array_filter( + array_column(ProductTypeEnums::cases(), 'value'), + fn (string $type) => $type !== ProductTypeEnums::ADDON->value + )); + } + + /** + * @return list + */ + private static function defaults(string $type): array + { + return in_array($type, self::YEARLY_TYPES, true) ? ['annually'] : BillingCycleCatalog::codes(); + } + + /** + * @param array $cycles + * @return list + */ + private static function normalize(array $cycles): array + { + return array_values(array_intersect(BillingCycleCatalog::codes(), $cycles)); + } +} diff --git a/app/Support/RenewalPeriodGuard.php b/app/Support/RenewalPeriodGuard.php new file mode 100644 index 0000000..4ca4ffe --- /dev/null +++ b/app/Support/RenewalPeriodGuard.php @@ -0,0 +1,52 @@ +expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now(); + $end = $start->copy()->addMonths($months); + + if ($end->lte(now())) { + return 'Không thể gia hạn cho khoảng thời gian trong quá khứ.'; + } + + $overlapping = ActivityLog::query() + ->where('subject_type', Service::class) + ->where('subject_id', $service->id) + ->where('event', 'renewed') + ->get(['old_values', 'new_values']) + ->first(fn (ActivityLog $log) => self::overlaps($log, $start, $end)); + + if (! $overlapping) { + return null; + } + + return sprintf( + 'Khoảng thời gian %s – %s đã được gia hạn trước đó, không thể gia hạn trùng.', + $start->format('d/m/Y'), + $end->format('d/m/Y'), + ); + } + + private static function overlaps(ActivityLog $log, Carbon $start, Carbon $end): bool + { + $loggedEnd = $log->new_values['expires_at'] ?? null; + if (! $loggedEnd) { + return false; + } + + $loggedEnd = Carbon::parse($loggedEnd); + $loggedStart = ($log->old_values['expires_at'] ?? null) + ? Carbon::parse($log->old_values['expires_at']) + : $loggedEnd->copy()->subMonths((int) ($log->new_values['months'] ?? 1)); + + return $loggedStart->lt($end) && $loggedEnd->gt($start); + } +} diff --git a/app/Support/RenewalPricing.php b/app/Support/RenewalPricing.php index 23cbb17..228048c 100644 --- a/app/Support/RenewalPricing.php +++ b/app/Support/RenewalPricing.php @@ -4,33 +4,53 @@ declare(strict_types=1); namespace App\Support; -use App\Helpers\OrderHelper; use App\Models\Addon; +use App\Models\Product; use App\Models\Service; class RenewalPricing { - /** @return list */ - public static function validMonths(string $billingCycle): array + /** @return list */ + public static function options(?Product $product): array { - $cycleMonths = OrderHelper::cycleToMonths($billingCycle); - - if ($cycleMonths > 24) { - return [$cycleMonths]; + if (! $product) { + return []; } - $maxMultiple = intdiv(24, $cycleMonths); + $options = []; + foreach ($product->pricings()->where('is_active', 1)->get() as $pricing) { + $cycle = (string) $pricing->billing_cycle; + $months = BillingCycleCatalog::monthsFor($cycle); - return array_map(static fn (int $n): int => $n * $cycleMonths, range(1, $maxMultiple)); + if (! $months || (int) $pricing->price <= 0 || ! ProductBillingCycles::allows($product->product_type, $cycle)) { + continue; + } + + $options[$months] = ['billing_cycle' => $cycle, 'months' => $months, 'price' => (int) $pricing->price]; + } + ksort($options); + + return array_values($options); } - public static function addonTotal(Service $service, int $cycles): int + public static function option(?Product $product, int $months): ?array + { + foreach (self::options($product) as $option) { + if ($option['months'] === $months) { + return $option; + } + } + + return null; + } + + public static function addonTotal(Service $service, float $cycles): int { return (int) array_sum(array_column(self::addonInvoiceItems($service, $cycles, 0), 'total_price')); } /** @return list> */ - public static function addonInvoiceItems(Service $service, int $cycles, int $months): array + public static function addonInvoiceItems(Service $service, float $cycles, int $months): array { $meta = array_filter( (array) ($service->orderItem?->addons_meta ?? []), @@ -47,7 +67,7 @@ class RenewalPricing foreach ($meta as $entry) { $qty = (int) $entry['qty']; $unitPrice = (int) ($currentPrices[$entry['addon_id'] ?? null] ?? $entry['price'] ?? 0); - $totalPrice = $unitPrice * $qty * $cycles; + $totalPrice = (int) round($unitPrice * $qty * $cycles); if ($totalPrice <= 0) { continue; @@ -59,7 +79,7 @@ class RenewalPricing 'description' => 'Addon: ' . ($entry['name'] ?? strtoupper((string) ($entry['code'] ?? ''))) . ' x' . $qty, 'months' => $months, 'quantity' => $qty, - 'unit_price' => $unitPrice * $cycles, + 'unit_price' => (int) round($unitPrice * $cycles), 'total_price' => $totalPrice, ]; } diff --git a/app/Support/ServiceExpirySync.php b/app/Support/ServiceExpirySync.php new file mode 100644 index 0000000..9e04d05 --- /dev/null +++ b/app/Support/ServiceExpirySync.php @@ -0,0 +1,23 @@ +vm_id || ! $service->expires_at) { + return 0; + } + + $timestamp = $service->expires_at->timestamp; + + return ModuleVmware::query() + ->whereKey($service->vm_id) + ->where(fn ($query) => $query->whereNull('next_due_date')->orWhere('next_due_date', '!=', $timestamp)) + ->update(['next_due_date' => $timestamp]); + } +} diff --git a/app/Support/VatCalculator.php b/app/Support/VatCalculator.php new file mode 100644 index 0000000..bbbcf89 --- /dev/null +++ b/app/Support/VatCalculator.php @@ -0,0 +1,44 @@ +vat_number) { + return $invoice; + } + + $tax = self::taxFor($invoice); + + if ((int) $invoice->tax !== $tax) { + $invoice->forceFill(['tax' => $tax])->saveQuietly(); + } + + return $invoice; + } + + public static function taxFor(Invoice $invoice): int + { + $items = $invoice->invoiceItems()->with('product:id,product_type')->get(); + $gross = (int) $items->sum('total_price'); + + if ($gross <= 0) { + return 0; + } + + $payable = min((int) $invoice->total, $gross); + $tax = 0.0; + + foreach ($items as $item) { + $rate = VatSettings::rateFor($item->product?->product_type); + $share = $payable * ((int) $item->total_price / $gross); + $tax += $share * $rate / (100 + $rate); + } + + return (int) round($tax); + } +} diff --git a/app/Support/VatNumbering.php b/app/Support/VatNumbering.php new file mode 100644 index 0000000..f5645d9 --- /dev/null +++ b/app/Support/VatNumbering.php @@ -0,0 +1,68 @@ +vat_number + && (int) $invoice->status === InvoiceStatus::PAID->value + && (int) $invoice->total > 0 + && VatSettings::numbering()['auto_issue']) { + return self::issue($invoice); + } + + return $invoice; + } + + public static function issue(Invoice $invoice): Invoice + { + return DB::transaction(function () use ($invoice) { + $locked = Invoice::query()->with('customer')->lockForUpdate()->findOrFail($invoice->id); + + if ($locked->vat_number) { + return $locked; + } + + Setting::query()->firstOrCreate(['key' => VatSettings::NUMBERING_KEY], ['value' => json_encode(VatSettings::numbering())]); + Setting::query()->where('key', VatSettings::NUMBERING_KEY)->lockForUpdate()->first(); + + $config = VatSettings::numbering(); + VatCalculator::refresh($locked); + + $next = $config['next_number']; + while (Invoice::query()->where('vat_serial', $config['serial'])->where('vat_number', self::format($next, $config['padding']))->exists()) { + $next++; + } + + $locked->fill([ + 'vat_serial' => $config['serial'], + 'vat_number' => self::format($next, $config['padding']), + 'vat_issued_at' => now()->toDateString(), + 'buyer_snapshot' => InvoiceBuyer::fromCustomer($locked->customer), + ])->save(); + + VatSettings::saveNumbering([...$config, 'next_number' => $next + 1]); + + return $locked; + }); + } + + private static function format(int $number, int $padding): string + { + return str_pad((string) $number, $padding, '0', STR_PAD_LEFT); + } +} diff --git a/app/Support/VatSettings.php b/app/Support/VatSettings.php new file mode 100644 index 0000000..c02cb47 --- /dev/null +++ b/app/Support/VatSettings.php @@ -0,0 +1,63 @@ + (string) ($saved['serial'] ?? '1C26TCP'), + 'next_number' => max(1, (int) ($saved['next_number'] ?? 1)), + 'padding' => min(12, max(1, (int) ($saved['padding'] ?? 8))), + 'auto_issue' => (bool) ($saved['auto_issue'] ?? false), + ]; + } + + public static function saveNumbering(array $config): void + { + Setting::updateOrCreate( + ['key' => self::NUMBERING_KEY], + ['value' => json_encode($config, JSON_UNESCAPED_UNICODE)] + ); + } + + public static function forget(): void + { + self::$rates = null; + } + + private static function rates(): array + { + return self::$rates ??= collect(self::read(self::RATES_KEY)) + ->filter(fn ($row) => is_array($row) && isset($row['type'])) + ->mapWithKeys(fn ($row) => [$row['type'] => (float) ($row['vat_rate'] ?? 0)]) + ->all(); + } + + private static function read(string $key): array + { + $value = Setting::where('key', $key)->value('value'); + $decoded = is_string($value) ? json_decode($value, true) : null; + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/database/migrations/2026_10_19_000001_backfill_service_renewal_logs.php b/database/migrations/2026_10_19_000001_backfill_service_renewal_logs.php new file mode 100644 index 0000000..414e2ef --- /dev/null +++ b/database/migrations/2026_10_19_000001_backfill_service_renewal_logs.php @@ -0,0 +1,71 @@ +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 + { + } +}; diff --git a/database/migrations/2026_10_19_000001_seed_service_renewed_templates.php b/database/migrations/2026_10_19_000001_seed_service_renewed_templates.php new file mode 100644 index 0000000..0df8876 --- /dev/null +++ b/database/migrations/2026_10_19_000001_seed_service_renewed_templates.php @@ -0,0 +1,23 @@ +seedScenarioIfMissing($scenario); + } + } + + public function down(): void + { + DB::table('template')->whereIn('scenario', self::SCENARIOS)->delete(); + } +}; diff --git a/database/migrations/2026_10_19_100001_add_vat_invoice_columns.php b/database/migrations/2026_10_19_100001_add_vat_invoice_columns.php new file mode 100644 index 0000000..df44d6f --- /dev/null +++ b/database/migrations/2026_10_19_100001_add_vat_invoice_columns.php @@ -0,0 +1,47 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_10_19_100001_create_coupons_tables.php b/database/migrations/2026_10_19_100001_create_coupons_tables.php new file mode 100644 index 0000000..8d29df1 --- /dev/null +++ b/database/migrations/2026_10_19_100001_create_coupons_tables.php @@ -0,0 +1,65 @@ +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'); + } +}; diff --git a/database/migrations/2026_10_19_200001_seed_auto_renew_failed_template.php b/database/migrations/2026_10_19_200001_seed_auto_renew_failed_template.php new file mode 100644 index 0000000..570b85e --- /dev/null +++ b/database/migrations/2026_10_19_200001_seed_auto_renew_failed_template.php @@ -0,0 +1,17 @@ +seedScenarioIfMissing('auto_renew_failed'); + } + + public function down(): void + { + DB::table('template')->where('scenario', 'auto_renew_failed')->delete(); + } +}; diff --git a/database/migrations/2026_10_19_300001_reseed_invoice_emails_with_lookup_code.php b/database/migrations/2026_10_19_300001_reseed_invoice_emails_with_lookup_code.php new file mode 100644 index 0000000..8314bea --- /dev/null +++ b/database/migrations/2026_10_19_300001_reseed_invoice_emails_with_lookup_code.php @@ -0,0 +1,16 @@ +reseedScenario('invoice_notification'); + $seeder->reseedScenario('invoice_payment_reminder'); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_19_400001_reseed_hosting_account_created_subject.php b/database/migrations/2026_10_19_400001_reseed_hosting_account_created_subject.php new file mode 100644 index 0000000..732dfb1 --- /dev/null +++ b/database/migrations/2026_10_19_400001_reseed_hosting_account_created_subject.php @@ -0,0 +1,14 @@ +reseedScenario('hosting_account_created'); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_19_500001_backfill_orders_date.php b/database/migrations/2026_10_19_500001_backfill_orders_date.php new file mode 100644 index 0000000..f67e752 --- /dev/null +++ b/database/migrations/2026_10_19_500001_backfill_orders_date.php @@ -0,0 +1,14 @@ +whereNull('date')->whereNotNull('created_at')->update(['date' => DB::raw('DATE(created_at)')]); + } + + public function down(): void {} +}; diff --git a/database/seeders/EmailTemplates/AdminTemplates.php b/database/seeders/EmailTemplates/AdminTemplates.php index 9d324f6..6b00d20 100644 --- a/database/seeders/EmailTemplates/AdminTemplates.php +++ b/database/seeders/EmailTemplates/AdminTemplates.php @@ -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 {{admin_name}} 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', diff --git a/database/seeders/EmailTemplates/BillingTemplates.php b/database/seeders/EmailTemplates/BillingTemplates.php index bcee4d6..62febb8 100644 --- a/database/seeders/EmailTemplates/BillingTemplates.php +++ b/database/seeders/EmailTemplates/BillingTemplates.php @@ -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', '{{outstanding}} VNĐ')) + ], '{{paid_row}}' . T::row('Còn phải thanh toán', '{{outstanding}} VNĐ') . '{{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(), diff --git a/database/seeders/EmailTemplates/ServiceTemplates.php b/database/seeders/EmailTemplates/ServiceTemplates.php index fc60385..c868695 100644 --- a/database/seeders/EmailTemplates/ServiceTemplates.php +++ b/database/seeders/EmailTemplates/ServiceTemplates.php @@ -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 {{domain_name}} tại {{app_name}} đã đượ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ụ {{product_name}}{{server_name_suffix}} của Quý khách vừa được gia hạn thêm {{months_label}}.') + . T::infoTable([ + 'Dịch vụ' => '{{product_name}}', + 'Hạn sử dụng trước' => '{{old_expires_at}}', + 'Hạn sử dụng mới' => '{{expires_at}}', + '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ụ {{product_name}}{{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' => '{{expires_at}}', + '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', diff --git a/database/seeders/EmailTemplates/TemplateVariables.php b/database/seeders/EmailTemplates/TemplateVariables.php index 0db7f4c..7c1d883 100644 --- a/database/seeders/EmailTemplates/TemplateVariables.php +++ b/database/seeders/EmailTemplates/TemplateVariables.php @@ -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', ''], diff --git a/resources/views/pdf/invoice.blade.php b/resources/views/pdf/invoice.blade.php index 38edbbb..e1e3340 100644 --- a/resources/views/pdf/invoice.blade.php +++ b/resources/views/pdf/invoice.blade.php @@ -12,7 +12,7 @@ 'footer_text' => '', 'footer_note' => '', ], $cfg ?? []); - $invoiceDate = $invoice->created_at ?? now(); + $invoiceDate = $invoice->vat_issued_at ?? $invoice->created_at ?? now(); $taxBase = ($invoice->total ?? 0) - ($invoice->tax ?? 0); $taxRate = ($taxBase > 0 && ($invoice->tax ?? 0) > 0) ? round($invoice->tax / $taxBase * 100) : 0; @endphp diff --git a/resources/views/pdf/invoice/buyer.blade.php b/resources/views/pdf/invoice/buyer.blade.php index 3a99b4f..7ecc103 100644 --- a/resources/views/pdf/invoice/buyer.blade.php +++ b/resources/views/pdf/invoice/buyer.blade.php @@ -1,21 +1,21 @@
Họ tên người mua hàng (Buyer): - {{ $customer?->fullname ?? '' }} + {{ $buyer['name'] ?? '' }}
Tên đơn vị (Company's name): - {{ $customer?->company ?? '' }} + {{ $buyer['company'] ?? '' }}
- Mã số thuế (Tax code): {{ $customer?->identity_number ?? '' }} + Mã số thuế (Tax code): {{ $buyer['tax_code'] ?? '' }}
Địa chỉ (Address): - {{ $customer?->address ?? '' }} + {{ $buyer['address'] ?? '' }}
diff --git a/resources/views/pdf/invoice/footer.blade.php b/resources/views/pdf/invoice/footer.blade.php index 4b849f7..8a683d4 100644 --- a/resources/views/pdf/invoice/footer.blade.php +++ b/resources/views/pdf/invoice/footer.blade.php @@ -1,11 +1,11 @@