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;
}
}