where('location_id', $location->id)->first(); if ($display) { return $display; } return PosCustomerDisplay::create([ 'location_id' => $location->id, 'owner_ref' => $location->owner_ref, 'token' => $this->freshToken(), 'phase' => PosCustomerDisplay::PHASE_IDLE, 'payload' => $this->defaultPayload($location), 'last_pushed_at' => now(), ]); } public function rotateToken(PosCustomerDisplay $display): PosCustomerDisplay { $display->update(['token' => $this->freshToken()]); return $display->fresh(); } /** * @param array $payload */ public function push(PosLocation $location, string $phase, array $payload = []): PosCustomerDisplay { if (! in_array($phase, PosCustomerDisplay::PHASES, true)) { $phase = PosCustomerDisplay::PHASE_IDLE; } $display = $this->ensureForLocation($location); $base = $this->defaultPayload($location); $merged = array_replace_recursive($base, $payload); $merged['phase'] = $phase; $merged['updated_at'] = now()->toIso8601String(); $display->update([ 'phase' => $phase, 'payload' => $merged, 'customer_action' => null, 'last_pushed_at' => now(), ]); return $display->fresh(); } /** * @param array{name: string, quantity: int|float, unit_price_minor: int, discount_minor?: int, note?: string}[] $lines * @param array $extras */ public function pushCart(PosLocation $location, array $lines, array $extras = []): PosCustomerDisplay { $normalized = []; $subtotal = 0; $discount = (int) ($extras['discount_minor'] ?? 0); foreach ($lines as $line) { $qty = max(0, (float) ($line['quantity'] ?? 0)); $unit = (int) ($line['unit_price_minor'] ?? 0); $lineDiscount = (int) ($line['discount_minor'] ?? 0); $lineTotal = (int) round($unit * $qty) - $lineDiscount; $subtotal += $lineTotal; $normalized[] = [ 'name' => (string) ($line['name'] ?? 'Item'), 'quantity' => $qty, 'unit_price_minor' => $unit, 'discount_minor' => $lineDiscount, 'line_total_minor' => max(0, $lineTotal), 'note' => isset($line['note']) ? (string) $line['note'] : null, ]; } $total = max(0, $subtotal - $discount); if ($normalized === []) { return $this->push($location, PosCustomerDisplay::PHASE_IDLE, $extras); } return $this->push($location, PosCustomerDisplay::PHASE_CART, array_merge($extras, [ 'lines' => $normalized, 'subtotal_minor' => $subtotal, 'discount_minor' => $discount, 'total_minor' => $total, 'currency' => $location->currency, ])); } /** * @param array $extras */ public function pushPayment(PosLocation $location, PosSale $sale, array $extras = []): PosCustomerDisplay { return $this->push($location, PosCustomerDisplay::PHASE_PAYMENT, array_merge([ 'lines' => $this->linesFromSale($sale), 'subtotal_minor' => (int) $sale->subtotal_minor, 'total_minor' => (int) $sale->total_minor, 'currency' => $sale->currency, 'payment' => [ 'method' => $sale->payment_method, 'status' => $sale->status, 'message' => $extras['message'] ?? 'Complete payment on the terminal or scan the code', 'qr_url' => $extras['qr_url'] ?? null, 'checkout_url' => $extras['checkout_url'] ?? null, 'options' => $extras['options'] ?? ['Card / MoMo', 'Cash'], ], 'sale_reference' => $sale->reference, ], $extras)); } /** * @param array $extras */ public function pushReceipt(PosLocation $location, PosSale $sale, array $extras = []): PosCustomerDisplay { return $this->push($location, PosCustomerDisplay::PHASE_RECEIPT, array_merge([ 'lines' => $this->linesFromSale($sale), 'subtotal_minor' => (int) $sale->subtotal_minor, 'discount_minor' => (int) $sale->loyalty_discount_minor, 'tip_minor' => (int) $sale->tip_minor, 'total_minor' => (int) $sale->total_minor, 'currency' => $sale->currency, 'receipt' => [ 'reference' => $sale->reference, 'sale_id' => $sale->id, 'total_minor' => (int) $sale->total_minor, 'digital_url' => $extras['digital_url'] ?? null, 'message' => $extras['message'] ?? 'Thank you for your purchase', 'email_prompt' => true, ], 'sale_reference' => $sale->reference, 'sale_id' => $sale->id, 'tip' => [ 'selected_minor' => (int) $sale->tip_minor, 'base_minor' => max(0, (int) $sale->subtotal_minor - (int) $sale->loyalty_discount_minor), ], ], $extras)); } /** * @param array $action */ public function recordCustomerAction(PosCustomerDisplay $display, array $action): PosCustomerDisplay { $display->update([ 'customer_action' => array_merge($action, [ 'received_at' => now()->toIso8601String(), ]), ]); return $display->fresh(); } public function clearCustomerAction(PosCustomerDisplay $display): void { $display->update(['customer_action' => null]); } /** * Public payload for the customer-facing screen (no operator secrets). * * @return array */ public function publicState(PosCustomerDisplay $display): array { $payload = is_array($display->payload) ? $display->payload : []; $location = $display->location; return [ 'phase' => $display->phase, 'updated_at' => optional($display->last_pushed_at)->toIso8601String() ?? ($payload['updated_at'] ?? null), 'location_name' => $location?->name ?? ($payload['location_name'] ?? 'Store'), 'currency' => $payload['currency'] ?? ($location?->currency ?? 'GHS'), 'brand' => [ 'logo_url' => $location?->receiptLogoUrl(), 'header' => $location?->receipt_header, 'footer' => $location?->receipt_footer, ], 'lines' => $payload['lines'] ?? [], 'subtotal_minor' => (int) ($payload['subtotal_minor'] ?? 0), 'discount_minor' => (int) ($payload['discount_minor'] ?? 0), 'tax_minor' => (int) ($payload['tax_minor'] ?? 0), 'tip_minor' => (int) ($payload['tip_minor'] ?? ($payload['tip']['selected_minor'] ?? 0)), 'total_minor' => (int) ($payload['total_minor'] ?? 0), 'loyalty' => $payload['loyalty'] ?? null, 'payment' => $payload['payment'] ?? null, 'tip' => $payload['tip'] ?? null, 'signature' => $payload['signature'] ?? null, 'receipt' => $payload['receipt'] ?? null, 'promo' => $payload['promo'] ?? ($this->defaultPayload($location ?? new PosLocation)['promo'] ?? null), 'customer_name' => $payload['customer_name'] ?? null, 'sale_reference' => $payload['sale_reference'] ?? null, 'sale_id' => $payload['sale_id'] ?? ($payload['receipt']['sale_id'] ?? null), 'message' => $payload['message'] ?? null, ]; } /** * @return array */ public function defaultPayload(?PosLocation $location): array { $name = $location?->name ?? 'Welcome'; $promo = $location ? $location->customerPromo() : ['headline' => $name, 'body' => 'Thank you for shopping with us.', 'image_url' => null]; return [ 'location_name' => $name, 'currency' => $location?->currency ?? 'GHS', 'lines' => [], 'subtotal_minor' => 0, 'discount_minor' => 0, 'tax_minor' => 0, 'tip_minor' => 0, 'total_minor' => 0, 'loyalty' => null, 'payment' => null, 'tip' => [ 'enabled' => (bool) $location?->isRestaurant(), 'options_percent' => [10, 15, 18, 20], 'custom_allowed' => true, 'selected_percent' => null, 'selected_minor' => null, 'base_minor' => 0, ], 'signature' => [ 'required' => false, 'data_url' => null, 'prompt' => 'Please sign below', ], 'receipt' => null, 'promo' => $promo, 'message' => null, ]; } public function displayUrl(PosCustomerDisplay $display): string { return route('pos.customer-display.show', ['token' => $display->token]); } private function freshToken(): string { return Str::random(48); } /** * @return list */ private function linesFromSale(PosSale $sale): array { $sale->loadMissing('lines'); return $sale->lines->map(function ($line) { $qty = (float) $line->quantity; $unit = (int) $line->unit_price_minor; $total = (int) ($line->line_total_minor ?? round($unit * $qty)); return [ 'name' => (string) $line->name, 'quantity' => $qty, 'unit_price_minor' => $unit, 'discount_minor' => 0, 'line_total_minor' => $total, 'note' => $line->notes ?? null, ]; })->values()->all(); } }