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.
*/