Split bills + kitchen ingest for online orders
Deploy Ladill POS / deploy (push) Successful in 36s

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:
isaacclad
2026-06-24 23:49:01 +00:00
co-authored by Claude Opus 4.8
parent 6c42f18186
commit 9780591e74
15 changed files with 521 additions and 9 deletions
+37 -3
View File
@@ -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);