Split bills (restaurant tickets): - New pos_payments ledger; a tab is settled across one or more cash/Ladill Pay payments. The ticket shows total / paid / balance, an amount field with Full / ½ / ⅓ / ¼ helpers, and the payments taken. Partial payments keep the tab open; the sale finalises (and frees the table) only when the balance hits zero. Pay splits get their own checkout + callback (pos.payments.callback). Pipe online orders to the KDS: - POST /api/kitchen/orders — a first-party, service-keyed ingest (config pos.kitchen_api_keys, scoped by owner, idempotent by external_ref) that creates a paid, already-fired ticket (order_type=online, lines source=online). - The KDS feed is now payment-agnostic (any kitchen-active sale), so paid online orders sit on the board next to dine-in tabs and bump the same way; they're badged "online". Schema additive: pos_payments, pos_sales.external_ref. Suite green (14). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6c42f18186
commit
9780591e74
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Pos\PosSaleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* First-party kitchen ingest — lets another Ladill app (e.g. Merchant) push a
|
||||
* paid online/QR order onto this account's Kitchen Display. Authenticated with a
|
||||
* per-service key (Authorization: Bearer <KITCHEN_API_KEY_*>) and scoped to an
|
||||
* account by the `owner` parameter. Idempotent by `reference`.
|
||||
*/
|
||||
class KitchenIngestController extends Controller
|
||||
{
|
||||
public function __construct(private PosSaleService $sales) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless($this->service($request) !== null, 401, 'Invalid service key.');
|
||||
|
||||
$data = $request->validate([
|
||||
'owner' => ['required', 'string', 'max:255'],
|
||||
'reference' => ['required', 'string', 'max:255'],
|
||||
'customer_name' => ['nullable', 'string', 'max:120'],
|
||||
'items' => ['required', 'array', 'min:1', 'max:100'],
|
||||
'items.*.name' => ['required', 'string', 'max:200'],
|
||||
'items.*.quantity' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||
'items.*.unit_price_minor' => ['nullable', 'integer', 'min:0'],
|
||||
'items.*.notes' => ['nullable', 'string', 'max:200'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$sale = $this->sales->ingestExternalOrder($data);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['id' => $sale->id, 'reference' => $sale->reference], 201);
|
||||
}
|
||||
|
||||
private function service(Request $request): ?string
|
||||
{
|
||||
$token = (string) $request->bearerToken();
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
foreach ((array) config('pos.kitchen_api_keys', []) as $service => $key) {
|
||||
if ((string) $key !== '' && hash_equals((string) $key, $token)) {
|
||||
return (string) $service;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,9 @@ class KitchenController extends Controller
|
||||
|
||||
private function buildTickets(string $owner): Collection
|
||||
{
|
||||
// Payment-agnostic: dine-in tabs (pending) and paid online orders both
|
||||
// sit on the board while the kitchen is still working them.
|
||||
$sales = PosSale::owned($owner)
|
||||
->openTickets()
|
||||
->where('kitchen_status', PosSale::KITCHEN_ACTIVE)
|
||||
->with(['table', 'lines' => fn ($q) => $q->orderBy('position')->with('modifiers', 'station')])
|
||||
->orderBy('kitchen_sent_at')
|
||||
|
||||
@@ -64,6 +64,8 @@ class TicketController extends Controller
|
||||
|
||||
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,
|
||||
@@ -186,6 +188,7 @@ class TicketController extends Controller
|
||||
|
||||
$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'],
|
||||
]);
|
||||
@@ -202,16 +205,27 @@ class TicketController extends Controller
|
||||
])->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->recordCashPayment($sale);
|
||||
$this->sales->takeCashPayment($sale, $amountMinor);
|
||||
$sale->refresh();
|
||||
|
||||
return redirect()->route('pos.sales.show', $sale)->with('success', 'Ticket settled (cash).');
|
||||
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->initiatePayCheckout($sale, $merchant);
|
||||
$result = $this->sales->startPayPayment($sale, $merchant, $amountMinor);
|
||||
|
||||
return redirect()->away($result['checkout_url']);
|
||||
} catch (RuntimeException $e) {
|
||||
@@ -219,6 +233,26 @@ class TicketController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function paymentCallback(Request $request): RedirectResponse
|
||||
{
|
||||
$reference = trim((string) $request->query('reference', ''));
|
||||
if ($reference === '') {
|
||||
return redirect()->route('pos.floor')->with('error', 'Missing payment reference.');
|
||||
}
|
||||
|
||||
try {
|
||||
$payment = $this->sales->completePayPayment($reference);
|
||||
} catch (\Throwable) {
|
||||
return redirect()->route('pos.floor')->with('error', 'Payment could not be verified.');
|
||||
}
|
||||
|
||||
$sale = $payment->sale->fresh();
|
||||
|
||||
return $sale->isOpen()
|
||||
? redirect()->route('pos.tickets.show', $sale)->with('success', 'Payment received — balance remaining.')
|
||||
: redirect()->route('pos.sales.show', $sale)->with('success', 'Payment received.');
|
||||
}
|
||||
|
||||
private function authorizeOpen(Request $request, PosSale $sale): void
|
||||
{
|
||||
$this->authorizeOwner($request, $sale);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PosPayment extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const METHOD_CASH = 'cash';
|
||||
|
||||
public const METHOD_PAY = 'pay';
|
||||
|
||||
protected $fillable = [
|
||||
'pos_sale_id',
|
||||
'owner_ref',
|
||||
'amount_minor',
|
||||
'method',
|
||||
'status',
|
||||
'pay_order_id',
|
||||
'payment_reference',
|
||||
'paid_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount_minor' => 'integer',
|
||||
'pay_order_id' => 'integer',
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function sale(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PosSale::class, 'pos_sale_id');
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ class PosSale extends Model
|
||||
'location_id',
|
||||
'table_id',
|
||||
'reference',
|
||||
'external_ref',
|
||||
'status',
|
||||
'payment_method',
|
||||
'order_type',
|
||||
@@ -84,6 +85,21 @@ class PosSale extends Model
|
||||
return $this->belongsTo(PosTable::class, 'table_id');
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PosPayment::class);
|
||||
}
|
||||
|
||||
public function paidMinor(): int
|
||||
{
|
||||
return (int) $this->payments()->where('status', PosPayment::STATUS_PAID)->sum('amount_minor');
|
||||
}
|
||||
|
||||
public function balanceMinor(): int
|
||||
{
|
||||
return max(0, (int) $this->total_minor - $this->paidMinor());
|
||||
}
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Services\Pos;
|
||||
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosPayment;
|
||||
use App\Models\PosProduct;
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosSaleLine;
|
||||
@@ -254,6 +256,191 @@ class PosSaleService
|
||||
return $fired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a (possibly partial) cash payment against a ticket. Finalises the
|
||||
* sale once the balance reaches zero — supports split bills.
|
||||
*/
|
||||
public function takeCashPayment(PosSale $sale, int $amountMinor): PosPayment
|
||||
{
|
||||
$balance = $sale->balanceMinor();
|
||||
if ($balance <= 0) {
|
||||
throw new RuntimeException('This ticket is already fully paid.');
|
||||
}
|
||||
|
||||
$payment = $sale->payments()->create([
|
||||
'owner_ref' => $sale->owner_ref,
|
||||
'amount_minor' => max(1, min($amountMinor, $balance)),
|
||||
'method' => PosPayment::METHOD_CASH,
|
||||
'status' => PosPayment::STATUS_PAID,
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
$this->finalizeIfSettled($sale);
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Ladill Pay checkout for part (or all) of a ticket's balance.
|
||||
*
|
||||
* @return array{payment: PosPayment, checkout_url: string}
|
||||
*/
|
||||
public function startPayPayment(PosSale $sale, User $merchant, int $amountMinor): array
|
||||
{
|
||||
$balance = $sale->balanceMinor();
|
||||
if ($balance <= 0) {
|
||||
throw new RuntimeException('This ticket is already fully paid.');
|
||||
}
|
||||
$amount = max(1, min($amountMinor, $balance));
|
||||
|
||||
$payment = $sale->payments()->create([
|
||||
'owner_ref' => $sale->owner_ref,
|
||||
'amount_minor' => $amount,
|
||||
'method' => PosPayment::METHOD_PAY,
|
||||
'status' => PosPayment::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $merchant->public_id,
|
||||
'fee_tier' => 'sales',
|
||||
'source_service' => 'pos',
|
||||
'source_ref' => 'payment:'.$payment->id,
|
||||
'callback_url' => route('pos.payments.callback'),
|
||||
'customer_name' => $sale->customer_name,
|
||||
'customer_email' => $sale->customer_email,
|
||||
'customer_phone' => $sale->customer_phone,
|
||||
'line_items' => [[
|
||||
'name' => 'Ticket '.$sale->reference.' payment',
|
||||
'unit_price_minor' => $amount,
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'metadata' => ['pos_sale_id' => $sale->id, 'pos_payment_id' => $payment->id],
|
||||
]);
|
||||
|
||||
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
||||
if ($checkoutUrl === '') {
|
||||
throw new RuntimeException('Could not start checkout. Please try again.');
|
||||
}
|
||||
|
||||
$payment->forceFill([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'payment_reference' => $payOrder['reference'] ?? null,
|
||||
])->save();
|
||||
|
||||
return ['payment' => $payment, 'checkout_url' => $checkoutUrl];
|
||||
}
|
||||
|
||||
public function completePayPayment(string $reference): PosPayment
|
||||
{
|
||||
$payment = PosPayment::where('payment_reference', $reference)
|
||||
->where('status', PosPayment::STATUS_PENDING)
|
||||
->firstOrFail();
|
||||
|
||||
$payOrder = $this->pay->verify($reference);
|
||||
|
||||
$payment->forceFill([
|
||||
'status' => PosPayment::STATUS_PAID,
|
||||
'pay_order_id' => $payOrder['id'] ?? $payment->pay_order_id,
|
||||
'amount_minor' => (int) ($payOrder['amount_minor'] ?? $payment->amount_minor),
|
||||
'paid_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->finalizeIfSettled($payment->sale);
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
private function finalizeIfSettled(PosSale $sale): void
|
||||
{
|
||||
$sale = $sale->fresh();
|
||||
if (! $sale || $sale->status === PosSale::STATUS_PAID || $sale->balanceMinor() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paid = $sale->payments()->where('status', PosPayment::STATUS_PAID)->get();
|
||||
|
||||
$sale->forceFill([
|
||||
'status' => PosSale::STATUS_PAID,
|
||||
'payment_method' => $paid->count() > 1 ? 'split' : ($paid->first()->method ?? PosSale::METHOD_CASH),
|
||||
'paid_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->closeTicket($sale);
|
||||
$this->timeline->pushPaidSale($sale->fresh('lines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a paid, already-fired kitchen ticket from an external order (e.g. a
|
||||
* Merchant online/QR order). Idempotent by owner + external reference, so it's
|
||||
* safe to call more than once for the same order.
|
||||
*
|
||||
* @param array{owner: string, reference: string, customer_name?: ?string, items: list<array{name: string, quantity?: int, unit_price_minor?: int, notes?: ?string}>} $data
|
||||
*/
|
||||
public function ingestExternalOrder(array $data): PosSale
|
||||
{
|
||||
$owner = (string) $data['owner'];
|
||||
$reference = (string) $data['reference'];
|
||||
|
||||
$existing = PosSale::where('owner_ref', $owner)->where('external_ref', $reference)->first();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$location = PosLocation::owned($owner)->first();
|
||||
$currency = strtoupper((string) ($location?->currency ?? config('pos.default_currency', 'GHS')));
|
||||
|
||||
$lines = [];
|
||||
$subtotal = 0;
|
||||
$position = 0;
|
||||
foreach ($data['items'] as $item) {
|
||||
$name = trim((string) ($item['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
||||
$unit = max(0, (int) ($item['unit_price_minor'] ?? 0));
|
||||
$subtotal += $unit * $qty;
|
||||
$lines[] = [
|
||||
'name' => $name,
|
||||
'unit_price_minor' => $unit,
|
||||
'quantity' => $qty,
|
||||
'line_total_minor' => $unit * $qty,
|
||||
'position' => $position++,
|
||||
'kitchen_state' => PosSaleLine::KITCHEN_QUEUED,
|
||||
'source' => 'online',
|
||||
'notes' => isset($item['notes']) ? (trim((string) $item['notes']) ?: null) : null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($lines === []) {
|
||||
throw new RuntimeException('No valid items to ingest.');
|
||||
}
|
||||
|
||||
$sale = PosSale::create([
|
||||
'owner_ref' => $owner,
|
||||
'location_id' => $location?->id,
|
||||
'reference' => 'POS-'.strtoupper(Str::random(12)),
|
||||
'external_ref' => $reference,
|
||||
'status' => PosSale::STATUS_PAID,
|
||||
'payment_method' => PosSale::METHOD_PAY,
|
||||
'order_type' => 'online',
|
||||
'kitchen_status' => PosSale::KITCHEN_ACTIVE,
|
||||
'customer_name' => $data['customer_name'] ?? null,
|
||||
'subtotal_minor' => $subtotal,
|
||||
'total_minor' => $subtotal,
|
||||
'currency' => $currency,
|
||||
'paid_at' => now(),
|
||||
'opened_at' => now(),
|
||||
'kitchen_sent_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$sale->lines()->create($line);
|
||||
}
|
||||
|
||||
return $sale->fresh('lines');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a ticket once it's settled: free its table and mark kitchen done.
|
||||
*/
|
||||
|
||||
@@ -3,4 +3,10 @@
|
||||
return [
|
||||
'default_currency' => env('POS_DEFAULT_CURRENCY', 'GHS'),
|
||||
'merchant_import_enabled' => (bool) env('POS_MERCHANT_IMPORT_ENABLED', true),
|
||||
|
||||
// Service-to-service keys for the kitchen ingest API (POST /api/kitchen/orders),
|
||||
// keyed by the calling service. Empty = that service cannot push (secure default).
|
||||
'kitchen_api_keys' => array_filter([
|
||||
'merchant' => env('KITCHEN_API_KEY_MERCHANT'),
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('pos_payments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('pos_sale_id')->constrained('pos_sales')->cascadeOnDelete();
|
||||
$table->string('owner_ref')->index();
|
||||
$table->unsignedInteger('amount_minor');
|
||||
$table->string('method')->default('cash'); // cash | pay
|
||||
$table->string('status')->default('pending'); // pending | paid | failed
|
||||
$table->unsignedBigInteger('pay_order_id')->nullable();
|
||||
$table->string('payment_reference')->nullable()->index();
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pos_payments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('pos_sales', function (Blueprint $table) {
|
||||
// Source order id for ingested online orders (e.g. a Merchant order) — for idempotency.
|
||||
$table->string('external_ref')->nullable()->after('reference')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('pos_sales', function (Blueprint $table) {
|
||||
$table->dropColumn('external_ref');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -338,6 +338,13 @@ Alpine.data('posTicket', (config = {}) => ({
|
||||
activeCategory: '',
|
||||
picker: { open: false, product: null, groups: [], notes: '', course: '' },
|
||||
courseLabels: config.courseLabels || {},
|
||||
paidMinor: config.paidMinor || 0,
|
||||
settleAmount: '',
|
||||
|
||||
init() { this.settleAmount = (this.balanceMinor / 100).toFixed(2); },
|
||||
get balanceMinor() { return Math.max(0, this.totalMinor - this.paidMinor); },
|
||||
splitAmount(n) { this.settleAmount = (this.balanceMinor / 100 / n).toFixed(2); },
|
||||
fullAmount() { this.settleAmount = (this.balanceMinor / 100).toFixed(2); },
|
||||
|
||||
money(minor) { return this.currency + ' ' + ((minor || 0) / 100).toFixed(2); },
|
||||
get hasNew() { return this.lines.some((l) => ! l.fired); },
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<span x-show="line.course" x-cloak class="rounded bg-slate-100 px-1.5 text-[10px] font-medium text-slate-500" x-text="line.course"></span>
|
||||
<span x-show="line.station" x-cloak class="rounded bg-indigo-50 px-1.5 text-[10px] font-medium text-indigo-600" x-text="line.station"></span>
|
||||
<span x-show="line.source === 'guest'" x-cloak class="rounded bg-violet-50 px-1.5 text-[10px] font-medium text-violet-700">guest</span>
|
||||
<span x-show="line.source === 'online'" x-cloak class="rounded bg-blue-50 px-1.5 text-[10px] font-medium text-blue-700">online</span>
|
||||
<span class="text-[11px] uppercase tracking-wide text-slate-400" x-text="line.state"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,10 +86,29 @@
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 p-4">
|
||||
<div class="mb-3 flex items-center justify-between text-base font-semibold text-slate-900">
|
||||
<span>Total</span>
|
||||
<span x-text="money(totalMinor)"></span>
|
||||
<div class="mb-1 flex items-center justify-between text-sm text-slate-500">
|
||||
<span>Total</span><span x-text="money(totalMinor)"></span>
|
||||
</div>
|
||||
@if($paidMinor > 0)
|
||||
<div class="mb-1 flex items-center justify-between text-sm text-emerald-600">
|
||||
<span>Paid</span><span x-text="'−' + money(paidMinor)"></span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="mb-3 flex items-center justify-between text-base font-semibold text-slate-900">
|
||||
<span x-text="paidMinor > 0 ? 'Balance' : 'Total'"></span>
|
||||
<span x-text="money(balanceMinor)"></span>
|
||||
</div>
|
||||
|
||||
@if($payments->isNotEmpty())
|
||||
<div class="mb-3 space-y-1 rounded-xl bg-slate-50 p-2">
|
||||
@foreach($payments as $p)
|
||||
<div class="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>{{ ucfirst($p->method) }} · {{ $p->paid_at?->format('H:i') }}</span>
|
||||
<span>{{ $sale->currency }} {{ number_format($p->amount_minor / 100, 2) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Fire to kitchen (all, or by course when more than one course is waiting) --}}
|
||||
<div x-show="hasNew" x-cloak class="mb-2 space-y-1.5">
|
||||
@@ -112,12 +131,24 @@
|
||||
@csrf
|
||||
<input type="text" name="customer_name" placeholder="Customer name (optional)"
|
||||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="number" step="0.01" min="0.01" name="amount" x-model="settleAmount"
|
||||
class="w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="Full balance">
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<button type="button" @click="fullAmount()" class="rounded-lg border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50">Full</button>
|
||||
<button type="button" @click="splitAmount(2)" class="rounded-lg border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50">½</button>
|
||||
<button type="button" @click="splitAmount(3)" class="rounded-lg border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50">⅓</button>
|
||||
<button type="button" @click="splitAmount(4)" class="rounded-lg border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50">¼</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button name="payment_method" value="cash" :disabled="lines.length === 0"
|
||||
class="rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-40">Cash</button>
|
||||
<button name="payment_method" value="pay" :disabled="lines.length === 0"
|
||||
class="btn-primary disabled:opacity-40">Ladill Pay</button>
|
||||
</div>
|
||||
<p class="text-center text-[11px] text-slate-400">Pay the full balance, or a partial amount to split the bill.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+9
-2
@@ -1,4 +1,11 @@
|
||||
<?php
|
||||
|
||||
// Ladill POS is a web-only in-store register; it exposes no JSON API.
|
||||
// (The mobile/QR API surface from the upstream fork was removed.)
|
||||
use App\Http\Controllers\Api\KitchenIngestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// First-party kitchen ingest — sibling Ladill apps (e.g. Merchant) push paid
|
||||
// online/QR orders onto an account's Kitchen Display. Auth via a per-service
|
||||
// Bearer key (config pos.kitchen_api_keys); scoped by the `owner` parameter.
|
||||
Route::post('/kitchen/orders', [KitchenIngestController::class, 'store'])
|
||||
->middleware('throttle:120,1')
|
||||
->name('api.kitchen.orders');
|
||||
|
||||
@@ -30,6 +30,7 @@ Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSign
|
||||
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('pos.dashboard') : view('auth.signed-out'))->name('pos.signed-out');
|
||||
|
||||
Route::get('/sales/{sale}/callback', [SaleController::class, 'callback'])->name('pos.sales.callback');
|
||||
Route::get('/payments/callback', [TicketController::class, 'paymentCallback'])->name('pos.payments.callback');
|
||||
|
||||
// Public table-QR ordering (no auth — scoped by the table short_code).
|
||||
Route::get('/t/{code}', [TableOrderController::class, 'menu'])->name('pos.table.menu');
|
||||
|
||||
@@ -187,6 +187,72 @@ class PosRestaurantTest extends TestCase
|
||||
$this->assertSame(PosSaleLine::KITCHEN_QUEUED, $line->kitchen_state);
|
||||
}
|
||||
|
||||
public function test_split_bill_settles_over_multiple_cash_payments(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
[, $table, $product] = $this->restaurant($user); // Latte 2500
|
||||
|
||||
$this->actingAs($user)->post(route('pos.tickets.open'), ['order_type' => 'dine_in', 'table_id' => $table->id])->assertRedirect();
|
||||
$sale = PosSale::where('owner_ref', $user->public_id)->firstOrFail();
|
||||
$this->actingAs($user)->postJson(route('pos.tickets.lines.add', $sale), ['product_id' => $product->id, 'quantity' => 1])->assertOk();
|
||||
|
||||
// Partial cash payment — ticket stays open with a balance.
|
||||
$this->actingAs($user)->post(route('pos.tickets.settle', $sale), ['payment_method' => 'cash', 'amount' => '10.00'])->assertRedirect();
|
||||
$sale->refresh();
|
||||
$this->assertSame(PosSale::STATUS_PENDING, $sale->status);
|
||||
$this->assertSame(1000, $sale->paidMinor());
|
||||
$this->assertSame(1500, $sale->balanceMinor());
|
||||
$this->assertSame(PosTable::STATUS_OCCUPIED, $table->fresh()->status);
|
||||
|
||||
// Pay the rest — settles and frees the table.
|
||||
$this->actingAs($user)->post(route('pos.tickets.settle', $sale), ['payment_method' => 'cash', 'amount' => '15.00'])
|
||||
->assertRedirect(route('pos.sales.show', $sale));
|
||||
$sale->refresh();
|
||||
$this->assertSame(PosSale::STATUS_PAID, $sale->status);
|
||||
$this->assertSame('split', $sale->payment_method);
|
||||
$this->assertSame(0, $sale->balanceMinor());
|
||||
$this->assertSame(PosTable::STATUS_FREE, $table->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_external_online_order_ingests_to_kitchen(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
$this->restaurant($user);
|
||||
config(['pos.kitchen_api_keys' => ['merchant' => 'test-key']]);
|
||||
|
||||
$payload = [
|
||||
'owner' => $user->public_id,
|
||||
'reference' => 'M-123',
|
||||
'customer_name' => 'Kofi',
|
||||
'items' => [['name' => 'Pizza', 'quantity' => 2, 'unit_price_minor' => 4000, 'notes' => 'thin crust']],
|
||||
];
|
||||
|
||||
// Rejected without a valid service key.
|
||||
$this->postJson(route('api.kitchen.orders'), $payload)->assertStatus(401);
|
||||
|
||||
// Accepted with the key — creates a paid, fired kitchen ticket.
|
||||
$this->withHeader('Authorization', 'Bearer test-key')
|
||||
->postJson(route('api.kitchen.orders'), $payload)
|
||||
->assertStatus(201)->assertJsonStructure(['id', 'reference']);
|
||||
|
||||
$sale = PosSale::where('owner_ref', $user->public_id)->where('external_ref', 'M-123')->firstOrFail();
|
||||
$this->assertSame(PosSale::STATUS_PAID, $sale->status);
|
||||
$this->assertSame('online', $sale->order_type);
|
||||
$this->assertSame(PosSale::KITCHEN_ACTIVE, $sale->kitchen_status);
|
||||
$this->assertSame(8000, $sale->total_minor);
|
||||
|
||||
$line = $sale->lines()->firstOrFail();
|
||||
$this->assertSame('online', $line->source);
|
||||
$this->assertSame(PosSaleLine::KITCHEN_QUEUED, $line->kitchen_state);
|
||||
|
||||
// Idempotent by reference.
|
||||
$this->withHeader('Authorization', 'Bearer test-key')->postJson(route('api.kitchen.orders'), $payload)->assertStatus(201);
|
||||
$this->assertSame(1, PosSale::where('external_ref', 'M-123')->count());
|
||||
|
||||
// Shows on the KDS feed (payment-agnostic).
|
||||
$this->actingAs($user)->getJson(route('pos.kitchen.feed'))->assertOk()->assertJsonCount(1, 'tickets');
|
||||
}
|
||||
|
||||
public function test_cannot_open_two_tabs_on_one_table(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
Reference in New Issue
Block a user