From f64a28cb21a8479d084704771d2260beff3c0d30 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 25 Jun 2026 21:52:51 +0000 Subject: [PATCH] Allow cancelling and deleting pending POS sales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a sale's detail page, unpaid sales (retail or restaurant) can now be cancelled or deleted: - Cancel (pending only) marks it cancelled and frees its table / clears it from the kitchen (PosSaleService::cancelSale). - Delete removes the sale and cascades its lines, modifiers and payments, freeing the table first (deleteSale). Paid sales are protected — they can't be deleted. Status badge now distinguishes cancelled/failed. Covered by PosRestaurantTest. Co-Authored-By: Claude Opus 4.8 --- app/Http/Controllers/Pos/SaleController.php | 26 +++++++++++++++++ app/Services/Pos/PosSaleService.php | 27 +++++++++++++++++ resources/views/pos/sales/show.blade.php | 26 ++++++++++++++++- routes/web.php | 2 ++ tests/Feature/PosRestaurantTest.php | 32 +++++++++++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Pos/SaleController.php b/app/Http/Controllers/Pos/SaleController.php index b9d0535..e8462b5 100644 --- a/app/Http/Controllers/Pos/SaleController.php +++ b/app/Http/Controllers/Pos/SaleController.php @@ -44,6 +44,32 @@ class SaleController extends Controller return view('pos.sales.show', compact('sale', 'invoiceUrl')); } + public function cancel(Request $request, PosSale $sale): RedirectResponse + { + $this->authorizeOwner($request, $sale); + + try { + $this->sales->cancelSale($sale); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect()->route('pos.sales.show', $sale)->with('success', 'Sale cancelled.'); + } + + public function destroy(Request $request, PosSale $sale): RedirectResponse + { + $this->authorizeOwner($request, $sale); + + try { + $this->sales->deleteSale($sale); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect()->route('pos.sales.index')->with('success', 'Sale deleted.'); + } + public function callback(Request $request, PosSale $sale): RedirectResponse { $reference = trim((string) $request->query('reference', $sale->payment_reference ?? '')); diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index 3caf6c5..3f42d9a 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -446,6 +446,33 @@ class PosSaleService return $sale->fresh('lines'); } + /** Cancel a pending sale/ticket — frees its table and clears it from the kitchen. */ + public function cancelSale(PosSale $sale): void + { + if ($sale->status !== PosSale::STATUS_PENDING) { + throw new RuntimeException('Only pending sales can be cancelled.'); + } + + $sale->forceFill(['status' => PosSale::STATUS_CANCELLED])->save(); + $this->closeTicket($sale); + } + + /** Permanently delete an unpaid sale (and its lines, modifiers, payments). */ + public function deleteSale(PosSale $sale): void + { + if ($sale->status === PosSale::STATUS_PAID) { + throw new RuntimeException('Paid sales cannot be deleted.'); + } + + if ($sale->table_id) { + PosTable::where('id', $sale->table_id) + ->where('current_sale_id', $sale->id) + ->update(['status' => PosTable::STATUS_FREE, 'current_sale_id' => null]); + } + + $sale->delete(); + } + /** * Close a ticket once it's settled: free its table and mark kitchen done. */ diff --git a/resources/views/pos/sales/show.blade.php b/resources/views/pos/sales/show.blade.php index 5bfbfde..147693c 100644 --- a/resources/views/pos/sales/show.blade.php +++ b/resources/views/pos/sales/show.blade.php @@ -8,7 +8,15 @@

{{ $sale->reference }}

{{ $sale->created_at->format('M j, Y g:i A') }}

- + @php + $badge = match ($sale->status) { + 'paid' => 'bg-green-50 text-green-700', + 'cancelled' => 'bg-slate-100 text-slate-500', + 'failed' => 'bg-red-50 text-red-600', + default => 'bg-amber-50 text-amber-700', + }; + @endphp + {{ $sale->status }} @@ -45,6 +53,22 @@ Create invoice @endif + + @unless ($sale->isPaid()) +
+ @if ($sale->status === 'pending') +
+ @csrf + +
+ @endif +
+ @csrf + @method('DELETE') + +
+
+ @endunless diff --git a/routes/web.php b/routes/web.php index 7ea750e..e574803 100644 --- a/routes/web.php +++ b/routes/web.php @@ -55,6 +55,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/sales', [SaleController::class, 'index'])->name('pos.sales.index'); Route::get('/sales/{sale}', [SaleController::class, 'show'])->name('pos.sales.show'); + Route::post('/sales/{sale}/cancel', [SaleController::class, 'cancel'])->name('pos.sales.cancel'); + Route::delete('/sales/{sale}', [SaleController::class, 'destroy'])->name('pos.sales.destroy'); // Restaurant mode — floor, open tickets (tabs), and the kitchen display. Route::get('/floor', [TableController::class, 'index'])->name('pos.floor'); diff --git a/tests/Feature/PosRestaurantTest.php b/tests/Feature/PosRestaurantTest.php index a848400..02aebeb 100644 --- a/tests/Feature/PosRestaurantTest.php +++ b/tests/Feature/PosRestaurantTest.php @@ -268,6 +268,38 @@ class PosRestaurantTest extends TestCase $this->assertSame('retail', PosLocation::owned($user->public_id)->first()->service_style); } + public function test_pending_ticket_can_be_cancelled_then_deleted(): void + { + $user = $this->user(); + [, $table, $product] = $this->restaurant($user); + + $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(); + + // Cancel — frees the table. + $this->actingAs($user)->post(route('pos.sales.cancel', $sale))->assertRedirect(route('pos.sales.show', $sale)); + $this->assertSame(PosSale::STATUS_CANCELLED, $sale->fresh()->status); + $this->assertSame(PosTable::STATUS_FREE, $table->fresh()->status); + + // Delete — gone (cascades lines). + $this->actingAs($user)->delete(route('pos.sales.destroy', $sale))->assertRedirect(route('pos.sales.index')); + $this->assertNull(PosSale::find($sale->id)); + $this->assertSame(0, $sale->lines()->count()); + } + + public function test_paid_sale_cannot_be_deleted(): void + { + $user = $this->user(); + $sale = PosSale::create([ + 'owner_ref' => $user->public_id, 'reference' => 'POS-PAID1', 'status' => PosSale::STATUS_PAID, + 'payment_method' => 'cash', 'subtotal_minor' => 1000, 'total_minor' => 1000, 'currency' => 'GHS', 'paid_at' => now(), + ]); + + $this->actingAs($user)->delete(route('pos.sales.destroy', $sale)); + $this->assertNotNull(PosSale::find($sale->id)); + } + public function test_cannot_open_two_tabs_on_one_table(): void { $user = $this->user();