invoiceRepository->filter( $request->all(), [ 'payment', 'customer', 'order', ...OrderItemServiceResource::relationsFor('order.orderItems'), ], [], ['*'], true ); return response()->json([ 'error' => false, 'data' => [ 'items' => InvoiceResource::collection($query->get())->resolve(), 'total' => $query->count(), ], ]); } public function export(BillingExportRequest $request, BrandedReportWriter $writer): BinaryFileResponse { $query = $this->invoiceRepository->filter( array_filter($request->validated(), fn ($value) => $value !== null && $value !== []), ['payment', 'customer', 'order', ...OrderItemServiceResource::relationsFor('order.orderItems')], [], ['*'], true ); return $writer->download(new InvoicesExport($query), 'hoa-don-' . now()->format('Y-m-d-His') . '.xlsx'); } /** * Admin tạo thêm 1 invoice thủ công cho 1 order đã tồn tại — chỉ dùng cho phụ * phí/điều chỉnh thuộc CHÍNH dịch vụ trong order đó (vd. phạt trễ hạn, phí xử lý). * Order cố định: không dùng endpoint này để bán thêm dịch vụ mới — dịch vụ mới phải * là 1 Order riêng (qua OrderController::store). Đi qua cùng convention với renewal * invoice ở nơi khác trong codebase: gắn order_id vào order gốc, tự sinh invoice_number. */ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'order_id' => 'required|integer|exists:orders,id', 'invoice_type' => 'nullable|integer|in:1,2,3', 'description' => 'nullable|string|max:500', 'due_date' => 'nullable|date', 'sub_total' => 'required|numeric|min:0', 'tax' => 'nullable|numeric|min:0', ]); $order = Order::find($validated['order_id']); $subTotal = (int) $validated['sub_total']; $tax = (int) ($validated['tax'] ?? 0); $total = $subTotal + $tax; try { $invoice = DB::transaction(function () use ($order, $validated, $subTotal, $tax, $total) { $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), 'customer_id' => $order->customer_id, 'order_id' => $order->id, 'invoice_type' => $validated['invoice_type'] ?? 3, 'sub_total' => $subTotal, 'discount_amount' => 0, 'tax' => $tax, 'total' => $total, 'status' => InvoiceStatus::UNPAID->value, 'due_date' => $validated['due_date'] ?? now()->addDays(7)->toDateString(), ]); InvoiceItem::create([ 'invoice_id' => $invoice->id, 'type' => 'service', 'description' => $validated['description'] ?: 'Hóa đơn bổ sung', 'months' => 0, 'quantity' => 1, 'unit_price' => $subTotal, 'total_price' => $subTotal, ]); return $invoice; }); return response()->json([ 'error' => false, 'message' => 'Đã tạo hóa đơn', 'data' => new InvoiceResource($invoice->load('invoiceItems')), ], 201); } catch (\Exception $e) { return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } public function show(int $id): JsonResponse { $invoice = $this->invoiceRepository->findWithRelationships($id, ['payment', 'invoiceItem', 'customer']); if (!$invoice) { return response()->json([ 'error' => true, 'message' => "Can't find invoice by ID #" . $id ], 404); } return response()->json([ 'error' => false, 'data' => new InvoiceResource($invoice) ]); } public function update(InvoiceRequest $request, int $id): JsonResponse { $invoice = $this->invoiceRepository->find($id); if (!$invoice) { return response()->json([ 'error' => true, 'message' => "Can't find invoice by ID #" . $id ], 404); } $request->validated(); $action = strtolower((string) $request->get('action')); $status = match ($action) { 'paid' => InvoiceStatus::PAID->value, 'cancel' => InvoiceStatus::CANCELLED->value, default => null, }; if ($status === null) { return response()->json([ 'error' => true, 'message' => 'Invalid invoice action.', ], 422); } try { DB::beginTransaction(); $invoice->load(['order', 'customer']); if ($status === InvoiceStatus::PAID->value) { $this->invoicePaymentService->markPaidByAdmin($invoice, auth()->id()); } else { $this->assertCancellable($invoice); $invoice->update([ 'status' => $status, 'paid_at' => null, ]); } DB::commit(); return response()->json([ 'error' => false, 'message' => 'Invoice updated successfully' ]); } catch (\InvalidArgumentException $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 422); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } public function destroy(int $id): JsonResponse { $invoice = $this->invoiceRepository->find($id); if (!$invoice) { return response()->json([ 'error' => true, 'message' => "Can't find invoice by ID #" . $id ], 404); } // Hoá đơn đã thu tiền là chứng từ gốc: xoá đi thì Transaction và số dư đã trừ vẫn còn, // sổ sách lệch mà không còn dấu vết. Muốn đảo thì phải đi qua luồng hoàn tiền. if (in_array((int) $invoice->status, [InvoiceStatus::PAID->value, InvoiceStatus::REFUNDED->value], true)) { return response()->json([ 'error' => true, 'message' => 'Không thể xoá hoá đơn đã thanh toán hoặc đã hoàn tiền. Hãy dùng chức năng hoàn tiền nếu cần đảo giao dịch.', ], 422); } try { // Note: `orders` has no `invoice_id` column (the FK runs the other way — // invoices.order_id -> orders.id), so there's nothing to clean up there. Any // `services.invoice_id` pointing at this invoice is already handled by the // FK's onDelete('set null'). $this->invoiceRepository->delete($id); return response()->json([ 'error' => false, 'message' => 'Invoice deleted successfully' ]); } catch (\Exception $e) { return response()->json([ 'error' => true, 'message' => $e->getMessage() ], 500); } } // ─── Attachments ──────────────────────────────────────────────────────────── // ─── Send Mail ─────────────────────────────────────────────────────────────── }