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
+52 -19
View File
@@ -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
// =========================================================================