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
@@ -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')
+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);
+44
View File
@@ -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');
}
}
+16
View File
@@ -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;
+187
View File
@@ -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.
*/