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
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin\Product;
use App\Http\Controllers\Controller;
use App\Models\OrderItem;
use App\Models\ProductPricings;
use App\Support\BillingCycleCatalog;
use App\Support\ProductBillingCycles;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProductBillingCycleController extends Controller
{
public function show(): JsonResponse
{
return response()->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<string, list<string>> $cycles
*/
private function payload(array $cycles): array
{
return [
'types' => ProductBillingCycles::typeOptions(),
'cycles' => $cycles,
'custom_months' => BillingCycleCatalog::customMonths(),
];
}
}
@@ -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(