Bagian 5 dari seri

Membuat Transaksi Penjualan Sistem Kasir Laravel

Diterbitkan 15 September 2026

Buat controller:

php artisan make:controller SaleController

Route:

Route::middleware('auth')->group(function () {
    Route::get('sales', [SaleController::class, 'index'])->name('sales.index');
    Route::get('sales/create', [SaleController::class, 'create'])->name('sales.create');
    Route::post('sales', [SaleController::class, 'store'])->name('sales.store');
    Route::get('sales/{sale}', [SaleController::class, 'show'])->name('sales.show');
    Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->name('sales.cancel');
});

Validasi item transaksi:

$data = $request->validate([
    'customer_id' => ['nullable', 'exists:customers,id'],
    'paid_amount' => ['required', 'numeric', 'min:0'],
    'discount' => ['nullable', 'numeric', 'min:0'],
    'notes' => ['nullable', 'string', 'max:1000'],
    'items' => ['required', 'array', 'min:1'],
    'items.*.product_id' => ['required', 'distinct', 'exists:products,id'],
    'items.*.quantity' => ['required', 'integer', 'min:1'],
]);

Simpan memakai transaksi database dan kunci baris stok:

$sale = DB::transaction(function () use ($data) {
    $sale = Sale::create([
        'invoice_number' => 'INV-'.now()->format('YmdHis').'-'.random_int(100, 999),
        'user_id' => auth()->id(),
        'customer_id' => $data['customer_id'] ?? null,
        'sale_date' => now(), 'subtotal' => 0, 'discount' => $data['discount'] ?? 0,
        'grand_total' => 0, 'paid_amount' => $data['paid_amount'],
        'change_amount' => 0, 'status' => 'paid', 'notes' => $data['notes'] ?? null,
    ]);

    $subtotal = 0;
    foreach ($data['items'] as $item) {
        $product = Product::lockForUpdate()->findOrFail($item['product_id']);
        if (!$product->is_active || $product->stock < $item['quantity']) {
            throw ValidationException::withMessages(['items' => "Stok {$product->name} tidak mencukupi."]);
        }
        $lineTotal = $product->selling_price * $item['quantity'];
        $sale->details()->create([
            'product_id' => $product->id, 'quantity' => $item['quantity'],
            'price' => $product->selling_price, 'discount' => 0, 'subtotal' => $lineTotal,
        ]);
        $product->decrement('stock', $item['quantity']);
        $product->stockMovements()->create([
            'type' => 'sale', 'quantity_out' => $item['quantity'],
            'reference_type' => Sale::class, 'reference_id' => $sale->id,
        ]);
        $subtotal += $lineTotal;
    }

    $total = max(0, $subtotal - $sale->discount);
    if ($sale->paid_amount < $total) {
        throw ValidationException::withMessages(['paid_amount' => 'Pembayaran belum mencukupi.']);
    }
    $sale->update(['subtotal' => $subtotal, 'grand_total' => $total, 'change_amount' => $sale->paid_amount - $total]);
    return $sale;
});

Harga harus selalu dibaca kembali dari database. Jangan mempercayai harga atau total yang dikirim browser.

Halaman kasir berisi pencarian SKU, keranjang, jumlah, subtotal, pembayaran, dan kembalian. Setelah berhasil, arahkan ke sales.show untuk mencetak nota.

Lanjut: Pembelian dan Stok