Integrasi payment gateway pada arsitektur monolit modern seperti Laravel dan Inertia.js kerap memicu masalah konkurensi: race condition antara browser redirect dan asynchronous webhook. Saat pengguna menyelesaikan transaksi di payment gateway, dua event terpicu secara simultan: gateway mengirimkan webhook via HTTP POST ke backend, dan browser pengguna diarahkan kembali (HTTP GET redirect) ke aplikasi frontend.

Jika browser mendarat di halaman konfirmasi Inertia sebelum background worker selesai memproses webhook, query halaman akan membaca state database lama (stale state). Akibatnya, UI menampilkan status pembayaran masih menggantung (pending) atau bahkan gagal, yang memicu kebingungan pengguna hingga potensi mutasi ganda.

Anatomi Masalah: Race Condition Redirect vs Webhook

Gateway memproses HTTP POST webhook melalui antrean sistem pihak ketiga, yang dapat mengalami delay jaringan atau antrean worker di backend lokal. Sebaliknya, redirect browser dari payment gateway terjadi seketika secara langsung pada sisi client.

Dua risiko utama dari skenario ini:

  • Stale UI State: Controller Inertia mengambil data transaksi dari database saat statusnya belum diperbarui oleh webhook worker.
  • Double Mutation / Lost Update: Upaya sinkronisasi status secara manual pada endpoint redirect dapat bertabrakan dengan worker webhook yang sedang mengeksekusi record yang sama.

1. Isolasi Route Webhook dari Middleware Inertia & CSRF

Endpoint webhook ditujukan untuk komunikasi Machine-to-Machine (M2M). Endpoint ini harus diisolasi total dari stack middleware Inertia dan verifikasi token CSRF, namun wajib diverifikasi melalui HMAC signature.

Definisikan route di luar grup web standar (misalnya di routes/api.php):

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

Route::post('/webhooks/payment', PaymentWebhookController::class)
    ->name('webhooks.payment');

Verifikasi payload signature dilakukan sebelum memproses data. Tolak request tanpa signature yang valid secara dini:

namespace App\Http\Controllers;

use App\Services\PaymentService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class PaymentWebhookController
{
    public function __invoke(Request $request, PaymentService $paymentService): Response
    {
        $signature = $request->header('X-Signature-Key');
        $payload = $request->getContent();

        if (! $paymentService->isValidSignature($payload, $signature)) {
            return response('Invalid signature', 401);
        }

        $paymentService->processTransaction($request->input('order_id'), $request->input('status'));

        return response('Webhook handled', 200);
    }
}

2. Idempotensi Backend Menggunakan Atomic State Machine

Jangan mengandalkan pengecekan status in-memory seperti if ($order->status === 'pending') yang diikuti oleh $order->save(). Pola tersebut rentan terhadap race condition jika endpoint redirect dan webhook mengeksekusi mutasi pada milidetik yang sama.

Gunakan atomic update berbasis kondisi database. Pembaruan state hanya akan mempengaruhi record jika status saat ini berada pada kondisi transisi yang valid:

namespace App\Services;

use Illuminate\Support\Facades\DB;

class PaymentService
{
    public function processTransaction(string $orderId, string $incomingStatus): bool
    {
        return DB::transaction(function () use ($orderId, $incomingStatus) {
            // Update hanya berlaku jika status sekarang 'pending'
            $affected = DB::table('orders')
                ->where('id', $orderId)
                ->where('status', 'pending')
                ->update([
                    'status' => $incomingStatus === 'settlement' ? 'paid' : 'failed',
                    'updated_at' => now(),
                ]);

            if ($affected === 0) {
                // Record sudah diubah oleh proses lain atau status bukan pending
                return false;
            }

            // Eksekusi side-effect (pengiriman notifikasi, inventory, dsb)
            return true;
        });
    }
}

Dengan pendekatan ini, proses mana pun yang sampai lebih dahulu (baik webhook worker maupun sinkronisasi saat redirect) akan mengunci transisi state secara atomik. Proses berikutnya akan menghasilkan $affected === 0 tanpa merusak integritas data.

3. Pola Frontend Inertia: Partial Reload dengan Exponential Backoff

Saat pengguna dialihkan kembali ke aplikasi, kontroler halaman merender halaman ringkasan order. Jika status transaksi masih pending, frontend tidak boleh langsung menyimpulkan kegagalan. Komponen UI harus menjalankan polling berkala terencana menggunakan router.reload dengan opsi only.

Fitur partial reload Inertia memastikan server hanya mengevaluasi kembali properti tertentu tanpa merender ulang layout penuh atau memuat asset tambahan.

Implementasi Controller Redirect

namespace App\Http\Controllers;

use App\Models\Order;
use Inertia\Inertia;
use Inertia\Response;

class OrderSummaryController
{
    public function show(Order $order): Response
    {
        return Inertia::render('Orders/Summary', [
            'order' => fn () => [
                'id' => $order->id,
                'total' => $order->total,
                'status' => $order->status,
            ],
        ]);
    }
}

Implementasi Komponen UI (Vue 3)

Komponen mengecek properti status saat pertama dimuat. Jika statusnya masih pending, script menginisiasi polling dengan interval bertahap (backoff) dan membatasi percobaan maksimum.

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { router } from '@inertiajs/vue3';

const props = defineProps({
    order: Object
});

const pollAttempts = ref(0);
const maxAttempts = 5;
const currentInterval = ref(2000); // Mulai dari 2 detik
let timer = null;

function pollOrderStatus() {
    if (props.order.status !== 'pending' || pollAttempts.value >= maxAttempts) {
        return;
    }

    timer = setTimeout(() => {
        pollAttempts.value++;
        
        router.reload({
            only: ['order'],
            onSuccess: () => {
                if (props.order.status === 'pending') {
                    currentInterval.value = Math.min(currentInterval.value * 1.5, 10000);
                    pollOrderStatus();
                }
            },
            onError: () => {
                // Hentikan polling jika terjadi network failure
                clearTimeout(timer);
            }
        });
    }, currentInterval.value);
}

onMounted(() => {
    if (props.order.status === 'pending') {
        pollOrderStatus();
    }
});

onUnmounted(() => {
    if (timer) {
        clearTimeout(timer);
    }
});
</script>

<template>
    <div class="order-card">
        <h1>Pesanan #{{ order.id }}</h1>
        <p>Total: {{ order.total }}</p>
        
        <div v-if="order.status === 'paid'" class="badge success">
            Pembayaran Berhasil
        </div>
        <div v-else-if="order.status === 'pending'" class="badge warning">
            Menunggu Konfirmasi Pembayaran...
            <span v-if="pollAttempts > 0">(Sinkronisasi data ke-{{ pollAttempts }})</span>
        </div>
        <div v-else class="badge danger">
            Pembayaran Gagal atau Kedaluwarsa
        </div>
    </div>
</template>

4. Fallback Terakhir: Server-Side Sync on Timeout

Jika batas waktu polling tercapai (misalnya setelah 5 kali percobaan) dan status database tetap pending, jangan biarkan pengguna terombang-ambing. Berikan tombol aksi manual "Cek Status Pembayaran" yang mengarahkan ke controller khusus untuk melakukan inquiry langsung via API payment gateway secara sinkron ke server gateway.

Peringatan Keamanan: Jangan mengeksekusi direct inquiry API secara otomatis pada setiap request HTTP GET redirect. Ini dapat menyebabkan IP server dibatasi (rate limit) oleh gateway jika ada lonjakan traffic.

Ringkasan Arsitektur

  • Webhook Route: Stateless, tanpa middleware session/CSRF, wajib validasi HMAC signature.
  • Idempotensi Backend: Kunci transisi state menggunakan database condition update untuk mengeliminasi concurrency bug.
  • Client Polling: Gunakan router.reload({ only: [...] }) dengan interval dinamis (exponential backoff) untuk meminimalkan beban transfer data.
  • Cleanup: Selalu bersihkan timer pada lifecycle hook onUnmounted frontend untuk mencegah memory leak.