validate([ 'order_type' => ['required', 'in:dine_in,takeaway,counter'], 'table_id' => ['nullable', 'integer'], 'covers' => ['nullable', 'integer', 'min:1', 'max:99'], ]); $merchant = ladill_account() ?? $request->user(); $location = $this->locations->ensureDefault($this->ownerRef($request)); try { $sale = $this->sales->openTicket($merchant, [ 'order_type' => $data['order_type'], 'table_id' => $data['table_id'] ?? null, 'covers' => $data['covers'] ?? null, 'location_id' => $location->id, 'currency' => $location->currency, ]); } catch (RuntimeException $e) { return back()->with('error', $e->getMessage()); } return redirect()->route('pos.tickets.show', $sale); } public function show(Request $request, PosSale $sale): View { $this->authorizeOwner($request, $sale); if (! $sale->isOpen()) { return redirect()->route('pos.sales.show', $sale); } $owner = $this->ownerRef($request); $sale->load('lines', 'table'); $products = PosProduct::owned($owner)->active() ->with(['category', 'modifierGroups.modifiers']) ->orderBy('name')->get(); return view('pos.tickets.show', [ 'sale' => $sale, 'paidMinor' => $sale->paidMinor(), 'payments' => $sale->payments()->where('status', 'paid')->latest()->get(), 'products' => $products->map(fn (PosProduct $p) => [ 'id' => $p->id, 'name' => $p->name, 'price_minor' => $p->price_minor, 'category' => $p->category?->name, 'modifier_groups' => $p->modifierGroups->map(fn ($g) => [ 'id' => $g->id, 'name' => $g->name, 'min' => $g->min_select, 'max' => $g->max_select, 'modifiers' => $g->modifiers->map(fn ($m) => [ 'id' => $m->id, 'name' => $m->name, 'price_delta_minor' => $m->price_delta_minor, ])->values()->all(), ])->values()->all(), ])->values()->all(), 'categories' => $products->pluck('category.name')->filter()->unique()->values()->all(), 'courses' => PosSaleLine::COURSES, 'lines' => $this->lineData($sale), ]); } public function addLine(Request $request, PosSale $sale): JsonResponse { $this->authorizeOpen($request, $sale); $data = $request->validate([ 'product_id' => ['nullable', 'integer'], 'name' => ['nullable', 'string', 'max:200'], 'unit_price_minor' => ['nullable', 'integer', 'min:1'], 'quantity' => ['nullable', 'integer', 'min:1', 'max:999'], 'notes' => ['nullable', 'string', 'max:200'], 'course' => ['nullable', 'in:'.implode(',', array_keys(PosSaleLine::COURSES))], 'modifier_ids' => ['nullable', 'array'], 'modifier_ids.*' => ['integer'], ]); $payload = [ 'quantity' => $data['quantity'] ?? 1, 'notes' => $data['notes'] ?? null, 'course' => $data['course'] ?? null, ]; if (! empty($data['product_id'])) { $resolved = $this->sales->buildProductLine( $this->ownerRef($request), (int) $data['product_id'], $data['modifier_ids'] ?? [], $payload['quantity'], $payload['notes'], $payload['course'], ); if ($resolved === null) { return response()->json(['message' => 'Unknown product.'], 422); } $payload = $resolved; } else { // Custom (open-price) line. $payload['name'] = (string) ($data['name'] ?? ''); $payload['unit_price_minor'] = (int) ($data['unit_price_minor'] ?? 0); } try { $this->sales->addLine($sale, $payload); } catch (RuntimeException $e) { return response()->json(['message' => $e->getMessage()], 422); } return $this->ticketJson($sale); } public function updateLine(Request $request, PosSale $sale, PosSaleLine $line): JsonResponse { $this->authorizeOpen($request, $sale); abort_unless($line->pos_sale_id === $sale->id, 404); $data = $request->validate([ 'quantity' => ['required', 'integer', 'min:0', 'max:999'], 'notes' => ['nullable', 'string', 'max:200'], ]); $this->sales->updateLine($line, $data['quantity'], $data['notes'] ?? null); return $this->ticketJson($sale); } public function removeLine(Request $request, PosSale $sale, PosSaleLine $line): JsonResponse { $this->authorizeOpen($request, $sale); abort_unless($line->pos_sale_id === $sale->id, 404); $this->sales->removeLine($line); return $this->ticketJson($sale); } public function sendToKitchen(Request $request, PosSale $sale): JsonResponse { $this->authorizeOpen($request, $sale); $data = $request->validate([ 'course' => ['nullable', 'in:'.implode(',', array_keys(PosSaleLine::COURSES))], ]); $fired = $this->sales->sendToKitchen($sale, $data['course'] ?? null); $sale->refresh(); return response()->json([ 'lines' => $this->lineData($sale), 'subtotal_minor' => $sale->subtotal_minor, 'total_minor' => $sale->total_minor, 'kitchen_status' => $sale->kitchen_status, 'fired' => $fired, ]); } public function settle(Request $request, PosSale $sale): RedirectResponse { $this->authorizeOpen($request, $sale); $data = $request->validate([ 'payment_method' => ['required', 'in:pay,cash'], 'amount' => ['nullable', 'numeric', 'min:0.01'], 'customer_name' => ['nullable', 'string', 'max:120'], 'customer_phone' => ['nullable', 'string', 'max:40'], ]); $sale->loadCount('lines'); if ($sale->lines_count === 0) { return back()->with('error', 'Add at least one item before settling.'); } if (! empty($data['customer_name']) || ! empty($data['customer_phone'])) { $sale->forceFill([ 'customer_name' => $data['customer_name'] ?? $sale->customer_name, 'customer_phone' => $data['customer_phone'] ?? $sale->customer_phone, ])->save(); } // Pay the whole balance by default, or a partial amount for a split bill. $balance = $sale->balanceMinor(); $amountMinor = isset($data['amount']) ? (int) round(((float) $data['amount']) * 100) : $balance; $amountMinor = max(1, min($amountMinor, $balance)); $merchant = ladill_account() ?? $request->user(); try { if ($data['payment_method'] === 'cash') { $this->sales->takeCashPayment($sale, $amountMinor); $sale->refresh(); if ($sale->isOpen()) { return redirect()->route('pos.tickets.show', $sale) ->with('success', 'Payment recorded — balance '.$sale->currency.' '.number_format($sale->balanceMinor() / 100, 2)); } return redirect()->route('pos.sales.show', $sale)->with('success', 'Ticket settled.'); } $result = $this->sales->startPayPayment($sale, $merchant, $amountMinor); if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'checkout_url' => $result['checkout_url'], 'sale_id' => $sale->id, ]); } return redirect() ->route('pos.tickets.show', $sale) ->with('checkout_url', $result['checkout_url']) ->with('success', 'Payment sheet ready — hand the device to the customer for card or MoMo.'); } catch (RuntimeException $e) { return back()->with('error', $e->getMessage()); } } public function paymentCallback(Request $request): RedirectResponse|View { $reference = trim((string) $request->query('reference', '')); if ($reference === '') { return view('pos.payment-return', [ 'redirect' => route('pos.floor'), ]); } try { $payment = $this->sales->completePayPayment($reference); } catch (\Throwable) { session()->flash('error', 'Payment could not be verified.'); return view('pos.payment-return', [ 'redirect' => route('pos.floor'), ]); } $sale = $payment->sale->fresh(); if ($sale->isOpen()) { session()->flash('success', 'Payment recorded — balance '.$sale->currency.' '.number_format($sale->balanceMinor() / 100, 2)); $url = route('pos.tickets.show', $sale); } else { session()->flash('success', 'Ticket settled.'); $url = route('pos.sales.show', $sale); } return view('pos.payment-return', [ 'redirect' => $url, ]); } private function authorizeOpen(Request $request, PosSale $sale): void { $this->authorizeOwner($request, $sale); abort_unless($sale->isOpen(), 409, 'This ticket is already settled.'); } private function ticketJson(PosSale $sale): JsonResponse { $sale->refresh(); return response()->json([ 'lines' => $this->lineData($sale), 'subtotal_minor' => $sale->subtotal_minor, 'total_minor' => $sale->total_minor, 'kitchen_status' => $sale->kitchen_status, ]); } /** @return list> */ private function lineData(PosSale $sale): array { return $sale->lines()->with('modifiers')->orderBy('position')->get()->map(fn (PosSaleLine $l) => [ 'id' => $l->id, 'product_id' => $l->product_id, 'name' => $l->name, 'unit_price_minor' => $l->unit_price_minor, 'quantity' => $l->quantity, 'line_total_minor' => $l->line_total_minor, 'kitchen_state' => $l->kitchen_state, 'notes' => $l->notes, 'course' => $l->course, 'source' => $l->source, 'modifiers' => $l->modifiers->map(fn ($m) => $m->name)->all(), 'fired' => $l->kitchen_state !== PosSaleLine::KITCHEN_NEW, ])->all(); } }