From 9780591e7425b82e84225c00cfbf7d6145df3c28 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 24 Jun 2026 23:49:01 +0000 Subject: [PATCH] Split bills + kitchen ingest for online orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Api/KitchenIngestController.php | 59 ++++++ .../Controllers/Pos/KitchenController.php | 3 +- app/Http/Controllers/Pos/TicketController.php | 40 +++- app/Models/PosPayment.php | 44 +++++ app/Models/PosSale.php | 16 ++ app/Services/Pos/PosSaleService.php | 187 ++++++++++++++++++ config/pos.php | 6 + .../2026_06_30_000000_create_pos_payments.php | 29 +++ ...1_000000_add_external_ref_to_pos_sales.php | 23 +++ resources/js/app.js | 7 + resources/views/pos/kitchen/index.blade.php | 1 + resources/views/pos/tickets/show.blade.php | 37 +++- routes/api.php | 11 +- routes/web.php | 1 + tests/Feature/PosRestaurantTest.php | 66 +++++++ 15 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/Api/KitchenIngestController.php create mode 100644 app/Models/PosPayment.php create mode 100644 database/migrations/2026_06_30_000000_create_pos_payments.php create mode 100644 database/migrations/2026_07_01_000000_add_external_ref_to_pos_sales.php diff --git a/app/Http/Controllers/Api/KitchenIngestController.php b/app/Http/Controllers/Api/KitchenIngestController.php new file mode 100644 index 0000000..88b7bbe --- /dev/null +++ b/app/Http/Controllers/Api/KitchenIngestController.php @@ -0,0 +1,59 @@ +) 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; + } +} diff --git a/app/Http/Controllers/Pos/KitchenController.php b/app/Http/Controllers/Pos/KitchenController.php index 8440837..12cb221 100644 --- a/app/Http/Controllers/Pos/KitchenController.php +++ b/app/Http/Controllers/Pos/KitchenController.php @@ -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') diff --git a/app/Http/Controllers/Pos/TicketController.php b/app/Http/Controllers/Pos/TicketController.php index 0ff593d..8ab4835 100644 --- a/app/Http/Controllers/Pos/TicketController.php +++ b/app/Http/Controllers/Pos/TicketController.php @@ -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); diff --git a/app/Models/PosPayment.php b/app/Models/PosPayment.php new file mode 100644 index 0000000..f47a4de --- /dev/null +++ b/app/Models/PosPayment.php @@ -0,0 +1,44 @@ + 'integer', + 'pay_order_id' => 'integer', + 'paid_at' => 'datetime', + ]; + } + + public function sale(): BelongsTo + { + return $this->belongsTo(PosSale::class, 'pos_sale_id'); + } +} diff --git a/app/Models/PosSale.php b/app/Models/PosSale.php index c4261a6..52c70d2 100644 --- a/app/Models/PosSale.php +++ b/app/Models/PosSale.php @@ -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; diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index 66cae2a..23b7ecb 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -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} $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. */ diff --git a/config/pos.php b/config/pos.php index 1e6f10c..b3b0def 100644 --- a/config/pos.php +++ b/config/pos.php @@ -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'), + ]), ]; diff --git a/database/migrations/2026_06_30_000000_create_pos_payments.php b/database/migrations/2026_06_30_000000_create_pos_payments.php new file mode 100644 index 0000000..e9c4994 --- /dev/null +++ b/database/migrations/2026_06_30_000000_create_pos_payments.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/database/migrations/2026_07_01_000000_add_external_ref_to_pos_sales.php b/database/migrations/2026_07_01_000000_add_external_ref_to_pos_sales.php new file mode 100644 index 0000000..800997e --- /dev/null +++ b/database/migrations/2026_07_01_000000_add_external_ref_to_pos_sales.php @@ -0,0 +1,23 @@ +string('external_ref')->nullable()->after('reference')->index(); + }); + } + + public function down(): void + { + Schema::table('pos_sales', function (Blueprint $table) { + $table->dropColumn('external_ref'); + }); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index 9dc3394..ffc590c 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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); }, diff --git a/resources/views/pos/kitchen/index.blade.php b/resources/views/pos/kitchen/index.blade.php index 432e6c9..d1f47b3 100644 --- a/resources/views/pos/kitchen/index.blade.php +++ b/resources/views/pos/kitchen/index.blade.php @@ -56,6 +56,7 @@ guest + online diff --git a/resources/views/pos/tickets/show.blade.php b/resources/views/pos/tickets/show.blade.php index 3635f28..eecaa6e 100644 --- a/resources/views/pos/tickets/show.blade.php +++ b/resources/views/pos/tickets/show.blade.php @@ -86,10 +86,29 @@
-
- Total - +
+ Total
+ @if($paidMinor > 0) +
+ Paid +
+ @endif +
+ + +
+ + @if($payments->isNotEmpty()) +
+ @foreach($payments as $p) +
+ {{ ucfirst($p->method) }} · {{ $p->paid_at?->format('H:i') }} + {{ $sale->currency }} {{ number_format($p->amount_minor / 100, 2) }} +
+ @endforeach +
+ @endif {{-- Fire to kitchen (all, or by course when more than one course is waiting) --}}
@@ -112,12 +131,24 @@ @csrf +
+ +
+ + + + +
+
+

Pay the full balance, or a partial amount to split the bill.

diff --git a/routes/api.php b/routes/api.php index 1d8a31c..2e9e627 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,4 +1,11 @@ middleware('throttle:120,1') + ->name('api.kitchen.orders'); diff --git a/routes/web.php b/routes/web.php index 3263aa5..40c5219 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/PosRestaurantTest.php b/tests/Feature/PosRestaurantTest.php index 6fff281..dea014d 100644 --- a/tests/Feature/PosRestaurantTest.php +++ b/tests/Feature/PosRestaurantTest.php @@ -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();