Bagian 6 dari seri

Membuat Transaksi Penjualan dan POS Laravel

Diterbitkan 15 September 2026

Pada tahap keenam kita akan membuat fitur utama dari aplikasi, yaitu transaksi penjualan atau Point of Sale (POS).

Tahap ini berbeda dari CRUD biasa karena satu transaksi menyentuh beberapa tabel sekaligus.

Ketika transaksi berhasil, aplikasi harus:

membuat header penjualan
membuat detail penjualan
mengambil harga produk dari database
memeriksa stok terbaru
mengurangi stok produk
mencatat stock movement
menghitung subtotal
menghitung diskon
menghitung grand total
memeriksa jumlah pembayaran
menghitung kembalian

Seluruh proses tersebut harus berhasil sebagai satu kesatuan.

Jika salah satu proses gagal, transaksi tidak boleh tersimpan setengah.

Karena itu kita akan menggunakan:

DB::transaction()

dan:

lockForUpdate()

Harga, subtotal, grand total, dan kembalian juga tidak dipercayai dari browser.

Browser hanya mengirim data yang memang perlu dimasukkan pengguna seperti:

product_id
quantity
discount
paid_amount
customer_id
notes

Harga produk akan diambil ulang dari database pada server.

Setelah tahap ini selesai, aplikasi sudah dapat digunakan untuk melakukan transaksi penjualan dasar.

Hasil yang Akan Dibuat

Pada tahap ini kita akan membuat:

SaleController

Route:

/sales
/sales/create
/sales/{sale}
/sales/{sale}/cancel

View:

resources/views/sales/index.blade.php
resources/views/sales/create.blade.php
resources/views/sales/show.blade.php

Fitur yang tersedia:

  • daftar transaksi;
  • halaman POS;
  • pencarian produk;
  • keranjang belanja;
  • tambah dan kurangi quantity;
  • hapus item dari keranjang;
  • pilih pelanggan;
  • diskon transaksi;
  • pembayaran;
  • perhitungan kembalian;
  • validasi server;
  • pengecekan stok;
  • pengurangan stok;
  • pencatatan stock movement;
  • detail transaksi;
  • pembatalan transaksi;
  • pengembalian stok saat transaksi dibatalkan;
  • proteksi pembatalan ganda.

Persiapan

Pastikan Tahap 01 sampai Tahap 05 sudah selesai.

Aplikasi harus sudah mempunyai:

users
customers
products
sales
sale_details
stock_movements

Produk juga harus mempunyai stok.

Produk dari seeder Tahap 03 sudah memiliki stok awal.

Jalankan aplikasi:

php artisan serve

Login sebagai:

admin@example.com
admin12345

atau:

kasir@example.com
kasir12345

1. Alur Transaksi Penjualan

Alur yang akan kita buat:

Kasir membuka POS
        ↓
Memilih produk
        ↓
Menentukan quantity
        ↓
Keranjang menghitung estimasi subtotal
        ↓
Kasir memasukkan diskon
        ↓
Kasir memasukkan uang pembayaran
        ↓
Form dikirim ke server
        ↓
Server memvalidasi request
        ↓
Server memuat ulang produk dari database
        ↓
Server mengunci baris produk
        ↓
Server memeriksa stok
        ↓
Server menghitung harga dan total kembali
        ↓
Server membuat sale
        ↓
Server membuat sale_details
        ↓
Server mengurangi products.stock
        ↓
Server membuat stock_movements
        ↓
Server menghitung kembalian
        ↓
Commit
        ↓
Tampilkan detail transaksi

JavaScript pada halaman POS hanya digunakan untuk membantu antarmuka.

Keputusan akhir tetap dilakukan server.

2. Mengapa Harga Tidak Dikirim sebagai Sumber Kebenaran?

Pengguna dapat mengubah HTML atau request melalui browser developer tools.

Misalnya harga asli:

Rp10.000

Jika browser mengirim:

price = 100

dan server mempercayainya, transaksi dapat dimanipulasi.

Karena itu server hanya menerima:

product_id
quantity

Kemudian harga dibaca:

$product->selling_price

dari database.

3. Mengapa Perlu DB::transaction()?

Satu penjualan dapat menghasilkan:

1 record sales
3 record sale_details
3 perubahan products.stock
3 record stock_movements

Jika detail kedua gagal, kita tidak ingin:

header sudah tersimpan
stok sebagian sudah berkurang
detail belum lengkap

Dengan database transaction, seluruh perubahan dapat dibatalkan ketika terjadi exception.

4. Mengapa Perlu lockForUpdate()?

Bayangkan stok produk:

1

Dua kasir melakukan transaksi pada waktu hampir bersamaan.

Tanpa lock, keduanya dapat membaca:

stock = 1

dan keduanya menganggap stok cukup.

Dengan:

lockForUpdate()

baris produk dikunci selama transaksi database berjalan.

Transaksi lain harus menunggu sampai lock dilepas.

Setelah transaksi pertama mengurangi stok, transaksi kedua akan membaca stok terbaru.

5. Membuat SaleController

Jalankan:

php artisan make:controller SaleController

File dibuat pada:

app/Http/Controllers/SaleController.php

6. Menambahkan Route Penjualan

Buka:

routes/web.php

Tambahkan import:

use App\Http\Controllers\SaleController;

Tambahkan 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::middleware(['auth', 'role:admin'])->group(function () {
    Route::post(
        'sales/{sale}/cancel',
        [SaleController::class, 'cancel']
    )->name('sales.cancel');
});

Dengan struktur ini:

admin
kasir

dapat membuat transaksi dan melihat transaksi.

Pembatalan transaksi dibatasi untuk:

admin

Agar lebih aman, kasir tidak dapat membatalkan transaksi sendiri.

7. Menambahkan Menu Penjualan

Pada layout:

resources/views/layouts/app.blade.php

tambahkan:

<li class="nav-item">
    <a
        class="nav-link"
        href="{{ route('sales.index') }}">
        Penjualan
    </a>
</li>

Tambahkan juga tombol cepat:

<a
    href="{{ route('sales.create') }}"
    class="btn btn-primary">
    Transaksi Baru
</a>

8. Import yang Dibutuhkan SaleController

SaleController akan menggunakan:

use App\Models\Customer;
use App\Models\Product;
use App\Models\Sale;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;

9. SaleController Lengkap

Buka:

app/Http/Controllers/SaleController.php

Isi:

<?php

namespace App\Http\Controllers;

use App\Models\Customer;
use App\Models\Product;
use App\Models\Sale;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;

class SaleController extends Controller
{
    public function index(Request $request): View
    {
        $search = trim((string) $request->get('q'));
        $status = $request->get('status');

        $sales = Sale::query()
            ->with(['user', 'customer'])
            ->when($search !== '', function ($query) use ($search) {
                $query->where(function ($query) use ($search) {
                    $query
                        ->where(
                            'invoice_number',
                            'like',
                            '%' . $search . '%'
                        )
                        ->orWhereHas(
                            'customer',
                            function ($query) use ($search) {
                                $query->where(
                                    'name',
                                    'like',
                                    '%' . $search . '%'
                                );
                            }
                        );
                });
            })
            ->when(
                in_array($status, ['paid', 'cancelled'], true),
                fn ($query) => $query->where('status', $status)
            )
            ->latest('sale_date')
            ->paginate(15)
            ->withQueryString();

        return view('sales.index', compact(
            'sales',
            'search',
            'status'
        ));
    }

    public function create(): View
    {
        $products = Product::query()
            ->with(['category', 'unit'])
            ->where('is_active', true)
            ->where('stock', '>', 0)
            ->orderBy('name')
            ->get([
                'id',
                'category_id',
                'unit_id',
                'sku',
                'name',
                'selling_price',
                'stock',
                'minimum_stock',
            ]);

        $customers = Customer::query()
            ->orderBy('name')
            ->get([
                'id',
                'name',
                'phone',
            ]);

        return view('sales.create', compact(
            'products',
            'customers'
        ));
    }

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

        $items = collect($validated['items'])
            ->groupBy('product_id')
            ->map(function ($rows, $productId) {
                return [
                    'product_id' => (int) $productId,
                    'quantity' => (int) $rows->sum('quantity'),
                ];
            })
            ->values();

        $discount = (float) ($validated['discount'] ?? 0);
        $paidAmount = (float) $validated['paid_amount'];

        $sale = DB::transaction(function () use (
            $request,
            $validated,
            $items,
            $discount,
            $paidAmount
        ) {
            $lockedProducts = [];

            $subtotal = 0;

            foreach ($items as $item) {
                $product = Product::query()
                    ->whereKey($item['product_id'])
                    ->lockForUpdate()
                    ->firstOrFail();

                if (! $product->is_active) {
                    throw ValidationException::withMessages([
                        'items' => "Produk {$product->name} sedang tidak aktif.",
                    ]);
                }

                if ($product->stock < $item['quantity']) {
                    throw ValidationException::withMessages([
                        'items' => "Stok {$product->name} tidak mencukupi. Stok tersedia: {$product->stock}.",
                    ]);
                }

                $price = (float) $product->selling_price;
                $lineSubtotal = $price * $item['quantity'];

                $subtotal += $lineSubtotal;

                $lockedProducts[] = [
                    'model' => $product,
                    'quantity' => $item['quantity'],
                    'price' => $price,
                    'subtotal' => $lineSubtotal,
                ];
            }

            if ($discount > $subtotal) {
                throw ValidationException::withMessages([
                    'discount' => 'Diskon tidak boleh lebih besar dari subtotal.',
                ]);
            }

            $grandTotal = $subtotal - $discount;

            if ($paidAmount < $grandTotal) {
                throw ValidationException::withMessages([
                    'paid_amount' => 'Jumlah pembayaran masih kurang.',
                ]);
            }

            $changeAmount = $paidAmount - $grandTotal;

            $sale = Sale::create([
                'invoice_number' => 'TMP-' . Str::uuid(),
                'user_id' => $request->user()->id,
                'customer_id' => $validated['customer_id'] ?? null,
                'sale_date' => now(),
                'subtotal' => $subtotal,
                'discount' => $discount,
                'grand_total' => $grandTotal,
                'paid_amount' => $paidAmount,
                'change_amount' => $changeAmount,
                'status' => 'paid',
                'notes' => $validated['notes'] ?? null,
            ]);

            $sale->update([
                'invoice_number' => 'INV-'
                    . now()->format('Ymd')
                    . '-'
                    . str_pad(
                        (string) $sale->id,
                        6,
                        '0',
                        STR_PAD_LEFT
                    ),
            ]);

            foreach ($lockedProducts as $item) {
                $product = $item['model'];

                $sale->details()->create([
                    'product_id' => $product->id,
                    'quantity' => $item['quantity'],
                    'price' => $item['price'],
                    'discount' => 0,
                    'subtotal' => $item['subtotal'],
                ]);

                $product->decrement(
                    'stock',
                    $item['quantity']
                );

                $product->stockMovements()->create([
                    'type' => 'sale',
                    'quantity_in' => 0,
                    'quantity_out' => $item['quantity'],
                    'reference_type' => 'sale',
                    'reference_id' => $sale->id,
                    'notes' => 'Penjualan '
                        . $sale->invoice_number,
                ]);
            }

            return $sale;
        });

        return redirect()
            ->route('sales.show', $sale)
            ->with('success', 'Transaksi penjualan berhasil disimpan.');
    }

    public function show(Sale $sale): View
    {
        $sale->load([
            'user',
            'customer',
            'details.product.unit',
        ]);

        return view('sales.show', compact('sale'));
    }

    public function cancel(
        Request $request,
        Sale $sale
    ): RedirectResponse {
        $validated = $request->validate([
            'cancel_notes' => [
                'nullable',
                'string',
                'max:500',
            ],
        ]);

        DB::transaction(function () use (
            $sale,
            $validated
        ) {
            $lockedSale = Sale::query()
                ->whereKey($sale->id)
                ->lockForUpdate()
                ->firstOrFail();

            if ($lockedSale->status === 'cancelled') {
                throw ValidationException::withMessages([
                    'cancel' => 'Transaksi ini sudah dibatalkan sebelumnya.',
                ]);
            }

            if ($lockedSale->status !== 'paid') {
                throw ValidationException::withMessages([
                    'cancel' => 'Hanya transaksi berstatus paid yang dapat dibatalkan.',
                ]);
            }

            $lockedSale->load('details');

            foreach ($lockedSale->details as $detail) {
                $product = Product::query()
                    ->whereKey($detail->product_id)
                    ->lockForUpdate()
                    ->firstOrFail();

                $product->increment(
                    'stock',
                    $detail->quantity
                );

                $product->stockMovements()->create([
                    'type' => 'sale_cancelled',
                    'quantity_in' => $detail->quantity,
                    'quantity_out' => 0,
                    'reference_type' => 'sale',
                    'reference_id' => $lockedSale->id,
                    'notes' => 'Pembatalan penjualan '
                        . $lockedSale->invoice_number,
                ]);
            }

            $notes = trim(
                (string) ($validated['cancel_notes'] ?? '')
            );

            $currentNotes = trim(
                (string) $lockedSale->notes
            );

            $cancelText = $notes !== ''
                ? 'Pembatalan: ' . $notes
                : 'Transaksi dibatalkan oleh admin.';

            $lockedSale->update([
                'status' => 'cancelled',
                'notes' => trim(
                    $currentNotes
                    . ($currentNotes !== '' ? PHP_EOL : '')
                    . $cancelText
                ),
            ]);
        });

        return redirect()
            ->route('sales.show', $sale)
            ->with('success', 'Transaksi berhasil dibatalkan dan stok telah dikembalikan.');
    }
}

10. Mengapa Item Dikelompokkan Berdasarkan product_id?

Browser seharusnya hanya mempunyai satu baris per produk.

Namun request dapat dimanipulasi.

Contoh:

product_id = 1, quantity = 2
product_id = 1, quantity = 3

Kita menggabungkannya:

collect($validated['items'])
    ->groupBy('product_id')

Hasil akhir:

product_id = 1
quantity = 5

Dengan demikian stok diperiksa sekali berdasarkan total quantity produk.

11. Mengunci Produk Sebelum Menghitung

Setiap produk diambil menggunakan:

Product::query()
    ->whereKey($item['product_id'])
    ->lockForUpdate()
    ->firstOrFail();

Lock hanya efektif di dalam database transaction.

Karena seluruh logic berada pada:

DB::transaction(...)

produk tetap terkunci sampai transaksi selesai atau rollback.

12. Memeriksa Status Produk di Server

Walaupun halaman POS hanya menampilkan produk aktif, request tetap dapat dimanipulasi.

Karena itu server memeriksa:

if (! $product->is_active)

Jika produk tidak aktif, transaksi ditolak.

13. Memeriksa Stok di Server

Server memeriksa:

if ($product->stock < $item['quantity'])

Halaman POS mungkin menampilkan stok 10.

Namun sebelum tombol simpan ditekan, kasir lain dapat menjual sebagian stok.

Karena itu pengecekan stok terbaru harus dilakukan di dalam transaction setelah row lock diperoleh.

14. Mengambil Harga dari Database

Harga:

$price = (float) $product->selling_price;

bukan berasal dari input browser.

Subtotal item:

$lineSubtotal = $price * $item['quantity'];

Subtotal transaksi:

$subtotal += $lineSubtotal;

15. Memvalidasi Diskon

Kita menerima diskon transaksi:

discount

Kemudian memastikan:

if ($discount > $subtotal)

Diskon tidak boleh membuat total negatif.

Grand total:

$grandTotal = $subtotal - $discount;

16. Memvalidasi Pembayaran

Jumlah yang dibayar harus:

>= grand_total

Pemeriksaan:

if ($paidAmount < $grandTotal)

Jika kurang, transaksi ditolak.

17. Menghitung Kembalian

Kembalian:

$changeAmount = $paidAmount - $grandTotal;

Contoh:

subtotal    = 50.000
discount    = 5.000
grand total = 45.000
dibayar     = 50.000
kembalian   = 5.000

18. Membuat Nomor Invoice

Pertama kita membuat nilai sementara:

'TMP-' . Str::uuid()

Setelah record sale memiliki ID, invoice diperbarui:

INV-YYYYMMDD-000001

Contoh:

INV-20260916-000025

Bagian belakang menggunakan ID sale sehingga unik pada database.

19. Membuat Sale Detail

Detail dibuat melalui relasi:

$sale->details()->create([
    // ...
]);

Relasi otomatis mengisi:

sale_id

Setiap detail menyimpan snapshot:

product_id
quantity
price
discount
subtotal

20. Mengurangi Stok

Setelah detail berhasil dibuat:

$product->decrement(
    'stock',
    $item['quantity']
);

Contoh:

stok awal = 20
terjual   = 3
stok akhir = 17

21. Mencatat Stock Movement

Setiap stok keluar dicatat:

$product->stockMovements()->create([
    'type' => 'sale',
    'quantity_in' => 0,
    'quantity_out' => $item['quantity'],
    'reference_type' => 'sale',
    'reference_id' => $sale->id,
]);

Dengan demikian kita dapat mengetahui stok berkurang karena transaksi penjualan tertentu.

22. Membuat Folder View Penjualan

Buat:

resources/views/sales

Kemudian:

index.blade.php
create.blade.php
show.blade.php

23. View Index Penjualan

Buat:

resources/views/sales/index.blade.php

Isi:

@extends('layouts.app')

@section('title', 'Penjualan')

@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
    <div>
        <h1 class="h4 mb-1">Penjualan</h1>
        <p class="text-muted mb-0">
            Daftar transaksi penjualan.
        </p>
    </div>

    <a
        href="{{ route('sales.create') }}"
        class="btn btn-primary">
        Transaksi Baru
    </a>
</div>

<div class="card mb-3">
    <div class="card-body">
        <form
            method="GET"
            action="{{ route('sales.index') }}"
            class="row g-2">

            <div class="col-md-6">
                <input
                    type="text"
                    name="q"
                    value="{{ $search }}"
                    class="form-control"
                    placeholder="Cari invoice atau pelanggan...">
            </div>

            <div class="col-md-3">
                <select
                    name="status"
                    class="form-select">

                    <option value="">
                        Semua Status
                    </option>

                    <option
                        value="paid"
                        @selected($status === 'paid')}>
                        Dibayar
                    </option>

                    <option
                        value="cancelled"
                        @selected($status === 'cancelled')}>
                        Dibatalkan
                    </option>
                </select>
            </div>

            <div class="col-md-3">
                <div class="d-flex gap-2">
                    <button
                        class="btn btn-outline-primary">
                        Filter
                    </button>

                    <a
                        href="{{ route('sales.index') }}"
                        class="btn btn-outline-secondary">
                        Reset
                    </a>
                </div>
            </div>
        </form>
    </div>
</div>

<div class="card">
    <div class="card-body">
        <div class="table-responsive">
            <table class="table table-bordered align-middle">
                <thead>
                    <tr>
                        <th>No</th>
                        <th>Invoice</th>
                        <th>Tanggal</th>
                        <th>Kasir</th>
                        <th>Pelanggan</th>
                        <th class="text-end">Total</th>
                        <th>Status</th>
                        <th>Aksi</th>
                    </tr>
                </thead>

                <tbody>
                    @forelse($sales as $sale)
                        <tr>
                            <td>
                                {{ $sales->firstItem() + $loop->index }}
                            </td>

                            <td>
                                <code>{{ $sale->invoice_number }}</code>
                            </td>

                            <td>
                                {{ $sale->sale_date->format('d/m/Y H:i') }}
                            </td>

                            <td>
                                {{ $sale->user?->name ?? '-' }}
                            </td>

                            <td>
                                {{ $sale->customer?->name ?? 'Umum' }}
                            </td>

                            <td class="text-end">
                                Rp {{ number_format(
                                    (float) $sale->grand_total,
                                    0,
                                    ',',
                                    '.'
                                ) }}
                            </td>

                            <td>
                                @if($sale->status === 'paid')
                                    <span class="badge text-bg-success">
                                        Dibayar
                                    </span>
                                @else
                                    <span class="badge text-bg-danger">
                                        Dibatalkan
                                    </span>
                                @endif
                            </td>

                            <td>
                                <a
                                    href="{{ route('sales.show', $sale) }}"
                                    class="btn btn-sm btn-outline-primary">
                                    Detail
                                </a>
                            </td>
                        </tr>
                    @empty
                        <tr>
                            <td
                                colspan="8"
                                class="text-center text-muted">
                                Belum ada transaksi penjualan.
                            </td>
                        </tr>
                    @endforelse
                </tbody>
            </table>
        </div>

        {{ $sales->links() }}
    </div>
</div>
@endsection

24. View Create POS

Buat:

resources/views/sales/create.blade.php

Isi:

@extends('layouts.app')

@section('title', 'Transaksi Penjualan')

@section('content')
<div class="mb-3">
    <h1 class="h4 mb-1">Transaksi Penjualan</h1>
    <p class="text-muted mb-0">
        Pilih produk, tentukan jumlah, lalu selesaikan pembayaran.
    </p>
</div>

@if($errors->any())
    <div class="alert alert-danger">
        <ul class="mb-0">
            @foreach($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif

<form
    method="POST"
    action="{{ route('sales.store') }}"
    id="sale-form">

    @csrf

    <div class="row g-3">
        <div class="col-xl-7">
            <div class="card mb-3">
                <div class="card-header">
                    Pilih Produk
                </div>

                <div class="card-body">
                    <input
                        type="text"
                        id="product-search"
                        class="form-control mb-3"
                        placeholder="Cari SKU atau nama produk...">

                    <div
                        id="product-list"
                        class="row g-2">
                    </div>
                </div>
            </div>
        </div>

        <div class="col-xl-5">
            <div class="card mb-3">
                <div class="card-header">
                    Keranjang
                </div>

                <div class="card-body">
                    <div
                        id="cart-empty"
                        class="text-muted text-center py-4">
                        Keranjang masih kosong.
                    </div>

                    <div
                        id="cart-list"
                        class="d-none">
                    </div>
                </div>
            </div>

            <div class="card">
                <div class="card-body">
                    <div class="mb-3">
                        <label class="form-label">
                            Pelanggan
                        </label>

                        <select
                            name="customer_id"
                            class="form-select">

                            <option value="">
                                Pelanggan Umum
                            </option>

                            @foreach($customers as $customer)
                                <option
                                    value="{{ $customer->id }}"
                                    @selected(
                                        (string) old('customer_id')
                                        ===
                                        (string) $customer->id
                                    )>
                                    {{ $customer->name }}
                                    @if($customer->phone)
                                        - {{ $customer->phone }}
                                    @endif
                                </option>
                            @endforeach
                        </select>
                    </div>

                    <div class="mb-3">
                        <label class="form-label">
                            Diskon Transaksi
                        </label>

                        <input
                            type="number"
                            name="discount"
                            id="discount"
                            value="{{ old('discount', 0) }}"
                            min="0"
                            step="0.01"
                            class="form-control">
                    </div>

                    <div class="border rounded p-3 mb-3">
                        <div class="d-flex justify-content-between mb-2">
                            <span>Subtotal</span>
                            <strong id="subtotal-text">
                                Rp 0
                            </strong>
                        </div>

                        <div class="d-flex justify-content-between">
                            <span>Grand Total</span>
                            <strong id="grand-total-text">
                                Rp 0
                            </strong>
                        </div>
                    </div>

                    <div class="mb-3">
                        <label class="form-label">
                            Jumlah Dibayar
                        </label>

                        <input
                            type="number"
                            name="paid_amount"
                            id="paid-amount"
                            value="{{ old('paid_amount', 0) }}"
                            min="0"
                            step="0.01"
                            class="form-control"
                            required>
                    </div>

                    <div class="mb-3">
                        <label class="form-label">
                            Estimasi Kembalian
                        </label>

                        <input
                            type="text"
                            id="change-text"
                            value="Rp 0"
                            class="form-control"
                            disabled>
                    </div>

                    <div class="mb-3">
                        <label class="form-label">
                            Catatan
                        </label>

                        <textarea
                            name="notes"
                            rows="3"
                            class="form-control"
                            maxlength="1000">{{ old('notes') }}</textarea>
                    </div>

                    <div id="hidden-items"></div>

                    <button
                        type="submit"
                        id="submit-sale"
                        class="btn btn-success w-100"
                        disabled>
                        Simpan Transaksi
                    </button>
                </div>
            </div>
        </div>
    </div>
</form>

<script>
const products = @json(
    $products->map(function ($product) {
        return [
            'id' => $product->id,
            'sku' => $product->sku,
            'name' => $product->name,
            'price' => (float) $product->selling_price,
            'stock' => $product->stock,
            'unit' => $product->unit?->symbol,
            'category' => $product->category?->name,
        ];
    })->values()
);

const cart = new Map();

const productSearch = document.getElementById('product-search');
const productList = document.getElementById('product-list');
const cartList = document.getElementById('cart-list');
const cartEmpty = document.getElementById('cart-empty');
const hiddenItems = document.getElementById('hidden-items');
const discountInput = document.getElementById('discount');
const paidAmountInput = document.getElementById('paid-amount');
const subtotalText = document.getElementById('subtotal-text');
const grandTotalText = document.getElementById('grand-total-text');
const changeText = document.getElementById('change-text');
const submitSaleButton = document.getElementById('submit-sale');

function rupiah(value) {
    return new Intl.NumberFormat('id-ID', {
        style: 'currency',
        currency: 'IDR',
        maximumFractionDigits: 0,
    }).format(value || 0);
}

function renderProducts(keyword = '') {
    const search = keyword.trim().toLowerCase();

    const filtered = products.filter(product => {
        return product.name.toLowerCase().includes(search)
            || product.sku.toLowerCase().includes(search);
    });

    productList.innerHTML = '';

    if (filtered.length === 0) {
        productList.innerHTML = `
            <div class="col-12">
                <div class="text-muted">
                    Produk tidak ditemukan.
                </div>
            </div>
        `;

        return;
    }

    filtered.forEach(product => {
        const col = document.createElement('div');
        col.className = 'col-md-6';

        col.innerHTML = `
            <button
                type="button"
                class="btn btn-outline-secondary text-start w-100 h-100"
                data-product-id="${product.id}">
                <div class="fw-semibold">
                    ${escapeHtml(product.name)}
                </div>
                <div class="small text-muted">
                    ${escapeHtml(product.sku)}
                    ${product.category ? ' • ' + escapeHtml(product.category) : ''}
                </div>
                <div class="mt-2">
                    ${rupiah(product.price)}
                </div>
                <div class="small">
                    Stok: ${product.stock}
                    ${product.unit ? escapeHtml(product.unit) : ''}
                </div>
            </button>
        `;

        col.querySelector('button').addEventListener('click', () => {
            addToCart(product.id);
        });

        productList.appendChild(col);
    });
}

function addToCart(productId) {
    const product = products.find(
        item => item.id === productId
    );

    if (! product) {
        return;
    }

    const existing = cart.get(productId);

    if (existing) {
        if (existing.quantity >= product.stock) {
            alert('Quantity tidak dapat melebihi stok.');
            return;
        }

        existing.quantity++;
    } else {
        cart.set(productId, {
            product,
            quantity: 1,
        });
    }

    renderCart();
}

function updateQuantity(productId, quantity) {
    const item = cart.get(productId);

    if (! item) {
        return;
    }

    let nextQuantity = Number(quantity);

    if (! Number.isInteger(nextQuantity)) {
        nextQuantity = 1;
    }

    if (nextQuantity < 1) {
        nextQuantity = 1;
    }

    if (nextQuantity > item.product.stock) {
        nextQuantity = item.product.stock;
        alert('Quantity disesuaikan dengan stok tersedia.');
    }

    item.quantity = nextQuantity;

    renderCart();
}

function removeFromCart(productId) {
    cart.delete(productId);
    renderCart();
}

function getSubtotal() {
    let subtotal = 0;

    cart.forEach(item => {
        subtotal += item.product.price * item.quantity;
    });

    return subtotal;
}

function getDiscount() {
    const value = Number(discountInput.value || 0);

    if (value < 0) {
        return 0;
    }

    return value;
}

function renderCart() {
    cartList.innerHTML = '';
    hiddenItems.innerHTML = '';

    if (cart.size === 0) {
        cartEmpty.classList.remove('d-none');
        cartList.classList.add('d-none');
        submitSaleButton.disabled = true;
    } else {
        cartEmpty.classList.add('d-none');
        cartList.classList.remove('d-none');
        submitSaleButton.disabled = false;
    }

    let index = 0;

    cart.forEach((item, productId) => {
        const row = document.createElement('div');

        row.className = 'border rounded p-2 mb-2';

        row.innerHTML = `
            <div class="d-flex justify-content-between gap-2">
                <div>
                    <div class="fw-semibold">
                        ${escapeHtml(item.product.name)}
                    </div>
                    <div class="small text-muted">
                        ${rupiah(item.product.price)}
                    </div>
                </div>

                <button
                    type="button"
                    class="btn btn-sm btn-outline-danger remove-item">
                    Hapus
                </button>
            </div>

            <div class="row g-2 mt-1 align-items-end">
                <div class="col-5">
                    <label class="form-label small">
                        Qty
                    </label>

                    <input
                        type="number"
                        class="form-control form-control-sm quantity-input"
                        min="1"
                        max="${item.product.stock}"
                        value="${item.quantity}">
                </div>

                <div class="col-7 text-end">
                    <div class="small text-muted">
                        Subtotal
                    </div>

                    <strong>
                        ${rupiah(
                            item.product.price * item.quantity
                        )}
                    </strong>
                </div>
            </div>
        `;

        row.querySelector('.remove-item')
            .addEventListener('click', () => {
                removeFromCart(productId);
            });

        row.querySelector('.quantity-input')
            .addEventListener('change', event => {
                updateQuantity(
                    productId,
                    Number(event.target.value)
                );
            });

        cartList.appendChild(row);

        const productInput = document.createElement('input');
        productInput.type = 'hidden';
        productInput.name = `items[${index}][product_id]`;
        productInput.value = item.product.id;

        const quantityInput = document.createElement('input');
        quantityInput.type = 'hidden';
        quantityInput.name = `items[${index}][quantity]`;
        quantityInput.value = item.quantity;

        hiddenItems.appendChild(productInput);
        hiddenItems.appendChild(quantityInput);

        index++;
    });

    updateSummary();
}

function updateSummary() {
    const subtotal = getSubtotal();
    const discount = Math.min(
        getDiscount(),
        subtotal
    );

    const grandTotal = Math.max(
        subtotal - discount,
        0
    );

    const paidAmount = Number(
        paidAmountInput.value || 0
    );

    const change = Math.max(
        paidAmount - grandTotal,
        0
    );

    subtotalText.textContent = rupiah(subtotal);
    grandTotalText.textContent = rupiah(grandTotal);
    changeText.value = rupiah(change);
}

function escapeHtml(value) {
    return String(value ?? '')
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#039;');
}

productSearch.addEventListener('input', event => {
    renderProducts(event.target.value);
});

discountInput.addEventListener('input', updateSummary);
paidAmountInput.addEventListener('input', updateSummary);

document.getElementById('sale-form')
    .addEventListener('submit', event => {
        if (cart.size === 0) {
            event.preventDefault();
            alert('Keranjang masih kosong.');
            return;
        }

        if (! confirm('Simpan transaksi penjualan ini?')) {
            event.preventDefault();
        }
    });

renderProducts();
renderCart();
</script>
@endsection

25. Fungsi JavaScript pada POS

JavaScript melakukan:

render produk
pencarian produk
menambah item
mengubah quantity
menghapus item
menghitung estimasi subtotal
menghitung estimasi grand total
menghitung estimasi kembalian
membuat hidden input

Namun hasil JavaScript bukan sumber kebenaran final.

Server tetap menghitung ulang seluruh transaksi.

26. Data Produk yang Dikirim ke JavaScript

Kita hanya memasukkan data yang dibutuhkan antarmuka:

id
sku
name
price
stock
unit
category

Data tersebut digunakan untuk display.

Walaupun price tersedia di JavaScript, server tidak menerima harga tersebut sebagai kebenaran.

27. Hidden Input Keranjang

Ketika ada produk di keranjang, JavaScript membuat:

<input
    type="hidden"
    name="items[0][product_id]"
    value="1">

<input
    type="hidden"
    name="items[0][quantity]"
    value="2">

Untuk item kedua:

items[1][product_id]
items[1][quantity]

Laravel menerima:

[
    'items' => [
        [
            'product_id' => 1,
            'quantity' => 2,
        ],
        [
            'product_id' => 2,
            'quantity' => 1,
        ],
    ],
]

28. Estimasi Bukan Total Final

Teks pada browser:

Subtotal
Grand Total
Kembalian

bersifat estimasi untuk kasir.

Nilai final dihitung ulang pada:

SaleController::store()

Jika harga berubah sebelum transaksi disimpan, server memakai harga database terbaru.

29. View Detail Penjualan

Buat:

resources/views/sales/show.blade.php

Isi:

@extends('layouts.app')

@section('title', 'Detail Penjualan')

@section('content')
<div class="d-flex justify-content-between align-items-start mb-3">
    <div>
        <h1 class="h4 mb-1">
            Detail Penjualan
        </h1>

        <div class="text-muted">
            {{ $sale->invoice_number }}
        </div>
    </div>

    <a
        href="{{ route('sales.index') }}"
        class="btn btn-secondary">
        Kembali
    </a>
</div>

@if($errors->has('cancel'))
    <div class="alert alert-danger">
        {{ $errors->first('cancel') }}
    </div>
@endif

<div class="row g-3">
    <div class="col-lg-8">
        <div class="card">
            <div class="card-header">
                Item Transaksi
            </div>

            <div class="card-body">
                <div class="table-responsive">
                    <table class="table table-bordered align-middle mb-0">
                        <thead>
                            <tr>
                                <th>Produk</th>
                                <th class="text-end">Harga</th>
                                <th class="text-center">Qty</th>
                                <th class="text-end">Subtotal</th>
                            </tr>
                        </thead>

                        <tbody>
                            @foreach($sale->details as $detail)
                                <tr>
                                    <td>
                                        {{ $detail->product?->name ?? 'Produk' }}

                                        @if($detail->product?->unit)
                                            <div class="small text-muted">
                                                {{ $detail->product->unit->symbol }}
                                            </div>
                                        @endif
                                    </td>

                                    <td class="text-end">
                                        Rp {{ number_format(
                                            (float) $detail->price,
                                            0,
                                            ',',
                                            '.'
                                        ) }}
                                    </td>

                                    <td class="text-center">
                                        {{ $detail->quantity }}
                                    </td>

                                    <td class="text-end">
                                        Rp {{ number_format(
                                            (float) $detail->subtotal,
                                            0,
                                            ',',
                                            '.'
                                        ) }}
                                    </td>
                                </tr>
                            @endforeach
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>

    <div class="col-lg-4">
        <div class="card mb-3">
            <div class="card-header">
                Informasi Transaksi
            </div>

            <div class="card-body">
                <dl class="row mb-0">
                    <dt class="col-5">Invoice</dt>
                    <dd class="col-7">
                        {{ $sale->invoice_number }}
                    </dd>

                    <dt class="col-5">Tanggal</dt>
                    <dd class="col-7">
                        {{ $sale->sale_date->format('d/m/Y H:i') }}
                    </dd>

                    <dt class="col-5">Kasir</dt>
                    <dd class="col-7">
                        {{ $sale->user?->name ?? '-' }}
                    </dd>

                    <dt class="col-5">Pelanggan</dt>
                    <dd class="col-7">
                        {{ $sale->customer?->name ?? 'Umum' }}
                    </dd>

                    <dt class="col-5">Status</dt>
                    <dd class="col-7">
                        @if($sale->status === 'paid')
                            <span class="badge text-bg-success">
                                Dibayar
                            </span>
                        @else
                            <span class="badge text-bg-danger">
                                Dibatalkan
                            </span>
                        @endif
                    </dd>
                </dl>
            </div>
        </div>

        <div class="card mb-3">
            <div class="card-header">
                Ringkasan Pembayaran
            </div>

            <div class="card-body">
                <div class="d-flex justify-content-between mb-2">
                    <span>Subtotal</span>
                    <span>
                        Rp {{ number_format(
                            (float) $sale->subtotal,
                            0,
                            ',',
                            '.'
                        ) }}
                    </span>
                </div>

                <div class="d-flex justify-content-between mb-2">
                    <span>Diskon</span>
                    <span>
                        Rp {{ number_format(
                            (float) $sale->discount,
                            0,
                            ',',
                            '.'
                        ) }}
                    </span>
                </div>

                <hr>

                <div class="d-flex justify-content-between mb-2">
                    <strong>Grand Total</strong>
                    <strong>
                        Rp {{ number_format(
                            (float) $sale->grand_total,
                            0,
                            ',',
                            '.'
                        ) }}
                    </strong>
                </div>

                <div class="d-flex justify-content-between mb-2">
                    <span>Dibayar</span>
                    <span>
                        Rp {{ number_format(
                            (float) $sale->paid_amount,
                            0,
                            ',',
                            '.'
                        ) }}
                    </span>
                </div>

                <div class="d-flex justify-content-between">
                    <span>Kembalian</span>
                    <span>
                        Rp {{ number_format(
                            (float) $sale->change_amount,
                            0,
                            ',',
                            '.'
                        ) }}
                    </span>
                </div>
            </div>
        </div>

        @if($sale->notes)
            <div class="card mb-3">
                <div class="card-header">
                    Catatan
                </div>

                <div class="card-body">
                    {!! nl2br(e($sale->notes)) !!}
                </div>
            </div>
        @endif

        @if(
            auth()->user()->role === 'admin'
            && $sale->status === 'paid'
        )
            <div class="card border-danger">
                <div class="card-header text-bg-danger">
                    Batalkan Transaksi
                </div>

                <div class="card-body">
                    <p class="small">
                        Pembatalan akan mengembalikan stok setiap produk.
                    </p>

                    <form
                        method="POST"
                        action="{{ route('sales.cancel', $sale) }}"
                        onsubmit="return confirm('Yakin membatalkan transaksi ini? Stok akan dikembalikan.')">

                        @csrf

                        <div class="mb-3">
                            <label class="form-label">
                                Alasan
                            </label>

                            <textarea
                                name="cancel_notes"
                                rows="3"
                                class="form-control"
                                maxlength="500"></textarea>
                        </div>

                        <button
                            type="submit"
                            class="btn btn-danger">
                            Batalkan Transaksi
                        </button>
                    </form>
                </div>
            </div>
        @endif
    </div>
</div>
@endsection

30. Pembatalan Tidak Menghapus Transaksi

Ketika transaksi dibatalkan, kita tidak melakukan:

$sale->delete();

Kita mengubah:

status = cancelled

Tujuannya agar histori tetap tersedia.

Laporan nantinya hanya menghitung transaksi:

status = paid

31. Pembatalan Mengembalikan Stok

Setiap detail transaksi diproses.

Contoh transaksi:

Air Mineral x 2
Kopi Sachet x 3

Saat dibatalkan:

Air Mineral +2
Kopi Sachet +3

Selain mengubah products.stock, sistem membuat stock movement:

type = sale_cancelled
quantity_in = quantity transaksi

32. Proteksi Pembatalan Ganda

Sebelum mengembalikan stok:

if ($lockedSale->status === 'cancelled')

Jika transaksi sudah dibatalkan, request berikutnya ditolak.

Ini penting.

Tanpa proteksi tersebut:

cancel pertama -> stok +2
cancel kedua   -> stok +2 lagi

dan saldo menjadi salah.

33. Mengunci Sale Saat Pembatalan

Pembatalan menggunakan:

lockForUpdate()

pada sale.

Tujuannya agar dua request pembatalan yang datang bersamaan tidak memproses transaksi yang sama dua kali.

34. Mengunci Product Saat Pembatalan

Setiap product juga dikunci:

Product::query()
    ->whereKey(...)
    ->lockForUpdate()

Kemudian stok dikembalikan.

35. Memastikan Database Mendukung Transaction

Untuk MySQL modern, gunakan storage engine yang mendukung transaction seperti:

InnoDB

Laravel/MySQL pada konfigurasi umum menggunakan engine yang sesuai.

Jika database memakai engine tanpa dukungan transaction, perilaku rollback tidak akan sesuai kebutuhan.

36. Pengujian Transaksi Dasar

Sebelum transaksi, periksa stok.

Contoh:

Air Mineral = 20

Buat transaksi:

Air Mineral x 2

Setelah berhasil:

Air Mineral = 18

37. Memeriksa Sale dengan Tinker

Jalankan:

php artisan tinker

Kemudian:

$sale = App\Models\Sale::latest('id')->first();

Cek:

$sale->invoice_number;

Contoh:

INV-20260916-000001

Cek status:

$sale->status;

Hasil:

paid

38. Memeriksa Detail Penjualan

Di Tinker:

$sale->details;

Pastikan jumlah detail sesuai item di keranjang.

Contoh:

$sale->details()->count();

Jika transaksi mempunyai 2 produk:

2

39. Memeriksa Stok Setelah Penjualan

Ambil produk:

$product = App\Models\Product::where(
    'sku',
    'BRG-001'
)->first();

Cek:

$product->stock;

Nilai harus berkurang sesuai penjualan.

40. Memeriksa Stock Movement

Cek:

$product->stockMovements()
    ->where('reference_type', 'sale')
    ->where('reference_id', $sale->id)
    ->get();

Harus ada record:

type = sale
quantity_out > 0

41. Memeriksa Konsistensi Total

Cek:

$detailTotal = $sale->details()->sum('subtotal');

Kemudian:

$detailTotal;

harus sesuai:

$sale->subtotal;

Jika tidak ada diskon detail, subtotal header merupakan jumlah subtotal seluruh detail.

42. Pengujian Pembayaran Kurang

Misalnya:

grand total = 20.000
dibayar = 10.000

Server harus menolak transaksi.

Pesan:

Jumlah pembayaran masih kurang.

Tidak boleh ada:

sale baru
sale detail baru
pengurangan stok
stock movement baru

43. Pengujian Diskon Lebih Besar dari Subtotal

Contoh:

subtotal = 20.000
discount = 30.000

Transaksi harus ditolak.

Grand total tidak boleh negatif.

44. Pengujian Stok Tidak Cukup

Misalnya stok:

3

Kirim request dengan:

quantity = 10

Server harus menolak meskipun browser dimanipulasi.

Pesan menyebut stok tersedia.

45. Pengujian Produk Tidak Aktif

Nonaktifkan produk pada CRUD.

Jika seseorang memanipulasi request untuk mengirim ID produk tersebut, server harus menolak.

46. Pengujian Harga Browser Dimanipulasi

Form kita memang tidak mengirim field:

price

Tetapi pengguna dapat menambahkan field sendiri.

Misalnya:

price = 1

Controller tidak pernah membaca nilai tersebut.

Harga tetap diambil:

$product->selling_price

47. Pengujian Subtotal Browser Dimanipulasi

Jika pengguna mengirim:

subtotal = 1

controller mengabaikannya karena tidak ada pada validation array.

Subtotal dihitung server.

48. Pengujian Grand Total Browser Dimanipulasi

Sama seperti subtotal.

Field:

grand_total

tidak digunakan dari request.

49. Pengujian Duplikat Produk pada Request

Kirim dua item:

product_id = 1, quantity = 2
product_id = 1, quantity = 3

Server menggabungkan menjadi:

quantity = 5

Kemudian stok diperiksa terhadap 5.

50. Pengujian Pembatalan

Ambil satu transaksi paid.

Catat stok produk.

Klik:

Batalkan Transaksi

Setelah berhasil:

status = cancelled

Stok produk harus kembali.

51. Memeriksa Movement Pembatalan

Cek:

$product->stockMovements()
    ->where('type', 'sale_cancelled')
    ->where('reference_id', $sale->id)
    ->get();

Harus ada record stok masuk.

52. Pengujian Pembatalan Kedua Kali

Setelah transaksi sudah cancelled, coba kirim request cancel lagi.

Controller harus menolak.

Stok tidak boleh bertambah lagi.

53. Pengujian Hak Akses Pembatalan

Login sebagai:

kasir@example.com

Kasir dapat:

membuka POS
membuat transaksi
melihat detail

Kasir tidak mendapatkan tombol pembatalan.

Route cancel juga menggunakan:

role:admin

Jadi menyembunyikan tombol saja bukan satu-satunya proteksi.

54. Mengapa Proteksi Harus Ada pada Route?

HTML dapat dimanipulasi.

Walaupun tombol tidak terlihat, pengguna tetap dapat membuat request manual.

Karena itu pembatasan akses harus diterapkan di server:

role:admin

55. Daftar Penjualan dan Cancelled

Transaksi cancelled tetap tampil pada daftar.

Ini disengaja untuk audit sederhana.

Status ditampilkan:

Dibayar
Dibatalkan

56. Laporan Harus Mengabaikan Cancelled

Pada tahap laporan nanti query penjualan akan menggunakan:

->where('status', 'paid')

Transaksi cancelled tidak termasuk pendapatan.

57. Produk Habis Tidak Ditampilkan pada POS

Method create() menggunakan:

->where('stock', '>', 0)

Produk dengan stok 0 tidak masuk daftar.

Namun server tetap memeriksa stok saat penyimpanan karena stok dapat berubah setelah halaman dibuka.

58. Produk Tidak Aktif Tidak Ditampilkan pada POS

Query:

->where('is_active', true)

mencegah produk tidak aktif tampil.

59. Refresh Halaman POS Setelah Transaksi

Setelah transaksi berhasil, user diarahkan ke:

sales.show

Jika ingin membuat transaksi berikutnya, klik:

Transaksi Baru

Halaman baru akan mengambil stok terbaru.

60. Mengapa Tidak Menyimpan Keranjang di Database?

Untuk tutorial dasar ini keranjang hanya hidup pada browser sebelum transaksi disimpan.

Keuntungannya:

  • implementasi lebih sederhana;
  • tidak perlu tabel cart;
  • tidak meninggalkan keranjang kosong;
  • transaksi baru dibuat saat checkout.

Jika aplikasi membutuhkan draft transaksi, hold bill, atau multi-terminal advanced POS, tabel keranjang/draft dapat ditambahkan kemudian.

61. Mengapa Tidak Menggunakan Session Cart?

Session juga dapat digunakan.

Namun untuk tutorial ini keranjang dikelola pada JavaScript karena interaksi quantity lebih cepat dan mudah dipahami secara visual.

Server tetap tidak mempercayai nilai harga dari JavaScript.

62. Perhitungan Uang dan float pada Tutorial

Database menggunakan:

decimal(15,2)

Pada contoh controller, nilai dikonversi menjadi float untuk perhitungan sederhana.

Untuk aplikasi finansial dengan kebutuhan presisi lebih ketat, gunakan representasi integer unit terkecil atau library decimal/money yang sesuai.

Untuk sistem kasir sederhana dengan rupiah tanpa pecahan dalam contoh, pendekatan ini cukup mudah dipahami.

63. Menambahkan Validasi Diskon Integer jika Hanya Rupiah Bulat

Jika bisnis tidak menggunakan pecahan rupiah, aturan:

'numeric'

dapat diubah menjadi:

'integer'

Begitu juga pembayaran.

Namun struktur database tutorial tetap mendukung dua angka desimal.

64. Struktur File Setelah Tahap 06

Controller:

app/Http/Controllers/SaleController.php

Views:

resources/views/sales/
├── create.blade.php
├── index.blade.php
└── show.blade.php

Routes:

sales.index
sales.create
sales.store
sales.show
sales.cancel

65. Checklist Pengujian Tahap 06

Pastikan:

[ ] halaman penjualan dapat dibuka
[ ] POS menampilkan produk aktif
[ ] produk stok 0 tidak tampil
[ ] pencarian produk bekerja
[ ] item dapat masuk keranjang
[ ] quantity dapat diubah
[ ] quantity browser dibatasi stok
[ ] item dapat dihapus dari keranjang
[ ] pelanggan dapat dipilih
[ ] diskon dapat diisi
[ ] estimasi subtotal tampil
[ ] estimasi grand total tampil
[ ] estimasi kembalian tampil

Server:

[ ] items wajib minimal 1
[ ] product_id harus valid
[ ] quantity minimal 1
[ ] produk tidak aktif ditolak
[ ] stok kurang ditolak
[ ] harga diambil dari database
[ ] subtotal dihitung server
[ ] diskon > subtotal ditolak
[ ] pembayaran kurang ditolak
[ ] kembalian dihitung server

Database:

[ ] sale tersimpan
[ ] sale_details tersimpan
[ ] stok berkurang
[ ] stock_movements tercatat
[ ] invoice unik
[ ] sale menyimpan user_id kasir

Pembatalan:

[ ] hanya admin dapat cancel
[ ] status berubah cancelled
[ ] stok dikembalikan
[ ] movement sale_cancelled dibuat
[ ] cancel kedua ditolak

66. Kesalahan Umum: Keranjang Kosong tetapi Request Masuk

Server menggunakan:

'items' => [
    'required',
    'array',
    'min:1',
],

Jadi walaupun JavaScript dilewati, request kosong ditolak.

67. Kesalahan Umum: Harga Dipercaya dari Hidden Input

Jangan membuat sumber kebenaran:

<input name="items[0][price]">

lalu langsung menggunakannya.

Harga selalu ambil dari:

$product->selling_price

68. Kesalahan Umum: Stok Dicek Sebelum Transaction Saja

Pengecekan stok harus berada di dalam:

DB::transaction()

dan setelah:

lockForUpdate()

Jika hanya dicek sebelum transaction, masih ada kemungkinan race condition.

69. Kesalahan Umum: Tidak Mengembalikan Stok Saat Cancel

Mengubah status menjadi cancelled saja tidak cukup.

Jika transaksi awal sudah mengurangi stok, cancel harus melakukan:

stock + quantity

dan membuat histori.

70. Kesalahan Umum: Cancel Menghapus Sale

Jangan:

$sale->delete();

untuk pembatalan biasa.

Gunakan status agar histori transaksi tetap tersedia.

71. Kesalahan Umum: Cancel Bisa Dua Kali

Pastikan status dicek sebelum stok dikembalikan.

Jika tidak, stok dapat bertambah berkali-kali.

72. Kesalahan Umum: Hanya Menyembunyikan Tombol Cancel

UI bukan proteksi keamanan.

Route juga harus dibatasi:

role:admin

73. Kesalahan Umum: Sale Detail Tidak Menyimpan Harga

Harga harus disimpan pada sale_details.

Jika harga produk berubah besok, transaksi lama tetap harus menunjukkan harga saat transaksi dibuat.

74. Kesalahan Umum: Laporan Menghitung Cancelled

Nanti gunakan:

where('status', 'paid')

untuk laporan pendapatan.

75. Checkpoint Hasil

Setelah tahap ini:

Login                         : selesai
Role admin/kasir              : selesai
Database                      : selesai
Model dan relasi              : selesai
Seeder                        : selesai
CRUD master                   : selesai
CRUD produk                   : selesai
POS penjualan                 : selesai
Keranjang                     : selesai
Pembayaran                    : selesai
Kembalian                     : selesai
Pengurangan stok              : selesai
Stock movement penjualan      : selesai
Detail transaksi              : selesai
Pembatalan penjualan          : selesai
Pengembalian stok             : selesai
Pembelian                     : tahap berikutnya
Kartu stok                    : belum
Dashboard/laporan             : belum
Nota                          : belum
Testing/deploy                : belum

76. Ringkasan

Pada tahap keenam kita telah membuat transaksi penjualan lengkap.

Halaman POS dapat:

mencari produk
menambah ke keranjang
mengubah quantity
memilih pelanggan
memberikan diskon
menerima pembayaran
menampilkan estimasi kembalian

Server melakukan ulang seluruh pemeriksaan penting:

produk aktif
stok tersedia
harga database
subtotal
diskon
grand total
pembayaran
kembalian

Penyimpanan menggunakan:

DB::transaction()

dan produk dikunci menggunakan:

lockForUpdate()

Saat transaksi berhasil:

sales dibuat
sale_details dibuat
stok berkurang
stock_movements dibuat

Pembatalan tidak menghapus transaksi.

Status diubah menjadi:

cancelled

dan stok dikembalikan dengan histori:

sale_cancelled

Tahap berikutnya kita akan membuat transaksi pembelian dan penambahan stok.

Pembelian akan menggunakan prinsip yang sama:

database transaction
server-side calculation
stock update
stock movement
detail transaksi

Lanjut: Pembelian dan Stok

Komentar

Belum ada komentar.

Tulis Komentar

Komentar pengunjung diperiksa sebelum ditampilkan.