feat(billing): renewals, coupons, VAT numbering, billing cycles and invoice payment tracking
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ea9a53f742
commit
25f23d541e
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Coupon;
|
||||
|
||||
use App\Enums\Product\ProductTypeEnums;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\Admin\CouponRequest;
|
||||
use App\Models\Coupon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CouponController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$search = trim((string) $request->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á.']);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Customer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\BillingProfileRequest;
|
||||
use App\Models\Customer;
|
||||
use App\Support\CustomerBillingProfile;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CustomerBillingProfileController extends Controller
|
||||
{
|
||||
public function show(string $uuid): JsonResponse
|
||||
{
|
||||
return response()->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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\EmailTemplate;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\EmailHealth;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class EmailHealthController extends Controller
|
||||
{
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
return response()->json(['error' => false, 'data' => EmailHealth::report()]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Invoice;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Models\Invoice;
|
||||
use App\Support\InvoiceBuyer;
|
||||
use App\Support\VatNumbering;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class InvoiceVatController extends Controller
|
||||
{
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return response()->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Setting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Invoice;
|
||||
use App\Support\VatSettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VatNumberingController extends Controller
|
||||
{
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
return response()->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()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\BillingProfileRequest;
|
||||
use App\Support\CustomerBillingProfile;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class BillingProfileController extends Controller
|
||||
{
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
return response()->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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Client;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\Client\InvoiceLookupRequest;
|
||||
use App\Services\InvoicePdfService;
|
||||
use App\Support\InvoiceLookup;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class InvoiceLookupController extends Controller
|
||||
{
|
||||
private const NOT_FOUND = 'Không tìm thấy hóa đơn. Vui lòng kiểm tra lại số hóa đơn và mã tra cứu.';
|
||||
|
||||
public function show(InvoiceLookupRequest $request): 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()->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) . '"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Service;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\Order\CouponPreviewRequest;
|
||||
use App\Models\Product;
|
||||
use App\Services\Api\Order\OrderService;
|
||||
use App\Services\Coupon\CouponService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CouponPreviewController extends Controller
|
||||
{
|
||||
public function __invoke(CouponPreviewRequest $request, OrderService $orders, CouponService $coupons): JsonResponse
|
||||
{
|
||||
$data = $request->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'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user