Bagian 4 menutup dengan isu uang: order bisa ditempatkan, direservasi, dan dibatalkan, tetapi belum ada satu rupiah pun yang berpindah tangan. Pada bagian ini kita menambahkan pembayaran — dan bersamanya satu keluarga masalah baru: gateway eksternal yang lambat dan bisa gagal, callback yang datang tidak menentu, serta bahaya paling mahal di dunia pembayaran, yaitu order yang terbayar dua kali. Jawaban kita ada tiga lapis: PaymentGatewayInterface menyembunyikan detail penyedia di balik kontrak kecil, tabel payments menjaga idempotensi lewat kolom order_id UNIQUE, dan PaymentService hanya mengizinkan order pending untuk membayar dengan meminjam mesin transisi status Bagian 3. Sebagai bonus, FakeGateway yang deterministik membuktikan bahwa seluruh alur bisa diuji tanpa jaringan sama sekali — dan dipakai lagi oleh test sungguhan pada Bagian 7.

Kenapa Gateway Dibalik Interface #

Penyedia payment gateway nyata (Midtrans, Xendit, Stripe, Doku) bekerja lewat HTTP: kita mengirim detail transaksi, mereka membalas halaman redirect atau VA number, lalu mengirim callback ketika uang benar-benar masuk. Jika PaymentService menulis panggilan curl langsung di dalam logika bisnisnya, tiga masalah muncul sekaligus. Pertama, setiap ganti penyedia berarti menulis ulang service. Kedua, test otomatis menjadi lambat dan rapuh karena bergantung pada jaringan dan server pihak ketiga. Ketiga, simulasi kegagalan (transaksi ditolak, callback terlambat) mustahil dilakukan dengan konsisten.

Interface memutus simpul itu dengan membalik arah dependensi: PaymentService mendeklarasikan dua kemampuan yang ia butuhkan, dan siapa pun yang memenuhi kontrak tersebut boleh disuntikkan lewat constructor (dependency injection). Di produksi kita suntikkan klien HTTP sungguhan; di demo dan test kita suntikkan FakeGateway yang mengembalikan nilai tetap. Tidak ada satu baris pun di PaymentService yang berubah antara dua dunia itu — inilah yang membuat abstraksi ini layak dijaga.

Kontrak PaymentGatewayInterface #

Seluruh kebutuhan service kita terhadap dunia luar hanya dua method:

php
<?php

declare(strict_types=1);

/**
 * Kontrak yang harus dipenuhi setiap payment gateway.
 *
 * Service pembayaran kita hanya mengenal dua kemampuan: meminta pembayaran
 * baru dan menanyakan status pembayaran yang sudah ada. Semua detail
 * teknis penyedia (HTTP, autentikasi, format payload) berada di belakang
 * kontrak ini sehingga implementasi bisa ditukar tanpa menyentuh
 * PaymentService — dari FakeGateway di lingkungan uji sampai klien
 * gateway sungguhan di produksi.
 */
interface PaymentGatewayInterface
{
    /**
     * Minta gateway membuat transaksi pembayaran untuk satu order.
     *
     * $jumlah dalam satuan sen (Rp1 = 100 sen), mengikuti konvensi uang
     * proyek. Status yang mungkin dikembalikan: 'pending', 'settlement',
     * atau 'gagal'.
     *
     * @param int $orderId id order yang akan dibayar
     * @param int $jumlah  total tagihan dalam sen
     *
     * @return array{id: string, status: string, redirectUrl: string|null}
     *         id adalah pengenal transaksi di sisi gateway, redirectUrl
     *         adalah halaman pembayaran untuk pelanggan bila gateway
     *         memakai alur redirect (null bila tidak).
     */
    public function buatPembayaran(int $orderId, int $jumlah): array;

    /**
     * Tanyakan status terkini satu transaksi ke gateway.
     *
     * Dipakai saat memproses callback/webhook dan saat polling status.
     * Status yang mungkin dikembalikan: 'pending', 'settlement', 'gagal',
     * atau 'tidak-dikenal' bila gateway tidak mengenal payment id.
     */
    public function statusPembayaran(string $paymentId): string;
}

Dua keputusan pada kontrak ini perlu digarisbawahi. Pertama, nilai balik buatPembayaran() adalah array berbentuk tetap (id, status, redirectUrl) yang didokumentasikan lewat array shape pada PHPDoc — gaya yang sama dengan OrderRepository sehingga pembaca proyek tidak perlu mempelajari konvensi baru. Kedua, nama status mengikuti kosakata gateway nyata: pending saat transaksi baru dibuat, settlement saat dana tercatat masuk, gagal saat ditolak. Menyimpan redirectUrl sebagai string|null mengakomodasi gateway berbasis redirect sekaligus yang tidak memakainya.

Tabel payments dan Migrasinya #

Setiap transaksi pembayaran kita rekam pada tabelnya sendiri, terpisah dari orders — status order dan status pembayaran adalah dua hal berbeda yang saling mengejar. Kolom kuncinya adalah order_id yang UNIQUE: satu order hanya boleh memiliki satu baris pembayaran, dijaga database itu sendiri, bukan hanya oleh kode aplikasi:

php
<?php
// Simulasi database yang dibangun sebelum tabel payments dirancang.
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(<<<'SQL'
CREATE TABLE orders (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    customer_id INTEGER NOT NULL,
    status      TEXT    NOT NULL DEFAULT 'pending',
    created_at  TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO orders (customer_id) VALUES (1);
SQL);

function migrasiPayments(PDO $pdo): void
{
    $ada = (int) $pdo->query(
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'payments'"
    )->fetchColumn();
    if ($ada === 1) {
        echo "Tabel payments sudah ada, tidak ada yang dilakukan.\n";
        return;
    }

    $pdo->exec(<<<'SQL'
    CREATE TABLE payments (
        id           INTEGER PRIMARY KEY AUTOINCREMENT,
        order_id     INTEGER NOT NULL UNIQUE REFERENCES orders(id),
        gateway      TEXT    NOT NULL,
        payment_id   TEXT    NOT NULL,
        jumlah       INTEGER NOT NULL CHECK (jumlah > 0),
        status       TEXT    NOT NULL CHECK (status IN ('pending', 'settlement', 'gagal')),
        redirect_url TEXT,
        created_at   TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
    );
    CREATE INDEX idx_payments_order ON payments(order_id);
    SQL);
    echo "Tabel payments dibuat oleh migrasi.\n";
}

migrasiPayments($pdo);
migrasiPayments($pdo);

$pdo->exec(
    "INSERT INTO payments (order_id, gateway, payment_id, jumlah, status)
     VALUES (1, 'FakeGateway', 'FAKE-0001', 125000000, 'pending')"
);
try {
    $pdo->exec(
        "INSERT INTO payments (order_id, gateway, payment_id, jumlah, status)
         VALUES (1, 'FakeGateway', 'FAKE-0001B', 125000000, 'pending')"
    );
} catch (PDOException $e) {
    echo "Insert kedua ditolak database: {$e->getMessage()}\n";
}

Output yang Dihasilkan:

text
Tabel payments dibuat oleh migrasi.
Tabel payments sudah ada, tidak ada yang dilakukan.
Insert kedua ditolak database: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: payments.order_id

Pemanggilan migrasi kedua membuktikan sifat idempoten yang dipinjam dari pola Bagian 4. Bagian terpenting justru percobaan terakhir: INSERT kedua untuk order #1 ditolak SQLite dengan pelanggaran UNIQUE, meskipun payment_id-nya berbeda. Idempotensi pembayaran kita sandarkan pada identitas order, bukan identitas transaksi gateway — pelanggan boleh menekan tombol bayar sepuluh kali, tetapi database hanya akan menyimpan satu baris.

Class PaymentService #

Berikut isi lengkap src/PaymentService.php. Constructor menerima PDO, OrderRepository, dan PaymentGatewayInterface — nama interface sebagai type declaration inilah pintu masuk dependency injection:

php
<?php

declare(strict_types=1);

/**
 * Lapisan pembayaran order: berbicara ke gateway lewat
 * PaymentGatewayInterface, mencatat setiap transaksi pada tabel
 * payments, menjaga idempotensi satu pembayaran per order, dan
 * menyinkronkan status order lewat mesin transisi Bagian 3.
 *
 * Satu order hanya boleh memiliki satu baris payments — dijaga kolom
 * order_id yang UNIQUE. Pemanggilan bayar() kedua kali untuk order yang
 * sama mengembalikan baris pembayaran yang sudah ada tanpa menghubungi
 * gateway lagi.
 *
 * Membutuhkan OrderRepository, OrderService, dan
 * TransisiTidakValidException dari bagian-bagian sebelumnya.
 */
final class PaymentService
{
    public function __construct(
        private PDO $pdo,
        private OrderRepository $repository,
        private PaymentGatewayInterface $gateway,
    ) {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Bayar satu order: buat transaksi di gateway lalu catat baris
     * payments berstatus pending.
     *
     * Idempoten per order: bila baris pembayaran sudah ada, method ini
     * mengembalikan baris tersebut apa pun statusnya dan TIDAK memanggil
     * gateway untuk kedua kalinya. Order yang bukan pending (sudah
     * dibayar, batal, atau status lain) ditolak oleh mesin transisi.
     *
     * @return array<string, mixed> baris payments untuk order ini
     */
    public function bayar(int $orderId): array
    {
        $existing = $this->cariPayment($orderId);
        if ($existing !== null) {
            return $existing;
        }

        $order = $this->repository->cariId($orderId);
        if ($order === null) {
            throw new InvalidArgumentException("Order #$orderId tidak ditemukan.");
        }

        if (!OrderService::bolehTransisi((string) $order['status'], 'dibayar')) {
            throw new TransisiTidakValidException(sprintf(
                'Order #%d berstatus %s; hanya order pending yang boleh dibayar.',
                $orderId,
                $order['status']
            ));
        }

        $hasil = $this->gateway->buatPembayaran($orderId, (int) $order['total']);

        $this->pdo->beginTransaction();
        try {
            $this->pdo->prepare(
                'INSERT INTO payments (order_id, gateway, payment_id, jumlah, status, redirect_url)
                 VALUES (?, ?, ?, ?, ?, ?)'
            )->execute([
                $orderId,
                get_class($this->gateway),
                $hasil['id'],
                (int) $order['total'],
                $hasil['status'],
                $hasil['redirectUrl'],
            ]);

            $this->pdo->commit();
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }

        $payment = $this->cariPayment($orderId);
        if ($payment === null) {
            throw new RuntimeException("Baris pembayaran order #$orderId hilang setelah ditulis.");
        }

        return $payment;
    }

    /**
     * Proses kabar dari gateway (simulasi callback/webhook) untuk satu
     * order: tanyakan status terkini, lalu finalisasi baris payments dan
     * status order dalam SATU transaksi.
     *
     * - status gateway 'settlement' -> payments.status = 'settlement'
     *   dan order menjadi 'dibayar' (divalidasi mesin transisi);
     * - status gateway 'gagal'      -> payments.status = 'gagal',
     *   status order tidak berubah;
     * - status gateway 'pending'    -> tidak ada yang berubah.
     *
     * Idempoten: baris pembayaran yang sudah final (settlement/gagal)
     * dikembalikan apa adanya tanpa menghubungi gateway lagi.
     *
     * @return array<string, mixed> baris payments setelah diproses
     */
    public function konfirmasi(int $orderId): array
    {
        $payment = $this->cariPayment($orderId);
        if ($payment === null) {
            throw new InvalidArgumentException(
                "Order #$orderId belum memiliki pembayaran; panggil bayar() lebih dulu."
            );
        }

        if ($payment['status'] !== 'pending') {
            return $payment;
        }

        $statusGateway = $this->gateway->statusPembayaran((string) $payment['payment_id']);
        if (!in_array($statusGateway, ['pending', 'settlement', 'gagal'], true)) {
            throw new RuntimeException("Status gateway tidak dikenal: $statusGateway");
        }

        if ($statusGateway === 'pending') {
            return $payment;
        }

        $this->pdo->beginTransaction();
        try {
            $this->pdo->prepare('UPDATE payments SET status = ? WHERE id = ?')
                ->execute([$statusGateway, $payment['id']]);

            if ($statusGateway === 'settlement') {
                $stmt = $this->pdo->prepare('SELECT status FROM orders WHERE id = ?');
                $stmt->execute([$orderId]);
                $statusOrder = (string) $stmt->fetchColumn();

                if (!OrderService::bolehTransisi($statusOrder, 'dibayar')) {
                    throw new TransisiTidakValidException(sprintf(
                        'Settlement ditolak: order #%d kini berstatus %s, transisi ke dibayar tidak diizinkan.',
                        $orderId,
                        $statusOrder
                    ));
                }

                $this->pdo->prepare("UPDATE orders SET status = 'dibayar' WHERE id = ?")
                    ->execute([$orderId]);
            }

            $this->pdo->commit();
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }

        $payment = $this->cariPayment($orderId);
        if ($payment === null) {
            throw new RuntimeException("Baris pembayaran order #$orderId hilang setelah ditulis.");
        }

        return $payment;
    }

    /**
     * Baris payments milik satu order; null bila order belum pernah
     * membayar (dijamin unik oleh constraint order_id UNIQUE).
     *
     * @return array<string, mixed>|null
     */
    public function cariPayment(int $orderId): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM payments WHERE order_id = ?');
        $stmt->execute([$orderId]);
        $payment = $stmt->fetch(PDO::FETCH_ASSOC);

        return $payment === false ? null : $payment;
    }
}

Empat keputusan pada class tersebut patut dibedah:

  1. Idempotensi diperiksa paling awal — bayar() membuka jalurnya dengan cariPayment(); bila baris sudah ada, baris itu dikembalikan langsung tanpa menyentuh gateway dan tanpa memedulikan status order saat ini. Karena itu perubahan status setelah pembayaran (order jadi dikirim, misalnya) tidak membuat panggilan ulang bayar() melempar exception.
  2. Gateway dipanggil di luar transaksi database — buatPembayaran() adalah panggilan eksternal yang lambat; membungkusnya dalam beginTransaction() akan menahan kunci database jauh lebih lama dari yang perlu. Transaksi hanya membungkus penulisan baris payments setelah jawaban gateway diterima.
  3. Mesin status dipakai dua kali — bolehTransisi($order['status'], 'dibayar') menjaga gerbang bayar() (hanya order pending), lalu diulang di dalam konfirmasi() sebelum mengubah status. Pemeriksaan kedua ini menangkap perlombaan yang nyata: order bisa saja dibatalkan di antara bayar() dan datangnya settlement, dan penolakan transisi memastikan perubahan status pembayaran ikut ter-rollBack().
  4. Kolom gateway menyimpan get_class() — baris pembayaran mencatat siapa yang memprosesnya, membuat laporan Bagian 6 bisa membedakan transaksi per penyedia.

FakeGateway untuk Pengujian #

Test double untuk gateway harus deterministik: hasil yang sama untuk masukan yang sama, kapan pun test dijalankan. src/FakeGateway.php memenuhinya dengan menghilangkan jaringan dan ketidakpastian sekaligus — payment id diturunkan dari orderId lewat sprintf(), transaksi baru selalu pending, dan perpindahan status hanya terjadi bila kode uji secara eksplisit mensimulasikan callback:

php
<?php

declare(strict_types=1);

require_once __DIR__ . '/PaymentGatewayInterface.php';

/**
 * Test double gateway: implementasi PaymentGatewayInterface yang
 * sepenuhnya deterministik untuk pengujian dan demo.
 *
 * Tidak ada jaringan, tidak ada kebetulan: payment id selalu
 * FAKE-<orderId empat digit>, transaksi baru selalu berstatus pending,
 * dan perpindahan ke settlement/gagal hanya terjadi bila kode uji
 * memanggil selesaikan()/gagalkan() — meniru callback dari gateway
 * sungguhan. Penghitung $panggilan membiarkan assertion membuktikan
 * bahwa bayar() bersifat idempoten (gateway tidak dihubungi dua kali).
 */
final class FakeGateway implements PaymentGatewayInterface
{
    /** Berapa kali buatPembayaran() dipanggil — untuk assertion idempotensi. */
    public int $panggilan = 0;

    /** @var array<string, string> payment id => status terkini */
    private array $status = [];

    public function buatPembayaran(int $orderId, int $jumlah): array
    {
        $this->panggilan++;
        $paymentId = sprintf('FAKE-%04d', $orderId);
        $this->status[$paymentId] = 'pending';

        return [
            'id'          => $paymentId,
            'status'      => 'pending',
            'redirectUrl' => "https://pay.example.com/$paymentId",
        ];
    }

    public function statusPembayaran(string $paymentId): string
    {
        return $this->status[$paymentId] ?? 'tidak-dikenal';
    }

    /**
     * Simulasi callback sukses dari gateway: transaksi menjadi settlement.
     */
    public function selesaikan(string $paymentId): void
    {
        $this->status[$paymentId] = 'settlement';
    }

    /**
     * Simulasi callback kegagalan dari gateway.
     */
    public function gagalkan(string $paymentId): void
    {
        $this->status[$paymentId] = 'gagal';
    }
}

Method selesaikan() dan gagalkan() tidak ada di interface — dan memang seharusnya tidak, karena mengubah status transaksi adalah hak gateway, bukan service. Kode uji memegang objek FakeGateway secara langsung sehingga method simulasi itu bisa dipanggil dari luar, sementara PaymentService hanya melihat dua method kontrak. Penghitung $panggilan yang juga bukan bagian kontrak menjadi saksi idempotensi: setelah walkthrough selesai, angkanya tidak boleh bertambah meski bayar() dipanggil lagi.

Walkthrough Alur Pembayaran Lengkap #

Walkthrough berikut menjalankan kisah utuh dalam satu skrip: order ditempatkan, dibayar (pending), callback settlement disimulasikan, order menjadi dibayar, lalu tiga percobaan abusif dijawab — bayar ulang, bayar order yang dibatalkan, dan settlement yang datang terlambat setelah order batal:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/src/PaymentGatewayInterface.php';
require __DIR__ . '/src/PaymentService.php';
require __DIR__ . '/src/FakeGateway.php';

function rupiah(int $sen): string
{
    return sprintf('Rp%s,%02d', number_format(intdiv($sen, 100), 0, ',', '.'), $sen % 100);
}

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-model-pelanggan-dan-produk/schema.sql'));
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS payments (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id     INTEGER NOT NULL UNIQUE REFERENCES orders(id),
    gateway      TEXT    NOT NULL,
    payment_id   TEXT    NOT NULL,
    jumlah       INTEGER NOT NULL CHECK (jumlah > 0),
    status       TEXT    NOT NULL CHECK (status IN ('pending', 'settlement', 'gagal')),
    redirect_url TEXT,
    created_at   TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);
SQL);

$repo = new OrderRepository($pdo);
$orderService = new OrderService($pdo, $repo);
$gateway = new FakeGateway();
$paymentService = new PaymentService($pdo, $repo, $gateway);

echo "== Order ditempatkan ==\n";
$orderId = $orderService->placeOrder(1, [[1, 1], [2, 2]]);
$order = $repo->cariId($orderId);
printf("Order #%d total %s, status %s\n\n", $orderId, rupiah((int) $order['total']), $order['status']);

echo "== bayar(): transaksi pending di gateway ==\n";
$payment = $paymentService->bayar($orderId);
printf("payment_id  : %s\n", $payment['payment_id']);
printf("jumlah      : %s\n", rupiah((int) $payment['jumlah']));
printf("status      : %s\n", $payment['status']);
printf("redirect_url: %s\n", $payment['redirect_url']);
printf("panggilan gateway: %d\n\n", $gateway->panggilan);

echo "== Callback settlement diterima, konfirmasi() ==\n";
$gateway->selesaikan((string) $payment['payment_id']);
$payment = $paymentService->konfirmasi($orderId);
printf("payment status: %s\n", $payment['status']);
printf("order status  : %s\n\n", $repo->cariId($orderId)['status']);

echo "== bayar() kedua: idempoten ==\n";
$payment = $paymentService->bayar($orderId);
printf("payment_id: %s, status: %s\n", $payment['payment_id'], $payment['status']);
printf("panggilan gateway tetap: %d\n\n", $gateway->panggilan);

echo "== Order batal ditolak ==\n";
$batal = $orderService->placeOrder(2, [[3, 1]]);
$orderService->ubahStatus($batal, 'batal');
try {
    $paymentService->bayar($batal);
} catch (TransisiTidakValidException $e) {
    echo "Ditolak: {$e->getMessage()}\n";
}

echo "\n== Settlement terlambat setelah order batal ==\n";
$terlambat = $orderService->placeOrder(3, [[6, 1]]);
$payment = $paymentService->bayar($terlambat);
$orderService->ubahStatus($terlambat, 'batal');
$gateway->selesaikan((string) $payment['payment_id']);
try {
    $paymentService->konfirmasi($terlambat);
} catch (TransisiTidakValidException $e) {
    echo "Ditolak: {$e->getMessage()}\n";
}
printf("Payment order #%d tetap: %s\n", $terlambat,
    $paymentService->cariPayment($terlambat)['status']);

Output yang Dihasilkan:

text
== Order ditempatkan ==
Order #1 total Rp1.250.000,00, status pending

== bayar(): transaksi pending di gateway ==
payment_id  : FAKE-0001
jumlah      : Rp1.250.000,00
status      : pending
redirect_url: https://pay.example.com/FAKE-0001
panggilan gateway: 1

== Callback settlement diterima, konfirmasi() ==
payment status: settlement
order status  : dibayar

== bayar() kedua: idempoten ==
payment_id: FAKE-0001, status: settlement
panggilan gateway tetap: 1

== Order batal ditolak ==
Ditolak: Order #2 berstatus batal; hanya order pending yang boleh dibayar.

== Settlement terlambat setelah order batal ==
Ditolak: Settlement ditolak: order #3 kini berstatus batal, transisi ke dibayar tidak diizinkan.
Payment order #3 tetap: pending

Bacalah output tersebut sebagai kronologi. Setelah bayar(), baris pembayaran berstatus pending dan order masih pending — uang belum masuk, tidak ada yang bohong. Simulasi callback (selesaikan()) lalu membuat konfirmasi() mengubah dua hal sekaligus dalam satu transaksi: pembayaran menjadi settlement dan order menjadi dibayar. Percobaan bayar() kedua mengembalikan baris yang sama dengan penghitung gateway tetap 1 — inilah idempotensi yang bekerja, bukan sekadar dijanjikan.

Dua skenario penolakan di bagian akhir menunjukkan mesin status sebagai penjaga konsistensi. Order yang dibatalkan tidak bisa dibayar lagi (batal adalah status terminal). Kasus terakhir adalah yang paling halus: uang benar-benar masuk setelah order dibatalkan, dan konfirmasi() menolak mengubah order menjadi dibayar — pembayaran tetap pending karena UPDATE-nya ter-rollBack() bersama transaksi. Di sistem produksi, kejadian seperti ini ditindaklanjuti proses refund; bagi kita, cukup memastikan status order tidak pernah berbohong.

Bukti terakhir kekuatan abstraksi: kita definisikan gateway lain yang selalu gagal — misalnya penyedia yang menolak transaksi saat saldonya bermasalah — lalu jalankan alur yang sama. PaymentService tidak diubah satu karakter pun:

php
<?php
require __DIR__ . '/../bagian-2-order-dan-order-items/src/OrderRepository.php';
require __DIR__ . '/../bagian-3-order-processing/src/OrderService.php';
require __DIR__ . '/src/PaymentGatewayInterface.php';
require __DIR__ . '/src/PaymentService.php';

final class GatewaySelaluGagal implements PaymentGatewayInterface
{
    public function buatPembayaran(int $orderId, int $jumlah): array
    {
        return [
            'id'          => sprintf('GAGAL-%04d', $orderId),
            'status'      => 'gagal',
            'redirectUrl' => null,
        ];
    }

    public function statusPembayaran(string $paymentId): string
    {
        return 'gagal';
    }
}

function rupiah(int $sen): string
{
    return sprintf('Rp%s,%02d', number_format(intdiv($sen, 100), 0, ',', '.'), $sen % 100);
}

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-model-pelanggan-dan-produk/schema.sql'));
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS payments (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id     INTEGER NOT NULL UNIQUE REFERENCES orders(id),
    gateway      TEXT    NOT NULL,
    payment_id   TEXT    NOT NULL,
    jumlah       INTEGER NOT NULL CHECK (jumlah > 0),
    status       TEXT    NOT NULL CHECK (status IN ('pending', 'settlement', 'gagal')),
    redirect_url TEXT,
    created_at   TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);
SQL);

$repo = new OrderRepository($pdo);
$gateway = new GatewaySelaluGagal();
$paymentService = new PaymentService($pdo, $repo, $gateway);

$orderId = (new OrderService($pdo, $repo))->placeOrder(1, [[5, 1]]);
$payment = $paymentService->bayar($orderId);

printf("Gateway aktif : %s\n", get_class($gateway));
printf("payment_id    : %s\n", $payment['payment_id']);
printf("jumlah        : %s\n", rupiah((int) $payment['jumlah']));
printf("status payment: %s\n", $payment['status']);
printf("status order  : %s\n", $repo->cariId($orderId)['status']);

Output yang Dihasilkan:

text
Gateway aktif : GatewaySelaluGagal
payment_id    : GAGAL-0001
jumlah        : Rp315.000,00
status payment: gagal
status order  : pending

Gateway ini menolak secara sinkron: buatPembayaran() langsung mengembalikan gagal, sehingga baris payments tercatat gagal sejak awal dan order tetap pending — masih bisa dibatalkan, dan stoknya masih terreservasi untuk order itu. Kolom gateway pada baris pembayaran menyimpan GatewaySelaluGagal, bukti tertulis penyedia mana yang memproses. Pada Bagian 7 nanti, test idempotensi pembayaran memakai FakeGateway yang sama dengan walkthrough ini — test double yang kita bangun sekali dipakai di banyak tempat.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan berkas dari Bagian 1-3. Susun foldernya sebagai berikut:

text
sistem-pemesanan/
├── bagian-1-model-pelanggan-dan-produk/
│   └── schema.sql
├── bagian-2-order-dan-order-items/
│   └── src/
│       └── OrderRepository.php
├── bagian-3-order-processing/
│   └── src/
│       └── OrderService.php
└── bagian-5-payment-integration/
    ├── src/
    │   ├── PaymentGatewayInterface.php
    │   ├── PaymentService.php
    │   └── FakeGateway.php
    └── uji-pembayaran.php

Simpan walkthrough alur pembayaran lengkap sebagai uji-pembayaran.php di dalam folder bagian-5-payment-integration, lalu jalankan:

bash
php uji-pembayaran.php

Snippet migrasi membangun skema lamanya sendiri di :memory: sehingga tidak membutuhkan berkas class mana pun. Dua walkthrough lainnya memuat schema Bagian 1 serta class dari Bagian 2, 3, dan 5 lewat path relatif — struktur folder antarbagian harus dipertahankan. Seluruh contoh memakai :memory: dan tidak meninggalkan berkas database; satu-satunya “jaringan” yang disentuh adalah domain fiktif pay.example.com di balik FakeGateway.

Apa yang Berubah #

Dibandingkan Bagian 4 yang mengelola inventaris, proyek kini memiliki aliran uang yang tercatat:

  • Berkas src/PaymentGatewayInterface.php baru dengan dua method kontrak: buatPembayaran() dan statusPembayaran().
  • Berkas src/PaymentService.php baru dengan bayar() (idempoten per order), konfirmasi() (pemrosesan callback yang menyinkronkan pembayaran dan status order dalam satu transaksi), dan cariPayment().
  • Berkas src/FakeGateway.php baru — test double deterministik dengan penghitung panggilan yang menjadi saksi idempotensi.
  • Tabel payments baru dengan order_id UNIQUE sebagai penjaga idempotensi di level database; pola migrasinya mengikuti Bagian 4 dan terbukti aman dijalankan berulang.
  • Mesin transisi Bagian 3 mendapat tugas baru: hanya order pending yang boleh membayar, dan settlement yang terlambat tertolak tanpa mengubah status order.

Ringkasan #

Pembayaran kini berjalan di balik kontrak kecil yang bisa ditukar implementasinya: interface mendefinisikan dua kemampuan, PaymentService menggunakannya tanpa tahu siapa penyedia sebenarnya, dan tabel payments memegang idempotensi dengan constraint database. Status order dan status pembayaran berjalan beriringan lewat mesin transisi, sehingga uang yang masuk terlambat tidak pernah membuat status berbohong. Semua janji-janji itu terbukti lewat FakeGateway yang deterministik. Dengan data order, stok, dan pembayaran yang kini lengkap dan konsisten, Bagian 6 mengubah tumpukan baris tersebut menjadi laporan penjualan serta jejak audit yang bisa diputar ulang.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/FakeGateway.php

Unduh berkas
php
<?php

declare(strict_types=1);

require_once __DIR__ . '/PaymentGatewayInterface.php';

/**
 * Test double gateway: implementasi PaymentGatewayInterface yang
 * sepenuhnya deterministik untuk pengujian dan demo.
 *
 * Tidak ada jaringan, tidak ada kebetulan: payment id selalu
 * FAKE-<orderId empat digit>, transaksi baru selalu berstatus pending,
 * dan perpindahan ke settlement/gagal hanya terjadi bila kode uji
 * memanggil selesaikan()/gagalkan() — meniru callback dari gateway
 * sungguhan. Penghitung $panggilan membiarkan assertion membuktikan
 * bahwa bayar() bersifat idempoten (gateway tidak dihubungi dua kali).
 */
final class FakeGateway implements PaymentGatewayInterface
{
    /** Berapa kali buatPembayaran() dipanggil — untuk assertion idempotensi. */
    public int $panggilan = 0;

    /** @var array<string, string> payment id => status terkini */
    private array $status = [];

    public function buatPembayaran(int $orderId, int $jumlah): array
    {
        $this->panggilan++;
        $paymentId = sprintf('FAKE-%04d', $orderId);
        $this->status[$paymentId] = 'pending';

        return [
            'id'          => $paymentId,
            'status'      => 'pending',
            'redirectUrl' => "https://pay.example.com/$paymentId",
        ];
    }

    public function statusPembayaran(string $paymentId): string
    {
        return $this->status[$paymentId] ?? 'tidak-dikenal';
    }

    /**
     * Simulasi callback sukses dari gateway: transaksi menjadi settlement.
     */
    public function selesaikan(string $paymentId): void
    {
        $this->status[$paymentId] = 'settlement';
    }

    /**
     * Simulasi callback kegagalan dari gateway.
     */
    public function gagalkan(string $paymentId): void
    {
        $this->status[$paymentId] = 'gagal';
    }
}

src/PaymentGatewayInterface.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Kontrak yang harus dipenuhi setiap payment gateway.
 *
 * Service pembayaran kita hanya mengenal dua kemampuan: meminta pembayaran
 * baru dan menanyakan status pembayaran yang sudah ada. Semua detail
 * teknis penyedia (HTTP, autentikasi, format payload) berada di belakang
 * kontrak ini sehingga implementasi bisa ditukar tanpa menyentuh
 * PaymentService — dari FakeGateway di lingkungan uji sampai klien
 * gateway sungguhan di produksi.
 */
interface PaymentGatewayInterface
{
    /**
     * Minta gateway membuat transaksi pembayaran untuk satu order.
     *
     * $jumlah dalam satuan sen (Rp1 = 100 sen), mengikuti konvensi uang
     * proyek. Status yang mungkin dikembalikan: 'pending', 'settlement',
     * atau 'gagal'.
     *
     * @param int $orderId id order yang akan dibayar
     * @param int $jumlah  total tagihan dalam sen
     *
     * @return array{id: string, status: string, redirectUrl: string|null}
     *         id adalah pengenal transaksi di sisi gateway, redirectUrl
     *         adalah halaman pembayaran untuk pelanggan bila gateway
     *         memakai alur redirect (null bila tidak).
     */
    public function buatPembayaran(int $orderId, int $jumlah): array;

    /**
     * Tanyakan status terkini satu transaksi ke gateway.
     *
     * Dipakai saat memproses callback/webhook dan saat polling status.
     * Status yang mungkin dikembalikan: 'pending', 'settlement', 'gagal',
     * atau 'tidak-dikenal' bila gateway tidak mengenal payment id.
     */
    public function statusPembayaran(string $paymentId): string;
}

src/PaymentService.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan pembayaran order: berbicara ke gateway lewat
 * PaymentGatewayInterface, mencatat setiap transaksi pada tabel
 * payments, menjaga idempotensi satu pembayaran per order, dan
 * menyinkronkan status order lewat mesin transisi Bagian 3.
 *
 * Satu order hanya boleh memiliki satu baris payments — dijaga kolom
 * order_id yang UNIQUE. Pemanggilan bayar() kedua kali untuk order yang
 * sama mengembalikan baris pembayaran yang sudah ada tanpa menghubungi
 * gateway lagi.
 *
 * Membutuhkan OrderRepository, OrderService, dan
 * TransisiTidakValidException dari bagian-bagian sebelumnya.
 */
final class PaymentService
{
    public function __construct(
        private PDO $pdo,
        private OrderRepository $repository,
        private PaymentGatewayInterface $gateway,
    ) {
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Bayar satu order: buat transaksi di gateway lalu catat baris
     * payments berstatus pending.
     *
     * Idempoten per order: bila baris pembayaran sudah ada, method ini
     * mengembalikan baris tersebut apa pun statusnya dan TIDAK memanggil
     * gateway untuk kedua kalinya. Order yang bukan pending (sudah
     * dibayar, batal, atau status lain) ditolak oleh mesin transisi.
     *
     * @return array<string, mixed> baris payments untuk order ini
     */
    public function bayar(int $orderId): array
    {
        $existing = $this->cariPayment($orderId);
        if ($existing !== null) {
            return $existing;
        }

        $order = $this->repository->cariId($orderId);
        if ($order === null) {
            throw new InvalidArgumentException("Order #$orderId tidak ditemukan.");
        }

        if (!OrderService::bolehTransisi((string) $order['status'], 'dibayar')) {
            throw new TransisiTidakValidException(sprintf(
                'Order #%d berstatus %s; hanya order pending yang boleh dibayar.',
                $orderId,
                $order['status']
            ));
        }

        $hasil = $this->gateway->buatPembayaran($orderId, (int) $order['total']);

        $this->pdo->beginTransaction();
        try {
            $this->pdo->prepare(
                'INSERT INTO payments (order_id, gateway, payment_id, jumlah, status, redirect_url)
                 VALUES (?, ?, ?, ?, ?, ?)'
            )->execute([
                $orderId,
                get_class($this->gateway),
                $hasil['id'],
                (int) $order['total'],
                $hasil['status'],
                $hasil['redirectUrl'],
            ]);

            $this->pdo->commit();
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }

        $payment = $this->cariPayment($orderId);
        if ($payment === null) {
            throw new RuntimeException("Baris pembayaran order #$orderId hilang setelah ditulis.");
        }

        return $payment;
    }

    /**
     * Proses kabar dari gateway (simulasi callback/webhook) untuk satu
     * order: tanyakan status terkini, lalu finalisasi baris payments dan
     * status order dalam SATU transaksi.
     *
     * - status gateway 'settlement' -> payments.status = 'settlement'
     *   dan order menjadi 'dibayar' (divalidasi mesin transisi);
     * - status gateway 'gagal'      -> payments.status = 'gagal',
     *   status order tidak berubah;
     * - status gateway 'pending'    -> tidak ada yang berubah.
     *
     * Idempoten: baris pembayaran yang sudah final (settlement/gagal)
     * dikembalikan apa adanya tanpa menghubungi gateway lagi.
     *
     * @return array<string, mixed> baris payments setelah diproses
     */
    public function konfirmasi(int $orderId): array
    {
        $payment = $this->cariPayment($orderId);
        if ($payment === null) {
            throw new InvalidArgumentException(
                "Order #$orderId belum memiliki pembayaran; panggil bayar() lebih dulu."
            );
        }

        if ($payment['status'] !== 'pending') {
            return $payment;
        }

        $statusGateway = $this->gateway->statusPembayaran((string) $payment['payment_id']);
        if (!in_array($statusGateway, ['pending', 'settlement', 'gagal'], true)) {
            throw new RuntimeException("Status gateway tidak dikenal: $statusGateway");
        }

        if ($statusGateway === 'pending') {
            return $payment;
        }

        $this->pdo->beginTransaction();
        try {
            $this->pdo->prepare('UPDATE payments SET status = ? WHERE id = ?')
                ->execute([$statusGateway, $payment['id']]);

            if ($statusGateway === 'settlement') {
                $stmt = $this->pdo->prepare('SELECT status FROM orders WHERE id = ?');
                $stmt->execute([$orderId]);
                $statusOrder = (string) $stmt->fetchColumn();

                if (!OrderService::bolehTransisi($statusOrder, 'dibayar')) {
                    throw new TransisiTidakValidException(sprintf(
                        'Settlement ditolak: order #%d kini berstatus %s, transisi ke dibayar tidak diizinkan.',
                        $orderId,
                        $statusOrder
                    ));
                }

                $this->pdo->prepare("UPDATE orders SET status = 'dibayar' WHERE id = ?")
                    ->execute([$orderId]);
            }

            $this->pdo->commit();
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }

        $payment = $this->cariPayment($orderId);
        if ($payment === null) {
            throw new RuntimeException("Baris pembayaran order #$orderId hilang setelah ditulis.");
        }

        return $payment;
    }

    /**
     * Baris payments milik satu order; null bila order belum pernah
     * membayar (dijamin unik oleh constraint order_id UNIQUE).
     *
     * @return array<string, mixed>|null
     */
    public function cariPayment(int $orderId): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM payments WHERE order_id = ?');
        $stmt->execute([$orderId]);
        $payment = $stmt->fetch(PDO::FETCH_ASSOC);

        return $payment === false ? null : $payment;
    }
}