Add dual-screen customer display for the register.
Deploy Ladill POS / deploy (push) Successful in 56s
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.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Pos;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||
use App\Models\PosCustomerDisplay;
|
||||
use App\Services\Pos\CustomerDisplayService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CustomerDisplayController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(private CustomerDisplayService $displays) {}
|
||||
|
||||
/** Operator: open/link info for the customer-facing screen. */
|
||||
public function info(Request $request): JsonResponse
|
||||
{
|
||||
$location = $this->location($request);
|
||||
$display = $this->displays->ensureForLocation($location);
|
||||
|
||||
return response()->json([
|
||||
'url' => $this->displays->displayUrl($display),
|
||||
'token' => $display->token,
|
||||
'phase' => $display->phase,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rotate(Request $request): JsonResponse
|
||||
{
|
||||
$location = $this->location($request);
|
||||
$display = $this->displays->rotateToken($this->displays->ensureForLocation($location));
|
||||
|
||||
return response()->json([
|
||||
'url' => $this->displays->displayUrl($display),
|
||||
'token' => $display->token,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Operator: push cart / phase updates from the register. */
|
||||
public function push(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'phase' => ['nullable', 'string', Rule::in(PosCustomerDisplay::PHASES)],
|
||||
'lines' => ['nullable', 'array'],
|
||||
'lines.*.name' => ['required_with:lines', 'string', 'max:200'],
|
||||
'lines.*.quantity' => ['required_with:lines', 'numeric', 'min:0'],
|
||||
'lines.*.unit_price_minor' => ['required_with:lines', 'integer', 'min:0'],
|
||||
'lines.*.discount_minor' => ['nullable', 'integer', 'min:0'],
|
||||
'lines.*.note' => ['nullable', 'string', 'max:200'],
|
||||
'discount_minor' => ['nullable', 'integer', 'min:0'],
|
||||
'tax_minor' => ['nullable', 'integer', 'min:0'],
|
||||
'customer_name' => ['nullable', 'string', 'max:120'],
|
||||
'loyalty' => ['nullable', 'array'],
|
||||
'loyalty.points_balance' => ['nullable', 'integer'],
|
||||
'loyalty.points_earn' => ['nullable', 'integer'],
|
||||
'loyalty.points_redeem' => ['nullable', 'integer'],
|
||||
'loyalty.label' => ['nullable', 'string', 'max:120'],
|
||||
'payment' => ['nullable', 'array'],
|
||||
'tip' => ['nullable', 'array'],
|
||||
'signature' => ['nullable', 'array'],
|
||||
'receipt' => ['nullable', 'array'],
|
||||
'promo' => ['nullable', 'array'],
|
||||
'message' => ['nullable', 'string', 'max:500'],
|
||||
'sale_reference' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$location = $this->location($request);
|
||||
$phase = $data['phase'] ?? null;
|
||||
$lines = $data['lines'] ?? null;
|
||||
|
||||
$extras = array_filter([
|
||||
'discount_minor' => $data['discount_minor'] ?? null,
|
||||
'tax_minor' => $data['tax_minor'] ?? null,
|
||||
'customer_name' => $data['customer_name'] ?? null,
|
||||
'loyalty' => $data['loyalty'] ?? null,
|
||||
'payment' => $data['payment'] ?? null,
|
||||
'tip' => $data['tip'] ?? null,
|
||||
'signature' => $data['signature'] ?? null,
|
||||
'receipt' => $data['receipt'] ?? null,
|
||||
'promo' => $data['promo'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'sale_reference' => $data['sale_reference'] ?? null,
|
||||
], fn ($v) => $v !== null);
|
||||
|
||||
if ($phase === PosCustomerDisplay::PHASE_CART || ($lines !== null && $phase === null)) {
|
||||
$display = $this->displays->pushCart($location, $lines ?? [], $extras);
|
||||
} elseif ($phase !== null) {
|
||||
if ($lines !== null) {
|
||||
$extras['lines'] = $lines;
|
||||
}
|
||||
$display = $this->displays->push($location, $phase, $extras);
|
||||
} else {
|
||||
$display = $this->displays->push($location, PosCustomerDisplay::PHASE_IDLE, $extras);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'phase' => $display->phase,
|
||||
'updated_at' => optional($display->last_pushed_at)->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Operator: read (and optionally clear) the latest customer action (tip, signature, email). */
|
||||
public function actions(Request $request): JsonResponse
|
||||
{
|
||||
$location = $this->location($request);
|
||||
$display = $this->displays->ensureForLocation($location);
|
||||
$action = $display->customer_action;
|
||||
|
||||
if ($request->boolean('clear') && $action) {
|
||||
$this->displays->clearCustomerAction($display);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'action' => $action,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosProduct;
|
||||
use App\Models\PosSale;
|
||||
use App\Services\Pos\CustomerDisplayService;
|
||||
use App\Services\Pos\PosBarcodeLookupService;
|
||||
use App\Services\Pos\PosLocationService;
|
||||
use App\Services\Pos\PosSaleService;
|
||||
@@ -25,18 +26,24 @@ class RegisterController extends Controller
|
||||
private PosSaleService $sales,
|
||||
private PosLocationService $locations,
|
||||
private PosBarcodeLookupService $barcodes,
|
||||
private CustomerDisplayService $customerDisplays,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->location($request);
|
||||
$display = $this->customerDisplays->ensureForLocation($location);
|
||||
|
||||
return view('pos.register', [
|
||||
'products' => $this->registerProducts($owner, $location),
|
||||
'location' => $location,
|
||||
'crmCustomers' => $this->crmCustomers($owner),
|
||||
'barcodeLookupUrl' => route('pos.register.lookup'),
|
||||
'customerDisplayUrl' => $this->customerDisplays->displayUrl($display),
|
||||
'customerDisplayPushUrl' => route('pos.customer-display.push'),
|
||||
'customerDisplayActionsUrl' => route('pos.customer-display.actions'),
|
||||
'isRestaurant' => $location->isRestaurant(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -163,6 +170,7 @@ class RegisterController extends Controller
|
||||
|
||||
if ($data['payment_method'] === 'cash') {
|
||||
$this->sales->recordCashPayment($sale);
|
||||
$this->customerDisplays->pushReceipt($location, $sale->fresh(['lines']));
|
||||
|
||||
if ($location->printer_auto_print) {
|
||||
return redirect()
|
||||
@@ -176,18 +184,26 @@ class RegisterController extends Controller
|
||||
}
|
||||
|
||||
$result = $this->sales->initiatePayCheckout($sale, $merchant);
|
||||
$checkoutUrl = $result['checkout_url'] ?? null;
|
||||
|
||||
$this->customerDisplays->pushPayment($location, $sale->fresh(['lines']), [
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'qr_url' => $checkoutUrl,
|
||||
'message' => 'Complete payment with card or MoMo',
|
||||
'options' => ['Card / MoMo'],
|
||||
]);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'checkout_url' => $result['checkout_url'],
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'sale_id' => $sale->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('pos.sales.show', $sale)
|
||||
->with('checkout_url', $result['checkout_url'])
|
||||
->with('success', 'Payment sheet ready — hand the device to the customer for card or MoMo.');
|
||||
->with('checkout_url', $checkoutUrl)
|
||||
->with('success', 'Payment sheet ready — customer screen shows the QR; till sheet is for the operator.');
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PosCustomerDisplay;
|
||||
use App\Services\Pos\CustomerDisplayService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CustomerDisplayController extends Controller
|
||||
{
|
||||
public function __construct(private CustomerDisplayService $displays) {}
|
||||
|
||||
public function show(string $token): View
|
||||
{
|
||||
$display = $this->find($token);
|
||||
|
||||
return view('pos.customer-display', [
|
||||
'display' => $display,
|
||||
'state' => $this->displays->publicState($display),
|
||||
'stateUrl' => route('pos.customer-display.state', ['token' => $token]),
|
||||
'streamUrl' => route('pos.customer-display.stream', ['token' => $token]),
|
||||
'actionUrl' => route('pos.customer-display.action', ['token' => $token]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function state(string $token): JsonResponse
|
||||
{
|
||||
$display = $this->find($token);
|
||||
|
||||
return response()->json($this->displays->publicState($display));
|
||||
}
|
||||
|
||||
public function stream(Request $request, string $token): StreamedResponse
|
||||
{
|
||||
$display = $this->find($token);
|
||||
$id = $display->id;
|
||||
$request->session()->save();
|
||||
|
||||
$response = new StreamedResponse(function () use ($id) {
|
||||
while (ob_get_level() > 0) {
|
||||
@ob_end_flush();
|
||||
}
|
||||
@set_time_limit(0);
|
||||
|
||||
$start = time();
|
||||
$lastHash = null;
|
||||
|
||||
while (true) {
|
||||
$display = PosCustomerDisplay::query()->find($id);
|
||||
if (! $display) {
|
||||
echo "event: end\ndata: {}\n\n";
|
||||
break;
|
||||
}
|
||||
|
||||
$payload = $this->displays->publicState($display);
|
||||
$json = json_encode($payload);
|
||||
$hash = md5((string) $json);
|
||||
|
||||
if ($hash !== $lastHash) {
|
||||
echo 'data: '.$json."\n\n";
|
||||
$lastHash = $hash;
|
||||
} else {
|
||||
echo ": ping\n\n";
|
||||
}
|
||||
@ob_flush();
|
||||
@flush();
|
||||
|
||||
if (connection_aborted() || (time() - $start) >= 25) {
|
||||
break;
|
||||
}
|
||||
usleep(400000);
|
||||
}
|
||||
});
|
||||
|
||||
$response->headers->set('Content-Type', 'text/event-stream');
|
||||
$response->headers->set('Cache-Control', 'no-cache');
|
||||
$response->headers->set('Connection', 'keep-alive');
|
||||
$response->headers->set('X-Accel-Buffering', 'no');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function action(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$display = $this->find($token);
|
||||
|
||||
$data = $request->validate([
|
||||
'type' => ['required', 'string', 'in:tip,signature,receipt_email,payment_option'],
|
||||
'tip_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'tip_minor' => ['nullable', 'integer', 'min:0'],
|
||||
'signature_data_url' => ['nullable', 'string', 'max:500000'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'payment_option' => ['nullable', 'string', 'max:40'],
|
||||
]);
|
||||
|
||||
$action = ['type' => $data['type']];
|
||||
|
||||
if ($data['type'] === 'tip') {
|
||||
$action['tip_percent'] = $data['tip_percent'] ?? null;
|
||||
$action['tip_minor'] = $data['tip_minor'] ?? null;
|
||||
// Reflect selection on the screen immediately.
|
||||
$payload = $display->payload ?? [];
|
||||
$tip = $payload['tip'] ?? [];
|
||||
$tip['selected_percent'] = $data['tip_percent'] ?? null;
|
||||
$tip['selected_minor'] = $data['tip_minor'] ?? null;
|
||||
$payload['tip'] = $tip;
|
||||
$display->update(['payload' => $payload, 'last_pushed_at' => now()]);
|
||||
}
|
||||
|
||||
if ($data['type'] === 'signature') {
|
||||
$action['signature_data_url'] = $data['signature_data_url'] ?? null;
|
||||
$payload = $display->payload ?? [];
|
||||
$sig = $payload['signature'] ?? [];
|
||||
$sig['data_url'] = $data['signature_data_url'] ?? null;
|
||||
$payload['signature'] = $sig;
|
||||
$display->update(['payload' => $payload, 'last_pushed_at' => now()]);
|
||||
}
|
||||
|
||||
if ($data['type'] === 'receipt_email') {
|
||||
$action['email'] = $data['email'] ?? null;
|
||||
}
|
||||
|
||||
if ($data['type'] === 'payment_option') {
|
||||
$action['payment_option'] = $data['payment_option'] ?? null;
|
||||
}
|
||||
|
||||
$this->displays->recordCustomerAction($display->fresh(), $action);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'state' => $this->displays->publicState($display->fresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
private function find(string $token): PosCustomerDisplay
|
||||
{
|
||||
return PosCustomerDisplay::query()
|
||||
->where('token', $token)
|
||||
->with('location')
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PosCustomerDisplay extends Model
|
||||
{
|
||||
public const PHASE_IDLE = 'idle';
|
||||
|
||||
public const PHASE_CART = 'cart';
|
||||
|
||||
public const PHASE_PAYMENT = 'payment';
|
||||
|
||||
public const PHASE_TIP = 'tip';
|
||||
|
||||
public const PHASE_SIGNATURE = 'signature';
|
||||
|
||||
public const PHASE_RECEIPT = 'receipt';
|
||||
|
||||
public const PHASE_PROMO = 'promo';
|
||||
|
||||
public const PHASES = [
|
||||
self::PHASE_IDLE,
|
||||
self::PHASE_CART,
|
||||
self::PHASE_PAYMENT,
|
||||
self::PHASE_TIP,
|
||||
self::PHASE_SIGNATURE,
|
||||
self::PHASE_RECEIPT,
|
||||
self::PHASE_PROMO,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'location_id',
|
||||
'owner_ref',
|
||||
'token',
|
||||
'phase',
|
||||
'payload',
|
||||
'customer_action',
|
||||
'last_pushed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
'customer_action' => 'array',
|
||||
'last_pushed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function location(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PosLocation::class, 'location_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user