Home / Artikel / Web Development
Web Development

Tombol Bayar Diklik Dua Kali? Cara Mencegah Pesanan Dobel di PHP dan MySQL

Pesanan ganda tidak selalu terjadi karena pengguna ceroboh. Jaringan lambat, tombol yang belum dinonaktifkan, atau request yang terkirim ulang bisa membuat satu tindakan tercatat dua kali. Dengan idempotency key, unique…

Tombol Bayar Diklik Dua Kali? Cara Mencegah Pesanan Dobel di PHP dan MySQL

Satu kali klik seharusnya menghasilkan satu pesanan. Namun dalam aplikasi web, hal itu tidak selalu terjadi. Pengguna bisa mengklik tombol bayar dua kali, browser dapat mengulang request ketika koneksi tidak stabil, atau layanan pembayaran mengirim webhook lebih dari sekali.

Jika endpoint hanya menjalankan INSERT setiap kali menerima request, hasilnya bisa berupa dua pesanan, dua pengurangan stok, atau dua pencatatan pembayaran. Menonaktifkan tombol di JavaScript memang membantu, tetapi itu bukan perlindungan utama. Request tetap bisa dikirim ulang dari browser, aplikasi mobile, proxy, atau sistem eksternal.

Pola yang lebih kuat disebut idempotency: request yang sama boleh diproses berkali-kali, tetapi efek akhirnya tetap hanya terjadi satu kali.

Idempotency dengan analogi sederhana

Bayangkan Anda menyerahkan formulir pembayaran dengan nomor referensi unik. Petugas boleh menerima formulir itu beberapa kali, tetapi ketika melihat nomor referensi yang sama, ia tahu bahwa formulir tersebut sudah diproses.

Dalam aplikasi, nomor referensi itu disebut idempotency key. Kunci ini dibuat oleh klien atau server sebelum proses penting dimulai, lalu dikirim bersama request. Contohnya:

  • order-8f3a... untuk pembuatan pesanan.
  • payment-2026-00091 untuk pembayaran.
  • ID event dari penyedia pembayaran untuk webhook.

Kunci tersebut harus disimpan di database dengan aturan unik. Jangan hanya memeriksa menggunakan SELECT lalu melakukan INSERT, karena dua request yang datang hampir bersamaan bisa sama-sama lolos dari pemeriksaan.

Jangan hanya mengandalkan JavaScript

Pada sisi antarmuka, tombol dapat dinonaktifkan setelah diklik agar pengguna tidak mengirim permintaan berulang secara tidak sengaja. Ini bagus untuk pengalaman pengguna, tetapi bukan mekanisme keamanan.

JavaScript bisa gagal dimuat, browser bisa melakukan retry, dan request dapat dikirim langsung tanpa melalui halaman yang Anda buat. Karena itu, pencegahan duplikasi harus berada di sisi server dan database.

Database memiliki posisi yang lebih tepat untuk menjaga aturan “satu kunci hanya boleh muncul sekali”. Pada MySQL, buat kolom idempotency key dengan UNIQUE index:

CREATE TABLE orders (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    idempotency_key VARCHAR(100) NOT NULL,
    status ENUM('pending', 'paid', 'failed') NOT NULL DEFAULT 'pending',
    total DECIMAL(12,2) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uq_orders_idempotency (idempotency_key)
) ENGINE=InnoDB;

Dengan aturan ini, dua request dengan kunci yang sama tidak bisa membuat dua baris pesanan. Salah satu request akan berhasil, sementara request berikutnya harus diperlakukan sebagai pengulangan dari operasi yang sudah ada.

Gabungkan dengan transaction

Transaction adalah kumpulan perubahan database yang diperlakukan sebagai satu unit. Jika seluruh proses berhasil, aplikasi menjalankan COMMIT. Jika ada kegagalan, perubahan dapat dibatalkan dengan ROLLBACK.

Di PHP, PDO menyediakan beginTransaction(), commit(), dan rollBack(). Transaction cocok digunakan ketika pembuatan pesanan melibatkan beberapa langkah, misalnya menyimpan order, menyimpan detail produk, dan mengurangi stok.

<?php
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$key = $_POST['idempotency_key'] ?? '';
$userId = 17;
$total = 149000;

if ($key === '' || strlen($key) > 100) {
    http_response_code(422);
    exit('Idempotency key tidak valid');
}

try {
    $pdo->beginTransaction();

    $insert = $pdo->prepare(
        'INSERT INTO orders (user_id, idempotency_key, total)
         VALUES (:user_id, :key, :total)'
    );
    $insert->execute([
        ':user_id' => $userId,
        ':key' => $key,
        ':total' => $total
    ]);

    $orderId = $pdo->lastInsertId();

    // Simpan detail order dan kurangi stok di sini.

    $pdo->commit();
    echo json_encode([
        'order_id' => $orderId,
        'status' => 'created'
    ]);
} catch (PDOException $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    // SQLSTATE 23000 umumnya menandakan pelanggaran constraint.
    if ($e->getCode() === '23000') {
        $find = $pdo->prepare(
            'SELECT id, status FROM orders WHERE idempotency_key = :key'
        );
        $find->execute([':key' => $key]);
        $existing = $find->fetch(PDO::FETCH_ASSOC);

        if ($existing) {
            echo json_encode([
                'order_id' => $existing['id'],
                'status' => $existing['status'],
                'duplicate' => true
            ]);
            exit;
        }
    }

    http_response_code(500);
    echo json_encode(['error' => 'Pesanan gagal diproses']);
}

Contoh tersebut menggunakan prepared statement agar nilai dari request tidak ditempel langsung ke SQL. Detail implementasinya perlu disesuaikan dengan struktur aplikasi, tetapi idenya penting: database yang memutuskan apakah kunci sudah pernah digunakan.

Jangan menyamakan semua duplicate request

Ketika request kedua datang, server perlu mengetahui apakah request itu benar-benar sama dengan request pertama. Karena itu, selain menyimpan kunci, Anda dapat menyimpan beberapa informasi tambahan:

  • Payload atau hash dari isi request.
  • Status proses, misalnya pending, paid, atau failed.
  • Respons terakhir yang dikirim kepada klien.
  • Waktu kedaluwarsa kunci jika operasi hanya berlaku dalam periode tertentu.

Jika kunci yang sama dikirim dengan total belanja berbeda, jangan diam-diam menggunakan data baru. Tolak request tersebut karena kemungkinan ada kesalahan klien atau penyalahgunaan kunci.

Transaction bukan pengganti idempotency

Transaction menjaga konsistensi beberapa perubahan dalam satu proses. Namun transaction saja tidak otomatis mencegah request yang sama membuat dua pesanan. Dua request bisa saja masing-masing menjalankan transaction dan sama-sama berhasil.

Sebaliknya, unique constraint saja juga belum cukup bila satu proses terdiri dari banyak perubahan. Pesanan mungkin berhasil dibuat, tetapi stok gagal dikurangi. Kombinasi yang lebih aman adalah:

  1. Gunakan idempotency key untuk mengenali operasi yang sama.
  2. Gunakan unique constraint untuk mencegah duplikasi secara atomik.
  3. Gunakan transaction untuk mengikat beberapa perubahan terkait.
  4. Tangani error duplicate sebagai hasil pengulangan, bukan selalu sebagai kegagalan.

Perhatikan deadlock dan proses yang terlalu panjang

Transaction sebaiknya dibuat singkat. Jangan mengirim email, memanggil API pembayaran, atau menunggu proses eksternal ketika transaction database masih terbuka. Semakin lama transaction berjalan, semakin lama pula lock dapat dipertahankan.

MySQL mendokumentasikan bahwa deadlock dapat membuat seluruh transaction dibatalkan, sehingga aplikasi perlu siap mengulang transaction dalam kondisi tertentu. Karena itu, simpan perubahan inti secepat mungkin, lalu jalankan pekerjaan lanjutan melalui queue atau proses asinkron jika memang diperlukan.

Apa artinya bagi kita?

Duplikasi pesanan adalah masalah desain alur, bukan sekadar masalah tombol yang terlalu cepat diklik. Mulailah dari operasi yang berdampak langsung pada uang, stok, kuota, atau pengiriman notifikasi.

Tambahkan idempotency key pada endpoint tersebut, pasang unique index di database, dan bungkus perubahan terkait dengan transaction. Setelah itu, uji skenario yang sering dilupakan: klik dua kali, refresh saat proses berjalan, retry karena timeout, dua request paralel, dan webhook yang dikirim ulang.

Dokumentasi transaction PDO menjelaskan penggunaan commit dan rollback, sedangkan dokumentasi InnoDB membahas duplicate key, deadlock, dan lock timeout. Keduanya layak dibaca sebelum menerapkan pola ini di sistem produksi.

Sumber & bacaan lebih lanjut

– Rio Yotto @rioyotto