Allow cancelling and deleting pending POS sales
Deploy Ladill POS / deploy (push) Successful in 37s

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 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-25 21:52:51 +00:00
co-authored by Claude Opus 4.8
parent 5d8e185223
commit f64a28cb21
5 changed files with 112 additions and 1 deletions
@@ -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 ?? ''));
+27
View File
@@ -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.
*/
+25 -1
View File
@@ -8,7 +8,15 @@
<h1 class="text-lg font-semibold text-slate-900">{{ $sale->reference }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ $sale->created_at->format('M j, Y g:i A') }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold capitalize {{ $sale->status === 'paid' ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700' }}">
@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
<span class="rounded-full px-3 py-1 text-xs font-semibold capitalize {{ $badge }}">
{{ $sale->status }}
</span>
</div>
@@ -45,6 +53,22 @@
<a href="{{ $invoiceUrl }}" class="btn-primary inline-flex w-full justify-center">Create invoice</a>
</div>
@endif
@unless ($sale->isPaid())
<div class="mt-6 flex flex-wrap gap-2 border-t border-slate-100 pt-4">
@if ($sale->status === 'pending')
<form method="post" action="{{ route('pos.sales.cancel', $sale) }}" onsubmit="return confirm('Cancel this sale?');">
@csrf
<button class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-100">Cancel sale</button>
</form>
@endif
<form method="post" action="{{ route('pos.sales.destroy', $sale) }}" onsubmit="return confirm('Delete this sale permanently?');">
@csrf
@method('DELETE')
<button class="rounded-xl border border-red-200 bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition hover:bg-red-100">Delete sale</button>
</form>
</div>
@endunless
</div>
</div>
</x-app-layout>
+2
View File
@@ -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');
+32
View File
@@ -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();