Deploy Ladill POS / deploy (push) Successful in 31s
Send real receipt emails from the customer display and sale page, store branch promo content for the idle screen, and fold selected tips into totals.
286 lines
10 KiB
PHP
286 lines
10 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,
|
|
'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<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),
|
|
'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<string, mixed>
|
|
*/
|
|
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<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();
|
|
}
|
|
}
|