Tutorial Filament 4 Integrasi Midtrans Payment Web Point of Sales Sederhana

Integrasi payment gateway adalah fitur essential untuk aplikasi e-commerce dan Point of Sales modern. Midtrans adalah salah satu payment gateway terpopuler di Indonesia dengan dukungan berbagai metode pembayaran โ€” dari transfer bank, e-wallet, hingga kartu kredit. Di tutorial ini, kita akan membangun aplikasi POS sederhana dengan Filament 4 dan Laravel, kemudian mengintegrasikan Midtrans Snap untuk pembayaran online. Kamu akan belajar mulai dari setup project, database design, membuat interface kasir, generate Snap token, hingga handling webhook untuk update status pembayaran otomatis.


Bagian 1: Intro & Persiapan

Apa itu Midtrans?

Midtrans adalah payment gateway Indonesia yang menyediakan infrastruktur pembayaran online. Dengan satu integrasi, aplikasi kamu bisa menerima berbagai metode pembayaran:

METODE PEMBAYARAN MIDTRANS:

๐Ÿ’ณ Kartu Kredit/Debit
โ”œโ”€โ”€ Visa, Mastercard, JCB, Amex

๐Ÿฆ Bank Transfer
โ”œโ”€โ”€ BCA, BNI, BRI, Mandiri, Permata
โ””โ”€โ”€ Virtual Account otomatis

๐Ÿ“ฑ E-Wallet
โ”œโ”€โ”€ GoPay, ShopeePay, OVO, DANA, LinkAja

๐Ÿช Convenience Store
โ”œโ”€โ”€ Indomaret, Alfamart

๐Ÿ’ฐ Paylater
โ””โ”€โ”€ Akulaku, Kredivo

Flow Pembayaran Midtrans Snap

Midtrans Snap adalah solusi pembayaran yang menampilkan popup untuk user memilih metode bayar. Ini flow-nya:

SNAP PAYMENT FLOW:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                                                             โ”‚
โ”‚  1. User checkout di aplikasi                               โ”‚
โ”‚                    โ”‚                                        โ”‚
โ”‚                    โ–ผ                                        โ”‚
โ”‚  2. Backend request Snap Token ke Midtrans API              โ”‚
โ”‚                    โ”‚                                        โ”‚
โ”‚                    โ–ผ                                        โ”‚
โ”‚  3. Midtrans return Snap Token                              โ”‚
โ”‚                    โ”‚                                        โ”‚
โ”‚                    โ–ผ                                        โ”‚
โ”‚  4. Frontend tampilkan Snap Popup (pakai token)             โ”‚
โ”‚                    โ”‚                                        โ”‚
โ”‚                    โ–ผ                                        โ”‚
โ”‚  5. User pilih metode bayar & selesaikan pembayaran         โ”‚
โ”‚                    โ”‚                                        โ”‚
โ”‚                    โ–ผ                                        โ”‚
โ”‚  6. Midtrans kirim webhook ke server kita                   โ”‚
โ”‚                    โ”‚                                        โ”‚
โ”‚                    โ–ผ                                        โ”‚
โ”‚  7. Backend update status order berdasarkan webhook         โ”‚
โ”‚                    โ”‚                                        โ”‚
โ”‚                    โ–ผ                                        โ”‚
โ”‚  8. User redirect ke halaman sukses/pending                 โ”‚
โ”‚                                                             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Sandbox vs Production

Midtrans menyediakan dua environment:

ENVIRONMENT:

๐Ÿงช SANDBOX (Testing)
โ”œโ”€โ”€ Gratis, tidak ada biaya
โ”œโ”€โ”€ Transaksi tidak real
โ”œโ”€โ”€ Untuk development & testing
โ”œโ”€โ”€ URL: dashboard.sandbox.midtrans.com
โ””โ”€โ”€ Keys berbeda dari production

๐Ÿš€ PRODUCTION (Live)
โ”œโ”€โ”€ Transaksi real dengan uang asli
โ”œโ”€โ”€ Ada fee per transaksi
โ”œโ”€โ”€ Butuh verifikasi bisnis
โ”œโ”€โ”€ URL: dashboard.midtrans.com
โ””โ”€โ”€ Jangan pakai untuk testing!

Di tutorial ini kita pakai Sandbox untuk testing.

Daftar Akun Midtrans Sandbox

Step 1: Buat Akun

  1. Buka https://dashboard.sandbox.midtrans.com
  2. Klik "Sign Up" atau "Daftar"
  3. Isi form: email, password, nama bisnis
  4. Verifikasi email yang dikirim Midtrans
  5. Login ke dashboard

Step 2: Dapatkan API Keys

Setelah login ke dashboard Sandbox:

  1. Klik menu Settings di sidebar
  2. Pilih Access Keys
  3. Kamu akan lihat 3 keys:
API KEYS:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Merchant ID    : G123456789                            โ”‚
โ”‚ Client Key     : SB-Mid-client-xxxxxxxxxx              โ”‚
โ”‚ Server Key     : SB-Mid-server-xxxxxxxxxx              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โš ๏ธ  PENTING:
โ”œโ”€โ”€ Client Key โ†’ Untuk frontend (boleh public)
โ”œโ”€โ”€ Server Key โ†’ Untuk backend (RAHASIA!)
โ””โ”€โ”€ Jangan pernah expose Server Key di frontend/GitHub!

Copy ketiga keys ini, kita akan pakai di langkah selanjutnya.

Step 3: Setup Notification URL (Nanti)

Notification URL (webhook) akan kita setup setelah aplikasi jadi. Untuk development, kita akan pakai ngrok untuk expose localhost.


Bagian 2: Setup Project

Install Laravel 12

# Buat project baru
composer create-project laravel/laravel pos-midtrans

# Masuk ke folder
cd pos-midtrans

Setup Database

Untuk simple, kita pakai SQLite:

# Edit .env
# Ubah DB_CONNECTION=sqlite
# Hapus/comment DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD

Buat file database:

# Mac/Linux
touch database/database.sqlite

# Windows PowerShell
New-Item database/database.sqlite -ItemType File

Install Filament 4

# Install Filament
composer require filament/filament:"^4.0"

# Setup panel admin
php artisan filament:install --panels
# Ketik: admin

Install Midtrans PHP Library

composer require midtrans/midtrans-php

Jalankan Migration & Buat User

# Migrate
php artisan migrate

# Buat user admin
php artisan make:filament-user
# Name: Admin
# Email: [email protected]
# Password: password

Konfigurasi Environment

Edit file .env dan tambahkan konfigurasi Midtrans:

# Midtrans Configuration
MIDTRANS_MERCHANT_ID=G123456789
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxx
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true

Ganti value dengan keys dari dashboard Midtrans Sandbox kamu.

Buat Config File Midtrans

Buat file config/midtrans.php:

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Midtrans Merchant ID
    |--------------------------------------------------------------------------
    */
    'merchant_id' => env('MIDTRANS_MERCHANT_ID'),

    /*
    |--------------------------------------------------------------------------
    | Midtrans Client Key (untuk frontend)
    |--------------------------------------------------------------------------
    */
    'client_key' => env('MIDTRANS_CLIENT_KEY'),

    /*
    |--------------------------------------------------------------------------
    | Midtrans Server Key (untuk backend - RAHASIA!)
    |--------------------------------------------------------------------------
    */
    'server_key' => env('MIDTRANS_SERVER_KEY'),

    /*
    |--------------------------------------------------------------------------
    | Production Mode
    |--------------------------------------------------------------------------
    | Set true untuk production, false untuk sandbox
    */
    'is_production' => env('MIDTRANS_IS_PRODUCTION', false),

    /*
    |--------------------------------------------------------------------------
    | Sanitization
    |--------------------------------------------------------------------------
    | Sanitize input untuk keamanan
    */
    'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),

    /*
    |--------------------------------------------------------------------------
    | 3D Secure
    |--------------------------------------------------------------------------
    | Enable 3DS untuk kartu kredit
    */
    'is_3ds' => env('MIDTRANS_IS_3DS', true),

    /*
    |--------------------------------------------------------------------------
    | Snap URL
    |--------------------------------------------------------------------------
    | URL untuk load Snap.js
    */
    'snap_url' => env('MIDTRANS_IS_PRODUCTION', false)
        ? '<https://app.midtrans.com/snap/snap.js>'
        : '<https://app.sandbox.midtrans.com/snap/snap.js>',
];

Buat Midtrans Service (Helper)

Buat file app/Services/MidtransService.php:

<?php

namespace App\\Services;

use Midtrans\\Config;
use Midtrans\\Snap;

class MidtransService
{
    public function __construct()
    {
        Config::$serverKey = config('midtrans.server_key');
        Config::$isProduction = config('midtrans.is_production');
        Config::$isSanitized = config('midtrans.is_sanitized');
        Config::$is3ds = config('midtrans.is_3ds');
    }

    /**
     * Generate Snap Token untuk pembayaran
     */
    public function createSnapToken(array $params): string
    {
        return Snap::getSnapToken($params);
    }

    /**
     * Generate Snap Redirect URL
     */
    public function createSnapUrl(array $params): string
    {
        return Snap::createTransaction($params)->redirect_url;
    }
}

Test Setup

Jalankan server dan pastikan semuanya working:

php artisan serve

Buka http://localhost:8000/admin dan login dengan credentials yang tadi dibuat.

CHECKPOINT โœ…

โ˜‘๏ธ Laravel 12 terinstall
โ˜‘๏ธ Filament 4 terinstall
โ˜‘๏ธ Database SQLite ready
โ˜‘๏ธ Package midtrans/midtrans-php terinstall
โ˜‘๏ธ Config midtrans.php dibuat
โ˜‘๏ธ MidtransService helper dibuat
โ˜‘๏ธ Environment variables di-set
โ˜‘๏ธ Bisa login ke admin panel


Bagian 3: Database Design

ERD Point of Sales

Ini struktur database untuk aplikasi POS kita:

DATABASE DESIGN:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   categories    โ”‚           โ”‚    products     โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค           โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ id              โ”‚โ”€โ”€โ”€โ”€โ”      โ”‚ id              โ”‚
โ”‚ name            โ”‚    โ”‚      โ”‚ category_id     โ”‚โ—„โ”€โ”€โ”€โ”€โ”˜
โ”‚ slug            โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ name            โ”‚
โ”‚ is_active       โ”‚           โ”‚ sku             โ”‚
โ”‚ timestamps      โ”‚           โ”‚ price           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚ stock           โ”‚
                              โ”‚ image           โ”‚
                              โ”‚ is_active       โ”‚
                              โ”‚ timestamps      โ”‚
                              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                      โ”‚
                                      โ”‚ (referenced by)
                                      โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚     orders      โ”‚           โ”‚   order_items   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค           โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ id              โ”‚โ”€โ”€โ”€โ”€โ”      โ”‚ id              โ”‚
โ”‚ order_number    โ”‚    โ”‚      โ”‚ order_id        โ”‚โ—„โ”€โ”€โ”€โ”€โ”˜
โ”‚ customer_name   โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ product_id      โ”‚โ”€โ”€โ”€โ–บ products
โ”‚ customer_email  โ”‚           โ”‚ product_name    โ”‚
โ”‚ customer_phone  โ”‚           โ”‚ quantity        โ”‚
โ”‚ subtotal        โ”‚           โ”‚ price           โ”‚
โ”‚ tax             โ”‚           โ”‚ subtotal        โ”‚
โ”‚ total           โ”‚           โ”‚ timestamps      โ”‚
โ”‚ status          โ”‚           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ payment_method  โ”‚
โ”‚ snap_token      โ”‚
โ”‚ midtrans_order_idโ”‚
โ”‚ paid_at         โ”‚
โ”‚ timestamps      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

RELASI:
โ”œโ”€โ”€ Category hasMany Products
โ”œโ”€โ”€ Product belongsTo Category
โ”œโ”€โ”€ Order hasMany OrderItems
โ”œโ”€โ”€ OrderItem belongsTo Order
โ””โ”€โ”€ OrderItem belongsTo Product

Migration 1: Categories

php artisan make:migration create_categories_table

Edit file migration:

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};

Migration 2: Products

php artisan make:migration create_products_table

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->foreignId('category_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('sku')->unique();
            $table->decimal('price', 12, 2);
            $table->integer('stock')->default(0);
            $table->string('image')->nullable();
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

Migration 3: Orders

php artisan make:migration create_orders_table

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->string('order_number')->unique();

            // Customer information
            $table->string('customer_name');
            $table->string('customer_email')->nullable();
            $table->string('customer_phone')->nullable();

            // Order amounts
            $table->decimal('subtotal', 12, 2);
            $table->decimal('tax', 12, 2)->default(0);
            $table->decimal('total', 12, 2);

            // Payment status & info
            $table->enum('status', [
                'pending',    // Menunggu pembayaran
                'paid',       // Sudah dibayar
                'failed',     // Pembayaran gagal
                'expired',    // Kadaluarsa
                'refunded',   // Dikembalikan
            ])->default('pending');

            $table->string('payment_method')->nullable();
            $table->string('snap_token')->nullable();
            $table->string('midtrans_order_id')->nullable();
            $table->timestamp('paid_at')->nullable();

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

Penjelasan field penting:

  • order_number โ€” Nomor order untuk display (ORD-20250112-XXXXXX)
  • midtrans_order_id โ€” ID unik untuk Midtrans (harus unique per transaksi)
  • snap_token โ€” Token untuk menampilkan Snap popup
  • status โ€” Status pembayaran yang akan di-update via webhook
  • paid_at โ€” Timestamp kapan pembayaran berhasil

Migration 4: Order Items

php artisan make:migration create_order_items_table

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('order_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();

            // Snapshot data (harga & nama saat transaksi)
            $table->string('product_name');
            $table->integer('quantity');
            $table->decimal('price', 12, 2);
            $table->decimal('subtotal', 12, 2);

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_items');
    }
};

Kenapa snapshot product_name dan price?

Karena harga produk bisa berubah sewaktu-waktu. Kita simpan harga saat transaksi supaya history akurat.

Jalankan Migration

php artisan migrate

Model: Category

php artisan make:model Category

Edit app/Models/Category.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Category extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'is_active',
    ];

    protected $casts = [
        'is_active' => 'boolean',
    ];

    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }
}

Model: Product

php artisan make:model Product

Edit app/Models/Product.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Product extends Model
{
    protected $fillable = [
        'category_id',
        'name',
        'sku',
        'price',
        'stock',
        'image',
        'is_active',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'is_active' => 'boolean',
    ];

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function orderItems(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}

Model: Order

php artisan make:model Order

Edit app/Models/Order.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Order extends Model
{
    protected $fillable = [
        'order_number',
        'customer_name',
        'customer_email',
        'customer_phone',
        'subtotal',
        'tax',
        'total',
        'status',
        'payment_method',
        'snap_token',
        'midtrans_order_id',
        'paid_at',
    ];

    protected $casts = [
        'subtotal' => 'decimal:2',
        'tax' => 'decimal:2',
        'total' => 'decimal:2',
        'paid_at' => 'datetime',
    ];

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    /**
     * Generate unique order number
     */
    public static function generateOrderNumber(): string
    {
        $date = date('Ymd');
        $random = strtoupper(substr(uniqid(), -6));

        return "ORD-{$date}-{$random}";
    }

    /**
     * Generate unique Midtrans order ID
     */
    public static function generateMidtransOrderId(): string
    {
        return 'MID-' . time() . '-' . strtoupper(substr(uniqid(), -6));
    }

    /**
     * Check if order is paid
     */
    public function isPaid(): bool
    {
        return $this->status === 'paid';
    }

    /**
     * Check if order is pending
     */
    public function isPending(): bool
    {
        return $this->status === 'pending';
    }
}

Model: OrderItem

php artisan make:model OrderItem

Edit app/Models/OrderItem.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

class OrderItem extends Model
{
    protected $fillable = [
        'order_id',
        'product_id',
        'product_name',
        'quantity',
        'price',
        'subtotal',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'subtotal' => 'decimal:2',
    ];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }
}

Verifikasi Database

Cek apakah semua sudah benar:

php artisan tinker

>>> Schema::hasTable('categories')
=> true

>>> Schema::hasTable('products')
=> true

>>> Schema::hasTable('orders')
=> true

>>> Schema::hasTable('order_items')
=> true

>>> exit

CHECKPOINT โœ…

โ˜‘๏ธ 4 migration files dibuat
โ˜‘๏ธ Migration berhasil dijalankan
โ˜‘๏ธ 4 model dibuat:
   โ”œโ”€โ”€ Category (hasMany products)
   โ”œโ”€โ”€ Product (belongsTo category)
   โ”œโ”€โ”€ Order (hasMany items, helper methods)
   โ””โ”€โ”€ OrderItem (belongsTo order & product)
โ˜‘๏ธ Helper methods untuk generate order number
โ˜‘๏ธ Snapshot fields untuk harga & nama produk

Database siap! Selanjutnya kita akan isi dengan data dummy menggunakan Seeder.

Bagian 4: Seeder Data Dummy

Database sudah siap, sekarang isi dengan data produk untuk testing.

CategorySeeder

php artisan make:seeder CategorySeeder

<?php

namespace Database\\Seeders;

use App\\Models\\Category;
use Illuminate\\Database\\Seeder;

class CategorySeeder extends Seeder
{
    public function run(): void
    {
        $categories = [
            ['name' => 'Makanan', 'slug' => 'makanan'],
            ['name' => 'Minuman', 'slug' => 'minuman'],
            ['name' => 'Snack', 'slug' => 'snack'],
            ['name' => 'Dessert', 'slug' => 'dessert'],
        ];

        foreach ($categories as $category) {
            Category::create([
                'name' => $category['name'],
                'slug' => $category['slug'],
                'is_active' => true,
            ]);
        }
    }
}

ProductSeeder

php artisan make:seeder ProductSeeder

<?php

namespace Database\\Seeders;

use App\\Models\\Product;
use App\\Models\\Category;
use Illuminate\\Database\\Seeder;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        $products = [
            // Makanan
            ['category' => 'Makanan', 'name' => 'Nasi Goreng Spesial', 'sku' => 'MKN001', 'price' => 25000, 'stock' => 50],
            ['category' => 'Makanan', 'name' => 'Mie Goreng Jawa', 'sku' => 'MKN002', 'price' => 22000, 'stock' => 50],
            ['category' => 'Makanan', 'name' => 'Ayam Geprek Sambal Matah', 'sku' => 'MKN003', 'price' => 23000, 'stock' => 30],
            ['category' => 'Makanan', 'name' => 'Nasi Ayam Bakar', 'sku' => 'MKN004', 'price' => 28000, 'stock' => 25],
            ['category' => 'Makanan', 'name' => 'Soto Ayam', 'sku' => 'MKN005', 'price' => 20000, 'stock' => 35],
            ['category' => 'Makanan', 'name' => 'Gado-Gado', 'sku' => 'MKN006', 'price' => 18000, 'stock' => 30],

            // Minuman
            ['category' => 'Minuman', 'name' => 'Es Teh Manis', 'sku' => 'MNM001', 'price' => 5000, 'stock' => 100],
            ['category' => 'Minuman', 'name' => 'Es Jeruk Segar', 'sku' => 'MNM002', 'price' => 8000, 'stock' => 100],
            ['category' => 'Minuman', 'name' => 'Kopi Susu Gula Aren', 'sku' => 'MNM003', 'price' => 18000, 'stock' => 50],
            ['category' => 'Minuman', 'name' => 'Jus Alpukat', 'sku' => 'MNM004', 'price' => 15000, 'stock' => 30],
            ['category' => 'Minuman', 'name' => 'Es Campur', 'sku' => 'MNM005', 'price' => 12000, 'stock' => 40],
            ['category' => 'Minuman', 'name' => 'Teh Tarik', 'sku' => 'MNM006', 'price' => 10000, 'stock' => 60],

            // Snack
            ['category' => 'Snack', 'name' => 'Kentang Goreng', 'sku' => 'SNK001', 'price' => 15000, 'stock' => 40],
            ['category' => 'Snack', 'name' => 'Cireng Isi Ayam', 'sku' => 'SNK002', 'price' => 12000, 'stock' => 50],
            ['category' => 'Snack', 'name' => 'Pisang Goreng Coklat', 'sku' => 'SNK003', 'price' => 10000, 'stock' => 40],
            ['category' => 'Snack', 'name' => 'Tahu Crispy', 'sku' => 'SNK004', 'price' => 8000, 'stock' => 60],
            ['category' => 'Snack', 'name' => 'Risol Mayo', 'sku' => 'SNK005', 'price' => 7000, 'stock' => 50],

            // Dessert
            ['category' => 'Dessert', 'name' => 'Es Krim Vanilla', 'sku' => 'DST001', 'price' => 12000, 'stock' => 30],
            ['category' => 'Dessert', 'name' => 'Pudding Coklat', 'sku' => 'DST002', 'price' => 10000, 'stock' => 25],
            ['category' => 'Dessert', 'name' => 'Brownies', 'sku' => 'DST003', 'price' => 15000, 'stock' => 20],
            ['category' => 'Dessert', 'name' => 'Klepon', 'sku' => 'DST004', 'price' => 8000, 'stock' => 40],
        ];

        foreach ($products as $product) {
            $category = Category::where('name', $product['category'])->first();

            if ($category) {
                Product::create([
                    'category_id' => $category->id,
                    'name' => $product['name'],
                    'sku' => $product['sku'],
                    'price' => $product['price'],
                    'stock' => $product['stock'],
                    'is_active' => true,
                ]);
            }
        }
    }
}

Update DatabaseSeeder

<?php

namespace Database\\Seeders;

use Illuminate\\Database\\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            CategorySeeder::class,
            ProductSeeder::class,
        ]);
    }
}

Jalankan Seeder

php artisan migrate:fresh --seed

Buat ulang user admin:

php artisan make:filament-user
# Name: Admin
# Email: [email protected]
# Password: password

Verifikasi

php artisan tinker

>>> App\\Models\\Category::count()
=> 4

>>> App\\Models\\Product::count()
=> 21

>>> exit

Data siap! โœ…


Bagian 5: Filament Resources

CategoryResource

php artisan make:filament-resource Category --generate

Edit app/Filament/Resources/CategoryResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\CategoryResource\\Pages;
use App\\Models\\Category;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Str;

class CategoryResource extends Resource
{
    protected static ?string $model = Category::class;

    protected static ?string $navigationIcon = 'heroicon-o-tag';

    protected static ?string $navigationGroup = 'Master Data';

    protected static ?string $navigationLabel = 'Kategori';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make()
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->label('Nama Kategori')
                            ->required()
                            ->maxLength(255)
                            ->live(onBlur: true)
                            ->afterStateUpdated(function (string $operation, $state, Forms\\Set $set) {
                                if ($operation !== 'create') return;
                                $set('slug', Str::slug($state));
                            }),

                        Forms\\Components\\TextInput::make('slug')
                            ->label('Slug')
                            ->required()
                            ->maxLength(255)
                            ->unique(ignoreRecord: true),

                        Forms\\Components\\Toggle::make('is_active')
                            ->label('Aktif')
                            ->default(true),
                    ])->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->label('Nama')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('slug')
                    ->label('Slug')
                    ->searchable(),

                Tables\\Columns\\TextColumn::make('products_count')
                    ->label('Produk')
                    ->counts('products')
                    ->badge()
                    ->color('primary'),

                Tables\\Columns\\IconColumn::make('is_active')
                    ->label('Aktif')
                    ->boolean(),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Dibuat')
                    ->dateTime('d M Y')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Status Aktif'),
            ])
            ->actions([
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListCategories::route('/'),
            'create' => Pages\\CreateCategory::route('/create'),
            'edit' => Pages\\EditCategory::route('/{record}/edit'),
        ];
    }
}

ProductResource

php artisan make:filament-resource Product --generate

Edit app/Filament/Resources/ProductResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\ProductResource\\Pages;
use App\\Models\\Product;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;

class ProductResource extends Resource
{
    protected static ?string $model = Product::class;

    protected static ?string $navigationIcon = 'heroicon-o-cube';

    protected static ?string $navigationGroup = 'Master Data';

    protected static ?string $navigationLabel = 'Produk';

    protected static ?int $navigationSort = 2;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Informasi Produk')
                    ->schema([
                        Forms\\Components\\Select::make('category_id')
                            ->label('Kategori')
                            ->relationship('category', 'name')
                            ->required()
                            ->searchable()
                            ->preload(),

                        Forms\\Components\\TextInput::make('name')
                            ->label('Nama Produk')
                            ->required()
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('sku')
                            ->label('SKU')
                            ->required()
                            ->unique(ignoreRecord: true)
                            ->maxLength(50),

                        Forms\\Components\\Toggle::make('is_active')
                            ->label('Aktif')
                            ->default(true),
                    ])->columns(2),

                Forms\\Components\\Section::make('Harga & Stok')
                    ->schema([
                        Forms\\Components\\TextInput::make('price')
                            ->label('Harga')
                            ->required()
                            ->numeric()
                            ->prefix('Rp')
                            ->minValue(0),

                        Forms\\Components\\TextInput::make('stock')
                            ->label('Stok')
                            ->required()
                            ->numeric()
                            ->default(0)
                            ->minValue(0),
                    ])->columns(2),

                Forms\\Components\\Section::make('Gambar')
                    ->schema([
                        Forms\\Components\\FileUpload::make('image')
                            ->label('Foto Produk')
                            ->image()
                            ->directory('products')
                            ->maxSize(2048)
                            ->imageEditor(),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\ImageColumn::make('image')
                    ->label('Foto')
                    ->circular()
                    ->defaultImageUrl(fn () => '<https://ui-avatars.com/api/?name=P&background=e2e8f0>'),

                Tables\\Columns\\TextColumn::make('name')
                    ->label('Nama')
                    ->searchable()
                    ->sortable()
                    ->description(fn (Product $record) => $record->sku),

                Tables\\Columns\\TextColumn::make('category.name')
                    ->label('Kategori')
                    ->sortable()
                    ->badge()
                    ->color('gray'),

                Tables\\Columns\\TextColumn::make('price')
                    ->label('Harga')
                    ->money('IDR')
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('stock')
                    ->label('Stok')
                    ->sortable()
                    ->badge()
                    ->color(fn (int $state): string => match (true) {
                        $state <= 10 => 'danger',
                        $state <= 30 => 'warning',
                        default => 'success',
                    }),

                Tables\\Columns\\IconColumn::make('is_active')
                    ->label('Aktif')
                    ->boolean(),
            ])
            ->defaultSort('name')
            ->filters([
                Tables\\Filters\\SelectFilter::make('category')
                    ->relationship('category', 'name')
                    ->label('Kategori')
                    ->preload(),

                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Status Aktif'),

                Tables\\Filters\\Filter::make('low_stock')
                    ->label('Stok Rendah (โ‰ค10)')
                    ->query(fn ($query) => $query->where('stock', '<=', 10)),
            ])
            ->actions([
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListProducts::route('/'),
            'create' => Pages\\CreateProduct::route('/create'),
            'edit' => Pages\\EditProduct::route('/{record}/edit'),
        ];
    }
}

Test Resources

Buka browser http://localhost:8000/admin:

CHECKPOINT โœ…

โ˜‘๏ธ Menu "Master Data" muncul di sidebar
โ˜‘๏ธ Kategori: 4 data tampil dengan products count
โ˜‘๏ธ Produk: 21 data tampil dengan kategori badge
โ˜‘๏ธ Filter stok rendah working
โ˜‘๏ธ CRUD working untuk keduanya

Selanjutnya kita akan bikin halaman Kasir untuk interface POS dan integrasi Midtrans.

Bagian 6: Halaman Kasir - POS Interface

Sekarang bagian seru โ€” bikin interface kasir untuk memilih produk dan checkout.

Buat Custom Filament Page

php artisan make:filament-page Cashier

Edit Cashier Page

Edit app/Filament/Pages/Cashier.php:

<?php

namespace App\\Filament\\Pages;

use App\\Models\\Order;
use App\\Models\\Product;
use Filament\\Pages\\Page;
use Filament\\Notifications\\Notification;
use Illuminate\\Support\\Str;
use Livewire\\Attributes\\Computed;

class Cashier extends Page
{
    protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';

    protected static ?string $navigationLabel = 'Kasir';

    protected static ?int $navigationSort = 0;

    protected static string $view = 'filament.pages.cashier';

    // Cart state
    public array $cart = [];

    // Customer info
    public string $customerName = '';
    public string $customerEmail = '';
    public string $customerPhone = '';

    // Category filter
    public ?int $selectedCategory = null;

    public function mount(): void
    {
        $this->cart = session('pos_cart', []);
    }

    #[Computed]
    public function products()
    {
        $query = Product::where('is_active', true)
            ->where('stock', '>', 0)
            ->with('category');

        if ($this->selectedCategory) {
            $query->where('category_id', $this->selectedCategory);
        }

        return $query->orderBy('name')->get();
    }

    #[Computed]
    public function categories()
    {
        return \\App\\Models\\Category::where('is_active', true)
            ->orderBy('name')
            ->get();
    }

    public function filterByCategory(?int $categoryId): void
    {
        $this->selectedCategory = $categoryId;
    }

    public function addToCart(int $productId): void
    {
        $product = Product::find($productId);

        if (!$product || !$product->is_active) {
            Notification::make()
                ->title('Produk tidak tersedia')
                ->danger()
                ->send();
            return;
        }

        $cartKey = (string) $productId;

        if (isset($this->cart[$cartKey])) {
            // Cek stok sebelum tambah
            if ($this->cart[$cartKey]['quantity'] >= $product->stock) {
                Notification::make()
                    ->title('Stok tidak mencukupi')
                    ->body("Stok tersedia: {$product->stock}")
                    ->warning()
                    ->send();
                return;
            }
            $this->cart[$cartKey]['quantity']++;
        } else {
            $this->cart[$cartKey] = [
                'id' => $product->id,
                'name' => $product->name,
                'price' => (float) $product->price,
                'quantity' => 1,
                'stock' => $product->stock,
            ];
        }

        $this->updateCartSession();

        Notification::make()
            ->title('Ditambahkan ke keranjang')
            ->success()
            ->duration(1000)
            ->send();
    }

    public function incrementQuantity(int $productId): void
    {
        $cartKey = (string) $productId;

        if (!isset($this->cart[$cartKey])) return;

        if ($this->cart[$cartKey]['quantity'] >= $this->cart[$cartKey]['stock']) {
            Notification::make()
                ->title('Stok tidak mencukupi')
                ->warning()
                ->send();
            return;
        }

        $this->cart[$cartKey]['quantity']++;
        $this->updateCartSession();
    }

    public function decrementQuantity(int $productId): void
    {
        $cartKey = (string) $productId;

        if (!isset($this->cart[$cartKey])) return;

        $this->cart[$cartKey]['quantity']--;

        if ($this->cart[$cartKey]['quantity'] <= 0) {
            unset($this->cart[$cartKey]);
        }

        $this->updateCartSession();
    }

    public function removeFromCart(int $productId): void
    {
        $cartKey = (string) $productId;
        unset($this->cart[$cartKey]);
        $this->updateCartSession();
    }

    public function clearCart(): void
    {
        $this->cart = [];
        $this->customerName = '';
        $this->customerEmail = '';
        $this->customerPhone = '';
        session()->forget('pos_cart');
    }

    private function updateCartSession(): void
    {
        session(['pos_cart' => $this->cart]);
    }

    #[Computed]
    public function subtotal(): float
    {
        return collect($this->cart)->sum(fn ($item) => $item['price'] * $item['quantity']);
    }

    #[Computed]
    public function tax(): float
    {
        return $this->subtotal * 0.11; // PPN 11%
    }

    #[Computed]
    public function total(): float
    {
        return $this->subtotal + $this->tax;
    }

    #[Computed]
    public function cartCount(): int
    {
        return collect($this->cart)->sum('quantity');
    }
}

Buat Blade View

Buat file resources/views/filament/pages/cashier.blade.php:

<x-filament-panels::page>
    <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">

        {{-- Product Grid (2 columns on large screen) --}}
        <div class="lg:col-span-2 space-y-4">

            {{-- Category Filter --}}
            <div class="flex flex-wrap gap-2">
                <button
                    wire:click="filterByCategory(null)"
                    @class([
                        'px-4 py-2 rounded-lg text-sm font-medium transition',
                        'bg-primary-600 text-white' => !$this->selectedCategory,
                        'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600' => $this->selectedCategory,
                    ])
                >
                    Semua
                </button>
                @foreach($this->categories as $category)
                    <button
                        wire:click="filterByCategory({{ $category->id }})"
                        @class([
                            'px-4 py-2 rounded-lg text-sm font-medium transition',
                            'bg-primary-600 text-white' => $this->selectedCategory === $category->id,
                            'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600' => $this->selectedCategory !== $category->id,
                        ])
                    >
                        {{ $category->name }}
                    </button>
                @endforeach
            </div>

            {{-- Products Grid --}}
            <div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
                @forelse($this->products as $product)
                    <div
                        wire:click="addToCart({{ $product->id }})"
                        wire:key="product-{{ $product->id }}"
                        class="cursor-pointer bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-md transition-all duration-200 overflow-hidden border border-gray-200 dark:border-gray-700 hover:border-primary-500"
                    >
                        {{-- Product Image --}}
                        <div class="aspect-square bg-gray-100 dark:bg-gray-700 relative">
                            @if($product->image)
                                <img
                                    src="{{ Storage::url($product->image) }}"
                                    alt="{{ $product->name }}"
                                    class="w-full h-full object-cover"
                                >
                            @else
                                <div class="w-full h-full flex items-center justify-center text-gray-400">
                                    <x-heroicon-o-photo class="w-12 h-12" />
                                </div>
                            @endif

                            {{-- Stock Badge --}}
                            <div class="absolute top-2 right-2">
                                <span @class([
                                    'px-2 py-1 text-xs font-medium rounded-full',
                                    'bg-green-100 text-green-800' => $product->stock > 10,
                                    'bg-yellow-100 text-yellow-800' => $product->stock <= 10 && $product->stock > 0,
                                ])>
                                    {{ $product->stock }}
                                </span>
                            </div>
                        </div>

                        {{-- Product Info --}}
                        <div class="p-3">
                            <h3 class="font-medium text-sm text-gray-900 dark:text-white truncate">
                                {{ $product->name }}
                            </h3>
                            <p class="text-xs text-gray-500 dark:text-gray-400">
                                {{ $product->category->name }}
                            </p>
                            <p class="mt-1 text-primary-600 dark:text-primary-400 font-bold">
                                Rp {{ number_format($product->price, 0, ',', '.') }}
                            </p>
                        </div>
                    </div>
                @empty
                    <div class="col-span-full text-center py-12 text-gray-500">
                        <x-heroicon-o-cube class="w-12 h-12 mx-auto mb-2" />
                        <p>Tidak ada produk tersedia</p>
                    </div>
                @endforelse
            </div>
        </div>

        {{-- Cart Sidebar --}}
        <div class="lg:col-span-1">
            <div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 sticky top-4">

                {{-- Cart Header --}}
                <div class="p-4 border-b border-gray-200 dark:border-gray-700">
                    <div class="flex items-center justify-between">
                        <h2 class="text-lg font-bold flex items-center gap-2">
                            <x-heroicon-o-shopping-cart class="w-5 h-5" />
                            Keranjang
                            @if($this->cartCount > 0)
                                <span class="bg-primary-600 text-white text-xs px-2 py-0.5 rounded-full">
                                    {{ $this->cartCount }}
                                </span>
                            @endif
                        </h2>
                        @if(count($cart) > 0)
                            <button
                                wire:click="clearCart"
                                wire:confirm="Hapus semua item dari keranjang?"
                                class="text-red-500 hover:text-red-700 text-sm"
                            >
                                Hapus Semua
                            </button>
                        @endif
                    </div>
                </div>

                {{-- Cart Items --}}
                <div class="p-4 max-h-[400px] overflow-y-auto">
                    @forelse($cart as $item)
                        <div wire:key="cart-{{ $item['id'] }}" class="flex gap-3 py-3 border-b border-gray-100 dark:border-gray-700 last:border-0">
                            <div class="flex-1 min-w-0">
                                <h4 class="font-medium text-sm truncate">{{ $item['name'] }}</h4>
                                <p class="text-xs text-gray-500">
                                    Rp {{ number_format($item['price'], 0, ',', '.') }}
                                </p>
                            </div>
                            <div class="flex items-center gap-2">
                                <button
                                    wire:click="decrementQuantity({{ $item['id'] }})"
                                    class="w-7 h-7 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center"
                                >
                                    <x-heroicon-o-minus class="w-4 h-4" />
                                </button>
                                <span class="w-8 text-center font-medium">{{ $item['quantity'] }}</span>
                                <button
                                    wire:click="incrementQuantity({{ $item['id'] }})"
                                    class="w-7 h-7 rounded-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center"
                                >
                                    <x-heroicon-o-plus class="w-4 h-4" />
                                </button>
                            </div>
                            <div class="text-right">
                                <p class="font-medium text-sm">
                                    Rp {{ number_format($item['price'] * $item['quantity'], 0, ',', '.') }}
                                </p>
                                <button
                                    wire:click="removeFromCart({{ $item['id'] }})"
                                    class="text-red-500 hover:text-red-700 text-xs"
                                >
                                    Hapus
                                </button>
                            </div>
                        </div>
                    @empty
                        <div class="text-center py-8 text-gray-500">
                            <x-heroicon-o-shopping-cart class="w-12 h-12 mx-auto mb-2 opacity-50" />
                            <p>Keranjang kosong</p>
                            <p class="text-xs">Klik produk untuk menambahkan</p>
                        </div>
                    @endforelse
                </div>

                {{-- Cart Summary --}}
                @if(count($cart) > 0)
                    <div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
                        <div class="flex justify-between text-sm">
                            <span class="text-gray-500">Subtotal</span>
                            <span>Rp {{ number_format($this->subtotal, 0, ',', '.') }}</span>
                        </div>
                        <div class="flex justify-between text-sm">
                            <span class="text-gray-500">PPN (11%)</span>
                            <span>Rp {{ number_format($this->tax, 0, ',', '.') }}</span>
                        </div>
                        <div class="flex justify-between text-lg font-bold pt-2 border-t border-gray-200 dark:border-gray-700">
                            <span>Total</span>
                            <span class="text-primary-600">Rp {{ number_format($this->total, 0, ',', '.') }}</span>
                        </div>

                        {{-- Customer Form --}}
                        <div class="pt-3 space-y-2">
                            <input
                                wire:model="customerName"
                                type="text"
                                placeholder="Nama Customer *"
                                class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 text-sm"
                            >
                            <input
                                wire:model="customerPhone"
                                type="text"
                                placeholder="No. HP (opsional)"
                                class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 text-sm"
                            >
                            <input
                                wire:model="customerEmail"
                                type="email"
                                placeholder="Email (opsional)"
                                class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 text-sm"
                            >
                        </div>

                        {{-- Checkout Button --}}
                        <button
                            wire:click="checkout"
                            wire:loading.attr="disabled"
                            class="w-full py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-bold rounded-lg transition flex items-center justify-center gap-2"
                        >
                            <span wire:loading.remove wire:target="checkout">
                                <x-heroicon-o-credit-card class="w-5 h-5" />
                            </span>
                            <span wire:loading wire:target="checkout">
                                <x-heroicon-o-arrow-path class="w-5 h-5 animate-spin" />
                            </span>
                            <span wire:loading.remove wire:target="checkout">Bayar Sekarang</span>
                            <span wire:loading wire:target="checkout">Memproses...</span>
                        </button>
                    </div>
                @endif
            </div>
        </div>
    </div>

    {{-- Midtrans Snap Script --}}
    @push('scripts')
    <script src="{{ config('midtrans.snap_url') }}" data-client-key="{{ config('midtrans.client_key') }}"></script>
    <script>
        document.addEventListener('livewire:init', () => {
            Livewire.on('openSnapPopup', ({ snapToken, orderId }) => {
                window.snap.pay(snapToken, {
                    onSuccess: function(result) {
                        window.location.href = '/admin/orders/' + orderId + '?payment=success';
                    },
                    onPending: function(result) {
                        window.location.href = '/admin/orders/' + orderId + '?payment=pending';
                    },
                    onError: function(result) {
                        window.location.href = '/admin/orders/' + orderId + '?payment=error';
                    },
                    onClose: function() {
                        Livewire.dispatch('paymentCancelled');
                    }
                });
            });
        });
    </script>
    @endpush
</x-filament-panels::page>


Bagian 7: Checkout & Midtrans Snap

Sekarang tambahkan method checkout yang akan generate Snap token dan menampilkan popup pembayaran.

Tambah Method Checkout di Cashier.php

Tambahkan method ini di app/Filament/Pages/Cashier.php:

public function checkout(): void
{
    // Validasi cart
    if (empty($this->cart)) {
        Notification::make()
            ->title('Keranjang kosong!')
            ->danger()
            ->send();
        return;
    }

    // Validasi customer name
    if (empty(trim($this->customerName))) {
        Notification::make()
            ->title('Nama customer wajib diisi!')
            ->danger()
            ->send();
        return;
    }

    // Validasi stok sekali lagi
    foreach ($this->cart as $item) {
        $product = Product::find($item['id']);
        if (!$product || $product->stock < $item['quantity']) {
            Notification::make()
                ->title('Stok tidak mencukupi')
                ->body("Produk {$item['name']} stok tersedia: " . ($product->stock ?? 0))
                ->danger()
                ->send();
            return;
        }
    }

    try {
        // Create order
        $order = Order::create([
            'order_number' => Order::generateOrderNumber(),
            'customer_name' => trim($this->customerName),
            'customer_email' => $this->customerEmail ?: null,
            'customer_phone' => $this->customerPhone ?: null,
            'subtotal' => $this->subtotal,
            'tax' => $this->tax,
            'total' => $this->total,
            'status' => 'pending',
            'midtrans_order_id' => Order::generateMidtransOrderId(),
        ]);

        // Create order items & decrease stock
        foreach ($this->cart as $item) {
            $order->items()->create([
                'product_id' => $item['id'],
                'product_name' => $item['name'],
                'quantity' => $item['quantity'],
                'price' => $item['price'],
                'subtotal' => $item['price'] * $item['quantity'],
            ]);

            // Decrease stock
            Product::where('id', $item['id'])->decrement('stock', $item['quantity']);
        }

        // Generate Snap Token
        $snapToken = $this->generateSnapToken($order);

        // Save snap token
        $order->update(['snap_token' => $snapToken]);

        // Clear cart
        $this->clearCart();

        // Dispatch event to open Snap popup
        $this->dispatch('openSnapPopup', snapToken: $snapToken, orderId: $order->id);

    } catch (\\Exception $e) {
        \\Log::error('Checkout Error: ' . $e->getMessage());

        Notification::make()
            ->title('Terjadi kesalahan')
            ->body('Silakan coba lagi atau hubungi admin.')
            ->danger()
            ->send();
    }
}

private function generateSnapToken(Order $order): string
{
    // Setup Midtrans configuration
    \\Midtrans\\Config::$serverKey = config('midtrans.server_key');
    \\Midtrans\\Config::$isProduction = config('midtrans.is_production');
    \\Midtrans\\Config::$isSanitized = config('midtrans.is_sanitized');
    \\Midtrans\\Config::$is3ds = config('midtrans.is_3ds');

    // Build item details
    $itemDetails = $order->items->map(fn ($item) => [
        'id' => (string) $item->product_id,
        'price' => (int) $item->price,
        'quantity' => (int) $item->quantity,
        'name' => substr($item->product_name, 0, 50), // Max 50 chars
    ])->toArray();

    // Add tax as separate item
    if ($order->tax > 0) {
        $itemDetails[] = [
            'id' => 'TAX',
            'price' => (int) $order->tax,
            'quantity' => 1,
            'name' => 'PPN 11%',
        ];
    }

    // Build params
    $params = [
        'transaction_details' => [
            'order_id' => $order->midtrans_order_id,
            'gross_amount' => (int) $order->total,
        ],
        'customer_details' => [
            'first_name' => $order->customer_name,
            'email' => $order->customer_email ?: '[email protected]',
            'phone' => $order->customer_phone ?: '08123456789',
        ],
        'item_details' => $itemDetails,
    ];

    return \\Midtrans\\Snap::getSnapToken($params);
}

#[On('paymentCancelled')]
public function handlePaymentCancelled(): void
{
    Notification::make()
        ->title('Pembayaran dibatalkan')
        ->body('Order tetap tersimpan. Anda bisa melanjutkan pembayaran nanti.')
        ->warning()
        ->send();
}

Jangan lupa tambah import di atas file:

use Livewire\\Attributes\\On;

Penjelasan Flow Checkout

CHECKOUT FLOW:

1. User klik "Bayar Sekarang"
           โ”‚
           โ–ผ
2. Validasi: cart tidak kosong, nama diisi, stok cukup
           โ”‚
           โ–ผ
3. Create Order di database (status: pending)
           โ”‚
           โ–ผ
4. Create OrderItems untuk setiap produk di cart
           โ”‚
           โ–ผ
5. Decrease stock produk
           โ”‚
           โ–ผ
6. Generate Snap Token via Midtrans API
           โ”‚
           โ–ผ
7. Simpan snap_token ke order
           โ”‚
           โ–ผ
8. Clear cart
           โ”‚
           โ–ผ
9. Dispatch event 'openSnapPopup' ke frontend
           โ”‚
           โ–ผ
10. JavaScript buka Snap popup dengan token
           โ”‚
           โ–ผ
11. User pilih metode bayar & selesaikan pembayaran
           โ”‚
           โ–ผ
12. Callback: onSuccess/onPending/onError/onClose
           โ”‚
           โ–ผ
13. Redirect ke halaman order detail

Test Checkout

  1. Buka http://localhost:8000/admin/cashier
  2. Klik beberapa produk untuk menambah ke cart
  3. Isi nama customer
  4. Klik "Bayar Sekarang"
  5. Popup Midtrans Snap akan muncul
  6. Pilih metode pembayaran (di Sandbox bisa pakai test card)

Test Card Midtrans Sandbox:

SUCCESS:
โ”œโ”€โ”€ Card Number: 4811 1111 1111 1114
โ”œโ”€โ”€ CVV: 123
โ”œโ”€โ”€ Exp Date: Any future date (e.g., 01/25)
โ””โ”€โ”€ OTP: 112233

FAILURE:
โ”œโ”€โ”€ Card Number: 4911 1111 1111 1113
โ””โ”€โ”€ (akan menampilkan error)


Bagian 8: Webhook Handler

Webhook adalah endpoint yang dipanggil Midtrans saat ada update status pembayaran. Ini penting supaya status order di database kita selalu sinkron.

Buat Controller

php artisan make:controller MidtransWebhookController

Edit app/Http/Controllers/MidtransWebhookController.php:

<?php

namespace App\\Http\\Controllers;

use App\\Models\\Order;
use App\\Models\\Product;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Support\\Facades\\Log;

class MidtransWebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        // Log incoming webhook for debugging
        Log::info('Midtrans Webhook Received', [
            'payload' => $request->all(),
        ]);

        $payload = $request->all();

        // Validate required fields
        if (!isset($payload['order_id'], $payload['status_code'], $payload['gross_amount'], $payload['signature_key'])) {
            Log::warning('Midtrans Webhook: Missing required fields');
            return response()->json(['message' => 'Invalid payload'], 400);
        }

        // Verify signature
        $serverKey = config('midtrans.server_key');
        $orderId = $payload['order_id'];
        $statusCode = $payload['status_code'];
        $grossAmount = $payload['gross_amount'];
        $signatureKey = $payload['signature_key'];

        $expectedSignature = hash('sha512', $orderId . $statusCode . $grossAmount . $serverKey);

        if ($signatureKey !== $expectedSignature) {
            Log::warning('Midtrans Webhook: Invalid signature', [
                'order_id' => $orderId,
                'expected' => $expectedSignature,
                'received' => $signatureKey,
            ]);
            return response()->json(['message' => 'Invalid signature'], 403);
        }

        // Find order by midtrans_order_id
        $order = Order::where('midtrans_order_id', $orderId)->first();

        if (!$order) {
            Log::warning('Midtrans Webhook: Order not found', ['order_id' => $orderId]);
            return response()->json(['message' => 'Order not found'], 404);
        }

        // Get transaction status
        $transactionStatus = $payload['transaction_status'] ?? null;
        $paymentType = $payload['payment_type'] ?? null;
        $fraudStatus = $payload['fraud_status'] ?? null;

        Log::info('Midtrans Webhook: Processing', [
            'order_id' => $orderId,
            'transaction_status' => $transactionStatus,
            'payment_type' => $paymentType,
            'fraud_status' => $fraudStatus,
        ]);

        // Update order based on transaction status
        $this->updateOrderStatus($order, $transactionStatus, $paymentType, $fraudStatus);

        return response()->json(['message' => 'OK']);
    }

    private function updateOrderStatus(Order $order, ?string $transactionStatus, ?string $paymentType, ?string $fraudStatus): void
    {
        // Skip if order already in final state
        if (in_array($order->status, ['paid', 'refunded'])) {
            Log::info('Midtrans Webhook: Order already in final state', [
                'order_id' => $order->midtrans_order_id,
                'current_status' => $order->status,
            ]);
            return;
        }

        switch ($transactionStatus) {
            case 'capture':
                // For credit card: check fraud status
                if ($fraudStatus === 'accept') {
                    $this->markAsPaid($order, $paymentType);
                } elseif ($fraudStatus === 'challenge') {
                    // Need manual review - keep as pending
                    $order->update(['payment_method' => $paymentType]);
                }
                break;

            case 'settlement':
                // Payment completed (bank transfer, e-wallet, etc.)
                $this->markAsPaid($order, $paymentType);
                break;

            case 'pending':
                // Waiting for payment
                $order->update([
                    'status' => 'pending',
                    'payment_method' => $paymentType,
                ]);
                break;

            case 'deny':
                // Payment denied
                $this->markAsFailed($order);
                break;

            case 'cancel':
                // Payment cancelled
                $this->markAsFailed($order);
                break;

            case 'expire':
                // Payment expired
                $this->markAsExpired($order);
                break;

            case 'refund':
            case 'partial_refund':
                // Refunded
                $order->update(['status' => 'refunded']);
                break;
        }
    }

    private function markAsPaid(Order $order, ?string $paymentType): void
    {
        $order->update([
            'status' => 'paid',
            'payment_method' => $paymentType,
            'paid_at' => now(),
        ]);

        Log::info('Order marked as paid', [
            'order_id' => $order->midtrans_order_id,
            'order_number' => $order->order_number,
        ]);
    }

    private function markAsFailed(Order $order): void
    {
        $order->update(['status' => 'failed']);
        $this->restoreStock($order);

        Log::info('Order marked as failed, stock restored', [
            'order_id' => $order->midtrans_order_id,
        ]);
    }

    private function markAsExpired(Order $order): void
    {
        $order->update(['status' => 'expired']);
        $this->restoreStock($order);

        Log::info('Order marked as expired, stock restored', [
            'order_id' => $order->midtrans_order_id,
        ]);
    }

    private function restoreStock(Order $order): void
    {
        foreach ($order->items as $item) {
            Product::where('id', $item->product_id)
                ->increment('stock', $item->quantity);
        }
    }
}

Tambah Route

Edit routes/web.php:

<?php

use App\\Http\\Controllers\\MidtransWebhookController;
use Illuminate\\Support\\Facades\\Route;

Route::get('/', function () {
    return redirect('/admin');
});

// Midtrans Webhook - exclude from CSRF protection
Route::post('/midtrans/webhook', [MidtransWebhookController::class, 'handle'])
    ->name('midtrans.webhook')
    ->withoutMiddleware([\\App\\Http\\Middleware\\VerifyCsrfToken::class]);

โš ๏ธ PENTING: Webhook harus exclude dari CSRF protection karena request datang dari server Midtrans, bukan dari browser user.

Testing Webhook dengan ngrok

Untuk testing di localhost, gunakan ngrok untuk expose server kamu ke internet.

Install ngrok:

# Download dari <https://ngrok.com/download>
# Atau via Homebrew (Mac)
brew install ngrok

Jalankan ngrok:

# Di terminal baru, jalankan
ngrok http 8000

Output:

Session Status    online
Forwarding        <https://abc123.ngrok.io> -> <http://localhost:8000>

Set Webhook URL di Midtrans:

  1. Login ke Midtrans Dashboard Sandbox
  2. Pergi ke Settings โ†’ Configuration
  3. Di bagian Payment Notification URL, masukkan: <https://abc123.ngrok.io/midtrans/webhook>
  4. Klik Update

Test Webhook:

  1. Buat order baru melalui halaman Kasir
  2. Selesaikan pembayaran di Snap popup
  3. Cek log Laravel: tail -f storage/logs/laravel.log
  4. Akan muncul log "Midtrans Webhook Received" dan "Order marked as paid"

Webhook Flow

WEBHOOK FLOW:

User bayar di Snap
        โ”‚
        โ–ผ
Midtrans proses pembayaran
        โ”‚
        โ–ผ
Midtrans kirim POST ke /midtrans/webhook
        โ”‚
        โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚           Webhook Handler             โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ 1. Log payload untuk debugging        โ”‚
โ”‚ 2. Validate signature (SHA512)        โ”‚
โ”‚ 3. Find order by midtrans_order_id    โ”‚
โ”‚ 4. Update status based on:            โ”‚
โ”‚    - capture/settlement โ†’ paid        โ”‚
โ”‚    - pending โ†’ pending                โ”‚
โ”‚    - deny/cancel โ†’ failed             โ”‚
โ”‚    - expire โ†’ expired                 โ”‚
โ”‚    - refund โ†’ refunded                โ”‚
โ”‚ 5. Restore stock jika gagal/expired   โ”‚
โ”‚ 6. Return 200 OK                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
        โ”‚
        โ–ผ
Order status updated di database โœ…

Checkpoint โœ…

โ˜‘๏ธ Halaman Kasir dengan product grid
โ˜‘๏ธ Keranjang dengan session storage
โ˜‘๏ธ Category filter working
โ˜‘๏ธ Quantity +/- working
โ˜‘๏ธ Subtotal, PPN, Total calculation
โ˜‘๏ธ Checkout create order di database
โ˜‘๏ธ Snap Token generated
โ˜‘๏ธ Snap popup muncul
โ˜‘๏ธ Payment methods tersedia
โ˜‘๏ธ Webhook endpoint working
โ˜‘๏ธ Signature verification
โ˜‘๏ธ Order status update otomatis
โ˜‘๏ธ Stock restore on failure/expire

Selanjutnya kita bikin OrderResource untuk management order dan stats widget.

Bagian 9: Order Management

Sekarang bikin resource untuk manage orders โ€” lihat daftar transaksi, detail order, dan statistik penjualan.

OrderResource

php artisan make:filament-resource Order --generate

Edit app/Filament/Resources/OrderResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\OrderResource\\Pages;
use App\\Filament\\Resources\\OrderResource\\RelationManagers;
use App\\Models\\Order;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Infolists;
use Filament\\Infolists\\Infolist;

class OrderResource extends Resource
{
    protected static ?string $model = Order::class;

    protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';

    protected static ?string $navigationLabel = 'Orders';

    protected static ?string $navigationGroup = 'Transaksi';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Informasi Order')
                    ->schema([
                        Forms\\Components\\TextInput::make('order_number')
                            ->label('No. Order')
                            ->disabled(),

                        Forms\\Components\\TextInput::make('midtrans_order_id')
                            ->label('Midtrans Order ID')
                            ->disabled(),

                        Forms\\Components\\Select::make('status')
                            ->label('Status')
                            ->options([
                                'pending' => 'Pending',
                                'paid' => 'Paid',
                                'failed' => 'Failed',
                                'expired' => 'Expired',
                                'refunded' => 'Refunded',
                            ])
                            ->disabled(),

                        Forms\\Components\\TextInput::make('payment_method')
                            ->label('Metode Pembayaran')
                            ->disabled(),
                    ])->columns(2),

                Forms\\Components\\Section::make('Customer')
                    ->schema([
                        Forms\\Components\\TextInput::make('customer_name')
                            ->label('Nama')
                            ->disabled(),

                        Forms\\Components\\TextInput::make('customer_email')
                            ->label('Email')
                            ->disabled(),

                        Forms\\Components\\TextInput::make('customer_phone')
                            ->label('No. HP')
                            ->disabled(),
                    ])->columns(3),

                Forms\\Components\\Section::make('Pembayaran')
                    ->schema([
                        Forms\\Components\\TextInput::make('subtotal')
                            ->label('Subtotal')
                            ->prefix('Rp')
                            ->disabled(),

                        Forms\\Components\\TextInput::make('tax')
                            ->label('PPN')
                            ->prefix('Rp')
                            ->disabled(),

                        Forms\\Components\\TextInput::make('total')
                            ->label('Total')
                            ->prefix('Rp')
                            ->disabled(),

                        Forms\\Components\\DateTimePicker::make('paid_at')
                            ->label('Waktu Bayar')
                            ->disabled(),
                    ])->columns(4),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('order_number')
                    ->label('No. Order')
                    ->searchable()
                    ->sortable()
                    ->copyable()
                    ->copyMessage('Order number disalin!'),

                Tables\\Columns\\TextColumn::make('customer_name')
                    ->label('Customer')
                    ->searchable()
                    ->description(fn (Order $record) => $record->customer_phone ?? '-'),

                Tables\\Columns\\TextColumn::make('items_count')
                    ->label('Items')
                    ->counts('items')
                    ->badge()
                    ->color('gray'),

                Tables\\Columns\\TextColumn::make('total')
                    ->label('Total')
                    ->money('IDR')
                    ->sortable()
                    ->weight('bold'),

                Tables\\Columns\\TextColumn::make('status')
                    ->label('Status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending' => 'warning',
                        'paid' => 'success',
                        'failed' => 'danger',
                        'expired' => 'gray',
                        'refunded' => 'info',
                    })
                    ->icon(fn (string $state): string => match ($state) {
                        'pending' => 'heroicon-o-clock',
                        'paid' => 'heroicon-o-check-circle',
                        'failed' => 'heroicon-o-x-circle',
                        'expired' => 'heroicon-o-exclamation-circle',
                        'refunded' => 'heroicon-o-arrow-uturn-left',
                    }),

                Tables\\Columns\\TextColumn::make('payment_method')
                    ->label('Metode')
                    ->badge()
                    ->color('primary')
                    ->placeholder('-'),

                Tables\\Columns\\TextColumn::make('paid_at')
                    ->label('Waktu Bayar')
                    ->dateTime('d M Y H:i')
                    ->sortable()
                    ->placeholder('-'),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Dibuat')
                    ->dateTime('d M Y H:i')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->defaultSort('created_at', 'desc')
            ->filters([
                Tables\\Filters\\SelectFilter::make('status')
                    ->label('Status')
                    ->options([
                        'pending' => 'Pending',
                        'paid' => 'Paid',
                        'failed' => 'Failed',
                        'expired' => 'Expired',
                        'refunded' => 'Refunded',
                    ]),

                Tables\\Filters\\Filter::make('date_range')
                    ->form([
                        Forms\\Components\\DatePicker::make('from')
                            ->label('Dari Tanggal'),
                        Forms\\Components\\DatePicker::make('until')
                            ->label('Sampai Tanggal'),
                    ])
                    ->query(function ($query, array $data) {
                        return $query
                            ->when($data['from'], fn ($q, $date) => $q->whereDate('created_at', '>=', $date))
                            ->when($data['until'], fn ($q, $date) => $q->whereDate('created_at', '<=', $date));
                    })
                    ->indicateUsing(function (array $data): array {
                        $indicators = [];
                        if ($data['from'] ?? null) {
                            $indicators['from'] = 'Dari: ' . \\Carbon\\Carbon::parse($data['from'])->format('d M Y');
                        }
                        if ($data['until'] ?? null) {
                            $indicators['until'] = 'Sampai: ' . \\Carbon\\Carbon::parse($data['until'])->format('d M Y');
                        }
                        return $indicators;
                    }),

                Tables\\Filters\\Filter::make('paid_today')
                    ->label('Dibayar Hari Ini')
                    ->query(fn ($query) => $query->where('status', 'paid')->whereDate('paid_at', today())),
            ])
            ->actions([
                Tables\\Actions\\ViewAction::make(),

                Tables\\Actions\\Action::make('retry_payment')
                    ->label('Retry Payment')
                    ->icon('heroicon-o-arrow-path')
                    ->color('warning')
                    ->visible(fn (Order $record) => $record->status === 'pending' && $record->snap_token)
                    ->url(fn (Order $record) => route('filament.admin.pages.cashier') . '?retry=' . $record->id)
                    ->openUrlInNewTab(),

                Tables\\Actions\\Action::make('mark_expired')
                    ->label('Mark Expired')
                    ->icon('heroicon-o-x-circle')
                    ->color('danger')
                    ->visible(fn (Order $record) => $record->status === 'pending')
                    ->requiresConfirmation()
                    ->modalHeading('Tandai Order Expired?')
                    ->modalDescription('Stock akan dikembalikan. Action ini tidak bisa dibatalkan.')
                    ->action(function (Order $record) {
                        $record->update(['status' => 'expired']);

                        // Restore stock
                        foreach ($record->items as $item) {
                            \\App\\Models\\Product::where('id', $item->product_id)
                                ->increment('stock', $item->quantity);
                        }
                    }),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make()
                        ->visible(fn () => auth()->user()?->is_admin ?? false),
                ]),
            ]);
    }

    public static function infolist(Infolist $infolist): Infolist
    {
        return $infolist
            ->schema([
                Infolists\\Components\\Section::make('Informasi Order')
                    ->schema([
                        Infolists\\Components\\TextEntry::make('order_number')
                            ->label('No. Order')
                            ->copyable(),

                        Infolists\\Components\\TextEntry::make('midtrans_order_id')
                            ->label('Midtrans ID')
                            ->copyable(),

                        Infolists\\Components\\TextEntry::make('status')
                            ->label('Status')
                            ->badge()
                            ->color(fn (string $state): string => match ($state) {
                                'pending' => 'warning',
                                'paid' => 'success',
                                'failed' => 'danger',
                                'expired' => 'gray',
                                'refunded' => 'info',
                            }),

                        Infolists\\Components\\TextEntry::make('payment_method')
                            ->label('Metode Pembayaran')
                            ->placeholder('-'),
                    ])->columns(4),

                Infolists\\Components\\Section::make('Customer')
                    ->schema([
                        Infolists\\Components\\TextEntry::make('customer_name')
                            ->label('Nama'),

                        Infolists\\Components\\TextEntry::make('customer_email')
                            ->label('Email')
                            ->placeholder('-'),

                        Infolists\\Components\\TextEntry::make('customer_phone')
                            ->label('No. HP')
                            ->placeholder('-'),
                    ])->columns(3),

                Infolists\\Components\\Section::make('Detail Pembayaran')
                    ->schema([
                        Infolists\\Components\\TextEntry::make('subtotal')
                            ->label('Subtotal')
                            ->money('IDR'),

                        Infolists\\Components\\TextEntry::make('tax')
                            ->label('PPN (11%)')
                            ->money('IDR'),

                        Infolists\\Components\\TextEntry::make('total')
                            ->label('Total')
                            ->money('IDR')
                            ->weight('bold')
                            ->size('lg'),

                        Infolists\\Components\\TextEntry::make('paid_at')
                            ->label('Waktu Bayar')
                            ->dateTime('d M Y H:i:s')
                            ->placeholder('Belum dibayar'),
                    ])->columns(4),

                Infolists\\Components\\Section::make('Waktu')
                    ->schema([
                        Infolists\\Components\\TextEntry::make('created_at')
                            ->label('Dibuat')
                            ->dateTime('d M Y H:i:s'),

                        Infolists\\Components\\TextEntry::make('updated_at')
                            ->label('Diupdate')
                            ->dateTime('d M Y H:i:s'),
                    ])->columns(2),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            RelationManagers\\ItemsRelationManager::class,
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListOrders::route('/'),
            'view' => Pages\\ViewOrder::route('/{record}'),
        ];
    }

    public static function getNavigationBadge(): ?string
    {
        return static::getModel()::where('status', 'pending')->count() ?: null;
    }

    public static function getNavigationBadgeColor(): ?string
    {
        return 'warning';
    }
}

Buat View Page

php artisan make:filament-page ViewOrder --resource=OrderResource --type=ViewRecord

Buat Items Relation Manager

php artisan make:filament-relation-manager OrderResource items product_name

Edit app/Filament/Resources/OrderResource/RelationManagers/ItemsRelationManager.php:

<?php

namespace App\\Filament\\Resources\\OrderResource\\RelationManagers;

use Filament\\Resources\\RelationManagers\\RelationManager;
use Filament\\Tables;
use Filament\\Tables\\Table;

class ItemsRelationManager extends RelationManager
{
    protected static string $relationship = 'items';

    protected static ?string $title = 'Item Pesanan';

    public function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('product_name')
                    ->label('Produk')
                    ->searchable(),

                Tables\\Columns\\TextColumn::make('quantity')
                    ->label('Qty')
                    ->alignCenter(),

                Tables\\Columns\\TextColumn::make('price')
                    ->label('Harga')
                    ->money('IDR'),

                Tables\\Columns\\TextColumn::make('subtotal')
                    ->label('Subtotal')
                    ->money('IDR')
                    ->weight('bold'),
            ])
            ->paginated(false);
    }
}

Sales Stats Widget

php artisan make:filament-widget SalesStatsWidget --stats-overview

Edit app/Filament/Widgets/SalesStatsWidget.php:

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Order;
use App\\Models\\Product;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
use Carbon\\Carbon;

class SalesStatsWidget extends BaseWidget
{
    protected static ?int $sort = 1;

    protected function getStats(): array
    {
        // Today's stats
        $todayOrders = Order::whereDate('created_at', today());
        $todayPaid = (clone $todayOrders)->where('status', 'paid');

        // This month stats
        $monthOrders = Order::whereMonth('created_at', now()->month)
            ->whereYear('created_at', now()->year);
        $monthPaid = (clone $monthOrders)->where('status', 'paid');

        // Yesterday comparison
        $yesterdayRevenue = Order::whereDate('paid_at', today()->subDay())
            ->where('status', 'paid')
            ->sum('total');

        $todayRevenue = $todayPaid->sum('total');

        $revenueChange = $yesterdayRevenue > 0
            ? round((($todayRevenue - $yesterdayRevenue) / $yesterdayRevenue) * 100, 1)
            : ($todayRevenue > 0 ? 100 : 0);

        return [
            Stat::make('Penjualan Hari Ini', 'Rp ' . number_format($todayRevenue, 0, ',', '.'))
                ->description($todayPaid->count() . ' transaksi sukses')
                ->descriptionIcon('heroicon-m-banknotes')
                ->color('success')
                ->chart($this->getWeeklyRevenueChart()),

            Stat::make('Penjualan Bulan Ini', 'Rp ' . number_format($monthPaid->sum('total'), 0, ',', '.'))
                ->description($monthPaid->count() . ' transaksi sukses')
                ->descriptionIcon('heroicon-m-calendar')
                ->color('primary'),

            Stat::make('Pending Payment', Order::where('status', 'pending')->count())
                ->description('Menunggu pembayaran')
                ->descriptionIcon('heroicon-m-clock')
                ->color(Order::where('status', 'pending')->count() > 0 ? 'warning' : 'gray'),

            Stat::make('Stok Rendah', Product::where('stock', '<=', 10)->where('is_active', true)->count())
                ->description('Produk perlu restock')
                ->descriptionIcon('heroicon-m-exclamation-triangle')
                ->color(Product::where('stock', '<=', 10)->count() > 0 ? 'danger' : 'success'),
        ];
    }

    private function getWeeklyRevenueChart(): array
    {
        $data = [];

        for ($i = 6; $i >= 0; $i--) {
            $date = today()->subDays($i);
            $revenue = Order::whereDate('paid_at', $date)
                ->where('status', 'paid')
                ->sum('total');
            $data[] = (int) $revenue;
        }

        return $data;
    }
}

Recent Orders Widget

php artisan make:filament-widget RecentOrdersWidget

Edit app/Filament/Widgets/RecentOrdersWidget.php:

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Order;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;

class RecentOrdersWidget extends BaseWidget
{
    protected static ?int $sort = 2;

    protected int | string | array $columnSpan = 'full';

    protected static ?string $heading = 'Order Terbaru';

    public function table(Table $table): Table
    {
        return $table
            ->query(
                Order::query()->latest()->limit(5)
            )
            ->columns([
                Tables\\Columns\\TextColumn::make('order_number')
                    ->label('No. Order')
                    ->searchable(),

                Tables\\Columns\\TextColumn::make('customer_name')
                    ->label('Customer'),

                Tables\\Columns\\TextColumn::make('total')
                    ->label('Total')
                    ->money('IDR'),

                Tables\\Columns\\TextColumn::make('status')
                    ->label('Status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending' => 'warning',
                        'paid' => 'success',
                        'failed' => 'danger',
                        'expired' => 'gray',
                        'refunded' => 'info',
                    }),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Waktu')
                    ->since(),
            ])
            ->actions([
                Tables\\Actions\\Action::make('view')
                    ->url(fn (Order $record) => route('filament.admin.resources.orders.view', $record))
                    ->icon('heroicon-o-eye'),
            ])
            ->paginated(false);
    }
}

Test Order Management

CHECKPOINT โœ…

โ˜‘๏ธ Order list dengan semua kolom
โ˜‘๏ธ Status badge dengan warna dan icon
โ˜‘๏ธ Filter by status working
โ˜‘๏ธ Filter by date range working
โ˜‘๏ธ View order detail dengan infolist
โ˜‘๏ธ Items relation manager menampilkan produk
โ˜‘๏ธ Navigation badge menampilkan pending count
โ˜‘๏ธ Stats widget menampilkan penjualan
โ˜‘๏ธ Recent orders widget di dashboard
โ˜‘๏ธ Mark expired action working


Bagian 10: Penutup

Recap - Apa yang Sudah Dibuat

โœ… FITUR LENGKAP POS + MIDTRANS:

๐Ÿ“ฆ Setup & Konfigurasi
โ”œโ”€โ”€ Laravel 12 + Filament 4
โ”œโ”€โ”€ Midtrans PHP package
โ”œโ”€โ”€ Config file untuk credentials
โ””โ”€โ”€ Service class (opsional)

๐Ÿ“Š Database
โ”œโ”€โ”€ Categories (4 kategori)
โ”œโ”€โ”€ Products (21 produk)
โ”œโ”€โ”€ Orders (dengan Midtrans fields)
โ”œโ”€โ”€ Order Items (dengan snapshot)
โ””โ”€โ”€ Relationships lengkap

๐ŸŽจ Filament Resources
โ”œโ”€โ”€ CategoryResource (CRUD)
โ”œโ”€โ”€ ProductResource (CRUD + stock badge)
โ””โ”€โ”€ OrderResource (View + filters + actions)

๐Ÿ›’ POS Interface (Halaman Kasir)
โ”œโ”€โ”€ Product grid dengan category filter
โ”œโ”€โ”€ Click-to-add functionality
โ”œโ”€โ”€ Session-based cart
โ”œโ”€โ”€ Quantity increment/decrement
โ”œโ”€โ”€ Stock validation
โ”œโ”€โ”€ Auto calculate subtotal + PPN + total
โ”œโ”€โ”€ Customer form
โ””โ”€โ”€ Responsive design

๐Ÿ’ณ Midtrans Integration
โ”œโ”€โ”€ Snap Token generation
โ”œโ”€โ”€ Snap popup di frontend
โ”œโ”€โ”€ Multiple payment methods
โ”œโ”€โ”€ Callback handlers (success/pending/error/close)
โ””โ”€โ”€ Redirect after payment

๐Ÿ”” Webhook Handler
โ”œโ”€โ”€ Signature verification (SHA512)
โ”œโ”€โ”€ Status mapping (capture, settlement, pending, etc.)
โ”œโ”€โ”€ Auto update order status
โ”œโ”€โ”€ Stock restoration on failure/expire
โ””โ”€โ”€ Comprehensive logging

๐Ÿ“ˆ Dashboard & Reports
โ”œโ”€โ”€ Sales stats widget (hari ini, bulan ini)
โ”œโ”€โ”€ Weekly revenue chart
โ”œโ”€โ”€ Pending payment counter
โ”œโ”€โ”€ Low stock alert
โ””โ”€โ”€ Recent orders table

Testing di Sandbox

Test Cards:

CREDIT CARD - SUCCESS:
โ”œโ”€โ”€ Number: 4811 1111 1111 1114
โ”œโ”€โ”€ CVV: 123
โ”œโ”€โ”€ Exp: 01/25 (any future)
โ””โ”€โ”€ OTP: 112233

CREDIT CARD - FAILURE:
โ”œโ”€โ”€ Number: 4911 1111 1111 1113
โ””โ”€โ”€ (akan declined)

CREDIT CARD - CHALLENGE:
โ”œโ”€โ”€ Number: 4511 1111 1111 1117
โ””โ”€โ”€ (fraud challenge)

E-Wallet & Bank Transfer:

  • Di Sandbox, semua e-wallet dan bank transfer akan generate QR/VA simulasi
  • Bisa langsung di-settle dari Midtrans Dashboard Sandbox

Simulate Payment di Dashboard:

  1. Login ke dashboard.sandbox.midtrans.com
  2. Pergi ke Transactions
  3. Cari order yang pending
  4. Klik Accept untuk simulate settlement

Checklist Go Production

BEFORE GO LIVE:

โ˜ Environment & Credentials
  โ”œโ”€โ”€ Ganti ke Production keys di Midtrans
  โ”œโ”€โ”€ Update .env: MIDTRANS_IS_PRODUCTION=true
  โ”œโ”€โ”€ Update MIDTRANS_CLIENT_KEY (production)
  โ”œโ”€โ”€ Update MIDTRANS_SERVER_KEY (production)
  โ””โ”€โ”€ Pastikan server key TIDAK exposed

โ˜ Webhook Configuration
  โ”œโ”€โ”€ Update webhook URL ke domain production
  โ”œโ”€โ”€ Test webhook dengan real transaction
  โ”œโ”€โ”€ Setup monitoring untuk webhook failures
  โ””โ”€โ”€ Handle duplicate webhooks (idempotent)

โ˜ Security
  โ”œโ”€โ”€ Enable HTTPS (wajib untuk production)
  โ”œโ”€โ”€ Verify signature di setiap webhook
  โ”œโ”€โ”€ Rate limiting untuk endpoints
  โ”œโ”€โ”€ Input validation
  โ””โ”€โ”€ SQL injection prevention (Eloquent OK)

โ˜ Error Handling
  โ”œโ”€โ”€ Proper try-catch di checkout
  โ”œโ”€โ”€ User-friendly error messages
  โ”œโ”€โ”€ Logging untuk debugging
  โ””โ”€โ”€ Alert untuk critical errors

โ˜ Testing
  โ”œโ”€โ”€ Test semua payment methods
  โ”œโ”€โ”€ Test failure scenarios
  โ”œโ”€โ”€ Test webhook dengan berbagai status
  โ”œโ”€โ”€ Load testing (opsional)
  โ””โ”€โ”€ User acceptance testing

โ˜ Monitoring
  โ”œโ”€โ”€ Monitor pending orders
  โ”œโ”€โ”€ Alert untuk expired orders
  โ”œโ”€โ”€ Track conversion rate
  โ””โ”€โ”€ Monitor Midtrans Dashboard

Tips & Best Practices

BEST PRACTICES:

1. Security
   โ”œโ”€โ”€ JANGAN pernah expose Server Key
   โ”œโ”€โ”€ Selalu verify webhook signature
   โ”œโ”€โ”€ Gunakan HTTPS di production
   โ””โ”€โ”€ Sanitize semua user input

2. Reliability
   โ”œโ”€โ”€ Simpan snap_token untuk retry
   โ”œโ”€โ”€ Handle duplicate webhooks
   โ”œโ”€โ”€ Set expiry time untuk pending orders
   โ””โ”€โ”€ Auto-expire old pending orders (cron)

3. User Experience
   โ”œโ”€โ”€ Show loading state saat checkout
   โ”œโ”€โ”€ Clear error messages
   โ”œโ”€โ”€ Retry payment option
   โ””โ”€โ”€ Email notification (opsional)

4. Monitoring
   โ”œโ”€โ”€ Log semua webhook requests
   โ”œโ”€โ”€ Monitor failed payments
   โ”œโ”€โ”€ Track payment success rate
   โ””โ”€โ”€ Alert untuk anomalies

5. Development
   โ”œโ”€โ”€ Gunakan Sandbox untuk testing
   โ”œโ”€โ”€ Test semua payment methods
   โ”œโ”€โ”€ Simulate berbagai scenarios
   โ””โ”€โ”€ Dokumentasikan flow untuk tim

Cron Job untuk Auto-Expire (Opsional)

Tambahkan command untuk auto-expire pending orders:

// app/Console/Commands/ExpirePendingOrders.php

namespace App\\Console\\Commands;

use App\\Models\\Order;
use App\\Models\\Product;
use Illuminate\\Console\\Command;

class ExpirePendingOrders extends Command
{
    protected $signature = 'orders:expire-pending';
    protected $description = 'Expire pending orders older than 24 hours';

    public function handle()
    {
        $expiredOrders = Order::where('status', 'pending')
            ->where('created_at', '<', now()->subHours(24))
            ->get();

        foreach ($expiredOrders as $order) {
            $order->update(['status' => 'expired']);

            // Restore stock
            foreach ($order->items as $item) {
                Product::where('id', $item->product_id)
                    ->increment('stock', $item->quantity);
            }

            $this->info("Expired: {$order->order_number}");
        }

        $this->info("Total expired: {$expiredOrders->count()} orders");
    }
}

Schedule di routes/console.php:

use Illuminate\\Support\\Facades\\Schedule;

Schedule::command('orders:expire-pending')->hourly();

Rekomendasi Kelas Gratis BuildWithAngga

๐ŸŽ“ KELAS GRATIS UNTUK BELAJAR LEBIH LANJUT:

1. Laravel Payment Gateway
   โ”œโ”€โ”€ Deep dive integrasi payment
   โ”œโ”€โ”€ Midtrans, Xendit, Stripe
   โ””โ”€โ”€ buildwithangga.com/kelas/laravel-payment-gateway

2. Filament Admin Panel
   โ”œโ”€โ”€ Filament lebih lengkap
   โ”œโ”€โ”€ Custom pages & widgets
   โ””โ”€โ”€ buildwithangga.com/kelas/filament-admin-panel

3. Laravel E-Commerce
   โ”œโ”€โ”€ Build full e-commerce app
   โ”œโ”€โ”€ Cart, checkout, payment
   โ””โ”€โ”€ buildwithangga.com/kelas/laravel-ecommerce

4. Laravel API Development
   โ”œโ”€โ”€ REST API untuk mobile
   โ”œโ”€โ”€ Authentication & authorization
   โ””โ”€โ”€ buildwithangga.com/kelas/laravel-api

5. Laravel Security Best Practices
   โ”œโ”€โ”€ Keamanan aplikasi
   โ”œโ”€โ”€ Payment security
   โ””โ”€โ”€ buildwithangga.com/kelas/laravel-security

6. Laravel Livewire
   โ”œโ”€โ”€ Reactive components
   โ”œโ”€โ”€ Real-time features
   โ””โ”€โ”€ buildwithangga.com/kelas/laravel-livewire

Penutup

Integrasi Midtrans dengan Filament memberikan solusi lengkap untuk aplikasi Point of Sales dengan pembayaran online. Dengan setup yang relatif simple, kamu bisa:

  • Terima pembayaran dari 20+ metode (bank transfer, e-wallet, kartu kredit)
  • Update status otomatis via webhook
  • Track semua transaksi di satu dashboard
  • Monitor penjualan dengan real-time stats

Project POS ini bisa dikembangkan lebih lanjut:

  • Multi-outlet support
  • Inventory management
  • Customer loyalty program
  • Reporting & analytics
  • Mobile app dengan API
  • Print struk thermal

Selamat coding! ๐Ÿš€


Resources: