Deploy Ladill POS / deploy (push) Successful in 56s
Token-gated full-screen customer view shows order, payment QR, tips, signature, receipt, and promo phases while the till keeps operator UI.
276 lines
9.7 KiB
PHP
276 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Pos;
|
|
|
|
use App\Models\PosCustomerDisplay;
|
|
use App\Models\PosLocation;
|
|
use App\Models\PosSale;
|
|
use Illuminate\Support\Str;
|
|
|
|
class CustomerDisplayService
|
|
{
|
|
public function ensureForLocation(PosLocation $location): PosCustomerDisplay
|
|
{
|
|
$display = PosCustomerDisplay::query()->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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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,
|
|
'total_minor' => (int) $sale->total_minor,
|
|
'currency' => $sale->currency,
|
|
'receipt' => [
|
|
'reference' => $sale->reference,
|
|
'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,
|
|
], $extras));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $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<string, mixed>
|
|
*/
|
|
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),
|
|
'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,
|
|
'message' => $payload['message'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function defaultPayload(?PosLocation $location): array
|
|
{
|
|
$name = $location?->name ?? 'Welcome';
|
|
|
|
return [
|
|
'location_name' => $name,
|
|
'currency' => $location?->currency ?? 'GHS',
|
|
'lines' => [],
|
|
'subtotal_minor' => 0,
|
|
'discount_minor' => 0,
|
|
'tax_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' => [
|
|
'headline' => $name,
|
|
'body' => 'Thank you for shopping with us.',
|
|
'image_url' => $location?->receiptLogoUrl(),
|
|
],
|
|
'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<array{name: string, quantity: float|int, unit_price_minor: int, discount_minor: int, line_total_minor: int, note: null}>
|
|
*/
|
|
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();
|
|
}
|
|
}
|