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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-19 16:37:56 +07:00
co-authored by Claude Sonnet 5.5
parent ea9a53f742
commit 25f23d541e
134 changed files with 4926 additions and 185 deletions
+67 -14
View File
@@ -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;