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,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) {