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:
co-authored by
Claude Opus 4.8
parent
5d8e185223
commit
f64a28cb21
@@ -44,6 +44,32 @@ class SaleController extends Controller
|
|||||||
return view('pos.sales.show', compact('sale', 'invoiceUrl'));
|
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
|
public function callback(Request $request, PosSale $sale): RedirectResponse
|
||||||
{
|
{
|
||||||
$reference = trim((string) $request->query('reference', $sale->payment_reference ?? ''));
|
$reference = trim((string) $request->query('reference', $sale->payment_reference ?? ''));
|
||||||
|
|||||||
@@ -446,6 +446,33 @@ class PosSaleService
|
|||||||
return $sale->fresh('lines');
|
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.
|
* Close a ticket once it's settled: free its table and mark kitchen done.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,15 @@
|
|||||||
<h1 class="text-lg font-semibold text-slate-900">{{ $sale->reference }}</h1>
|
<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>
|
<p class="mt-1 text-sm text-slate-500">{{ $sale->created_at->format('M j, Y g:i A') }}</p>
|
||||||
</div>
|
</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 }}
|
{{ $sale->status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,6 +53,22 @@
|
|||||||
<a href="{{ $invoiceUrl }}" class="btn-primary inline-flex w-full justify-center">Create invoice</a>
|
<a href="{{ $invoiceUrl }}" class="btn-primary inline-flex w-full justify-center">Create invoice</a>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
|
|
||||||
Route::get('/sales', [SaleController::class, 'index'])->name('pos.sales.index');
|
Route::get('/sales', [SaleController::class, 'index'])->name('pos.sales.index');
|
||||||
Route::get('/sales/{sale}', [SaleController::class, 'show'])->name('pos.sales.show');
|
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.
|
// Restaurant mode — floor, open tickets (tabs), and the kitchen display.
|
||||||
Route::get('/floor', [TableController::class, 'index'])->name('pos.floor');
|
Route::get('/floor', [TableController::class, 'index'])->name('pos.floor');
|
||||||
|
|||||||
@@ -268,6 +268,38 @@ class PosRestaurantTest extends TestCase
|
|||||||
$this->assertSame('retail', PosLocation::owned($user->public_id)->first()->service_style);
|
$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
|
public function test_cannot_open_two_tabs_on_one_table(): void
|
||||||
{
|
{
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|||||||
Reference in New Issue
Block a user