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);
|
||||
|
||||
Reference in New Issue
Block a user