Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Bagaimana mendesain payment system yang tidak pernah menagih ganda?
Wajibkan idempotency key di setiap request pembayaran dan insert ke tabel yang primary key-nya adalah key itu sebelum mengerjakan apa pun. Replay lalu mengembalikan hasil tersimpan, bukan menagih lagi. Dukung dengan payment state machine yang transisinya compare-and-set, serta ledger yang menolak posting kedua untuk reference processor yang sama.
02Mengapa uang disimpan sebagai integer, bukan float?
Floating point biner tidak bisa merepresentasikan sebagian besar pecahan desimal secara tepat, sehingga 0.1 ditambah 0.2 menghasilkan 0.30000000000000004 di JavaScript. Menyimpan integer dalam minor unit mata uang, misalnya 1999 sen untuk USD 19.99, menjaga penjumlahan tetap eksak. Simpan currency di samping setiap nominal karena minor unit berbeda dan sebagian mata uang tidak punya desimal.
03Apa yang dilakukan saat panggilan API pembayaran timeout?
Anggap sebagai unknown, bukan failed. Tandai payment unknown, biarkan pelanggan di layar pending, lalu retry dengan idempotency key yang sama atau cari payment lewat reference milik Anda sendiri. Jangan membuat key baru untuk retry, karena itulah cara pelanggan berakhir tertagih dua kali.
04Apa itu payment reconciliation dan mengapa diperlukan?
Reconciliation membandingkan ledger Anda dengan laporan settlement processor, biasanya harian, dan mendaftar apa pun yang hanya ada di satu sisi atau berbeda nominal. Ini diperlukan karena webhook bisa hilang atau terlambat dan panggilan API bisa berakhir dengan hasil unknown. Setiap selisih menjadi tiket yang diperbaiki dengan entri baru, tidak pernah dengan mengedit riwayat.
05Bagaimana menangani refund di ledger double-entry?
Posting transaksi seimbang yang baru untuk memindahkan uang kembali, misalnya debit revenue dan kredit akun clearing processor, bukan mengedit capture awal. Pastikan refund yang sudah diposting ditambah nominal baru tidak melebihi nominal yang di-capture. Entri yang salah dikoreksi dengan reversal yang mereferensikan aslinya, sehingga keduanya tetap terlihat untuk audit.
Cara mendesain payment system yang tidak kehilangan atau menagih ganda: state machine pembayaran, ledger double-entry append-only dalam integer sen, idempotency key, webhook, dan reconciliation harian.
Payment system yang tidak pernah kehilangan atau menagih ganda menggabungkan empat hal: payment state machine dengan state unknown yang eksplisit, ledger double-entry append-only dalam integer minor unit, idempotency key di batas API, dan job reconciliation yang membandingkan ledger dengan laporan settlement processor. Refund adalah entri baru, bukan edit.
Bug pembayaran yang paling menakutkan bukan crash. Melainkan request yang timeout setelah processor sudah mengambil uangnya, karena kode Anda tidak bisa memastikan harus retry atau menyerah, dan salah tebak bisa merugikan pelanggan atau bisnis.
Tulisan ini membahas desain seluruh sistemnya: komponen-komponennya dan urutan membangunnya. Saya mengerjakan sistem ERP dan POS di Postgres dan NestJS, jadi contohnya memakai Postgres dan TypeScript. Setiap angka di bawah dihitung langsung di depan Anda atau dikutip dari dokumentasi, dan tidak ada benchmark.
Apa artinya tidak kehilangan atau menagih ganda uang?
Dua kegagalan mendefinisikan masalahnya, dan keduanya saling tarik. Pembayaran hilang terjadi saat pelanggan sudah ditagih tetapi sistem Anda tidak punya catatan. Tagihan ganda terjadi saat satu maksud dieksekusi dua kali. Retry lebih keras memperbaiki yang pertama dan menciptakan yang kedua, jadi desain harus memberi tiga jaminan sekaligus.
Setiap upaya memindahkan uang dicatat sebelum dikirim, sehingga tidak ada tagihan tanpa jejak.
Setiap panggilan eksternal dan setiap event masuk aman diulang, sehingga retry tidak menggandakan tagihan.
Pembukuan Anda dicek terhadap pembukuan processor secara terjadwal, sehingga setiap selisih ditemukan oleh Anda dan bukan oleh pelanggan.
Perhatikan apa yang tidak ada: janji bahwa tidak ada yang pernah gagal. Jaringan timeout, processor mengirim event dua kali dan kadang tidak sama sekali. Tujuannya sistem yang setiap kegagalannya berakhir di state yang diketahui atau sebuah tiket, bukan selisih diam-diam.
Bagaimana payment state machine seharusnya bekerja?
Modelkan setiap pembayaran sebagai state machine dengan tabel transisi tetap, dan tulis tabel itu sebagai data. Satu state yang tidak biasa adalah unknown: request sudah dikirim dan hasilnya belum diketahui. Hanya reconciliation atau webhook yang terkonfirmasi yang boleh keluar dari state itu. Transisi di bawah adalah update compare-and-set plus baris audit dalam transaksi yang sama.
type PaymentState =
| "created" | "submitted" | "authorised" | "captured"
| "failed" | "unknown" | "partially_refunded" | "refunded";
// The whole lifecycle in one table. Anything not listed here cannot happen.
const ALLOWED: Record<PaymentState, PaymentState[]> = {
created: ["submitted", "failed"],
submitted: ["authorised", "captured", "failed", "unknown"],
unknown: ["authorised", "captured", "failed"], // only reconciliation resolves it
authorised: ["captured", "failed"],
captured: ["partially_refunded", "refunded"],
partially_refunded: ["partially_refunded", "refunded"],
failed: [], // terminal: a retry is a NEW payment
refunded: [],
};
async function transition(
db: PoolClient,
paymentId: string,
from: PaymentState,
to: PaymentState,
evidence: { source: "api" | "webhook" | "reconciliation"; ref: string },
): Promise<void> {
if (!ALLOWED[from].includes(to)) {
throw new Error("illegal transition " + from + " -> " + to);
}
// Compare-and-set: two racing writers cannot both move the same payment.
const res = await db.query(
"UPDATE payments SET state = $3, version = version + 1 WHERE id = $1 AND state = $2",
[paymentId, from, to],
);
if (res.rowCount === 0) return; // someone else got there first: a no-op, not an error
// The audit row commits with the state change or not at all.
await db.query(
"INSERT INTO payment_events (payment_id, from_state, to_state, source, ref) VALUES ($1, $2, $3, $4, $5)",
[paymentId, from, to, evidence.source, evidence.ref],
);
// Money movement (ledger entries) is posted by the caller in this same transaction.
}
Compare-and-set inilah yang mencegah dua worker, atau webhook dan retry, memindahkan pembayaran yang sama bersamaan. Yang kalah tidak cocok dengan baris mana pun dan menganggapnya no-op. Failed sengaja dibuat terminal: pelanggan yang mencoba lagi adalah pembayaran baru dengan idempotency key baru, bukan pembayaran lama yang dihidupkan kembali.
Letakkan state machine dan ledger di transaksi database yang sama. Jika state-nya captured, entri capture-nya ada, dan jika transaksi di-rollback, keduanya tidak ada. Dua sistem yang harus selaras pada akhirnya akan berselisih.
Mengapa memakai ledger double-entry append-only dengan integer sen?
Simpan uang sebagai integer dalam minor unit mata uang, jangan pernah sebagai float. Floating point biner tidak bisa merepresentasikan sebagian besar pecahan desimal, dan errornya muncul tepat saat Anda mengalikan atau menjumlah. Minor unit berbeda tiap mata uang, dan ada mata uang tanpa desimal, jadi simpan currency di samping setiap nominal dan jadikan aturan pembulatan sebagai kode yang eksplisit.
// Wrong: binary floating point cannot represent most decimal fractions.
0.1 + 0.2; // 0.30000000000000004
19.99 * 100; // 1998.9999999999998, and Math.floor() gives 1998
// Right: integers in the currency's minor unit, rounding decided once and written down.
const priceMinor = 1999; // USD 19.99 stored as 1999 cents
const taxMinor = Math.round((priceMinor * 11) / 100); // 219.89 -> 220, an explicit rule
Di atas integer, pakai pembukuan double-entry: setiap pergerakan adalah kumpulan entri debit dan kredit yang berjumlah nol, sehingga uang tidak pernah tercipta atau lenyap, hanya berpindah antar akun. Entri hanya pernah di-insert. Skema di bawah menegakkan kedua aturan itu di database, bukan mempercayakannya pada kode aplikasi.
CREATE TABLE accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code text NOT NULL UNIQUE, -- 'psp_clearing', 'revenue', 'psp_fees', 'bank'
currency char(3) NOT NULL
);
CREATE TABLE ledger_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id uuid NOT NULL,
kind text NOT NULL CHECK (kind IN ('capture', 'fee', 'payout', 'refund', 'reversal')),
reverses_id uuid REFERENCES ledger_transactions(id), -- corrections point at what they undo
external_ref text, -- the PSP's id for this movement
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (kind, external_ref) -- the same PSP event can never post twice
);
CREATE TABLE ledger_entries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
transaction_id uuid NOT NULL REFERENCES ledger_transactions(id),
account_id bigint NOT NULL REFERENCES accounts(id),
direction text NOT NULL CHECK (direction IN ('debit', 'credit')),
amount_minor bigint NOT NULL CHECK (amount_minor > 0), -- sign lives in direction, never here
currency char(3) NOT NULL
);
-- A CHECK constraint sees one row, so it cannot say "the rows of a transaction sum to zero".
-- A deferred constraint trigger runs at COMMIT, after every entry of the transaction exists.
CREATE FUNCTION assert_balanced() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM ledger_entries
WHERE transaction_id = NEW.transaction_id
GROUP BY currency
HAVING sum(CASE direction WHEN 'debit' THEN amount_minor ELSE -amount_minor END) <> 0
) THEN
RAISE EXCEPTION 'ledger transaction % is unbalanced', NEW.transaction_id;
END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER ledger_entries_balanced
AFTER INSERT ON ledger_entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION assert_balanced();
-- Append-only, enforced by the database and not by good intentions.
CREATE FUNCTION forbid_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'ledger rows are immutable: post a reversal instead';
END $$;
CREATE TRIGGER ledger_entries_immutable
BEFORE UPDATE OR DELETE ON ledger_entries
FOR EACH ROW EXECUTE FUNCTION forbid_mutation();
-- Also: REVOKE UPDATE, DELETE, TRUNCATE ON ledger_entries FROM the application role.
Constraint CHECK hanya melihat satu baris, jadi tidak bisa menyatakan bahwa sebuah transaksi seimbang. Deferred constraint trigger berjalan saat commit, setelah semua entri transaksi ada, dan melempar error jika ada currency yang tidak berjumlah nol. Pasangan unik kind dan external reference berarti event processor yang sama tidak bisa diposting dua kali, bahkan jika kode Anda mencobanya.
Cabut UPDATE, DELETE, dan TRUNCATE dari role aplikasi, jangan hanya menambah trigger. Trigger melindungi Anda dari bug, sedangkan permission melindungi Anda dari migrasi yang dijalankan seseorang jam 2 pagi. Saldo selalu diturunkan dengan menjumlah entri, tidak pernah disimpan lalu diedit.
Bagaimana idempotency key mencegah tagihan ganda di batas API?
Klien membuat key unik per maksud dan mengirimkannya bersama request. Server meng-insert key ke tabel yang primary key-nya adalah jaminannya, sebelum mengerjakan apa pun. Stripe mendokumentasikan ide yang sama: key sampai 255 karakter, dan pada API v1 key boleh dihapus otomatis setelah berumur minimal 24 jam, jadi tentukan retensi Anda sendiri dengan sengaja. Mekanismenya saya bahas di tulisan terpisah, jadi di sini hanya bentuk yang penting untuk pembayaran.
CREATE TABLE idempotency_keys (
merchant_id text NOT NULL,
key text NOT NULL,
request_hash text NOT NULL, -- sha256 of the canonical request body
payment_id uuid,
response_status int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (merchant_id, key) -- the primary key IS the guarantee
);
-- First line of every payment request. One row comes back for the winner, none for a replay.
INSERT INTO idempotency_keys (merchant_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (merchant_id, key) DO NOTHING
RETURNING key;
-- Zero rows: this is a replay. Read the stored row, then:
-- same request_hash, response stored -> return the stored response verbatim
-- same request_hash, no response yet -> 409: the first attempt is still running
-- different request_hash -> 422: the key is being reused for a different request
Simpan hash request bersama key. Memutar ulang key yang sama dengan body berbeda adalah bug klien, dan diam-diam mengembalikan hasil pertama akan menyembunyikannya. Kembalikan conflict. Simpan juga baris key dan baris payment dalam satu transaksi, agar crash tidak meninggalkan key yang menunjuk ke ketiadaan.
Apa yang dilakukan saat panggilan pembayaran timeout?
Timeout bukan kegagalan, melainkan unknown. Apa yang Anda lihat tidak memberi tahu apa yang dilakukan processor, jadi tabel di bawah memetakan tiap pengamatan ke tindakan aman. Aturan di baliknya: Anda boleh mengirim ulang idempotency key yang sama sebanyak apa pun, tetapi tidak boleh membuat key baru untuk me-retry hasil yang unknown.
Yang Anda amati
Yang mungkin terjadi di processor
Tindakan aman
Efek ke ledger
Connection refused atau DNS gagal sebelum terkirim
Request tidak pernah sampai
Retry dengan idempotency key yang sama
Tidak ada
Read timeout setelah request terkirim
Sudah captured, ditolak, atau masih diproses
Tandai payment unknown, retry key yang sama atau cari lewat reference Anda
Tidak ada sampai hasilnya terkonfirmasi
HTTP 5xx dari processor
Ambigu, sama seperti timeout
Perlakukan persis seperti read timeout
Tidak ada sampai hasilnya terkonfirmasi
Respons decline yang jelas
Pasti ditolak
Pindah ke failed, minta pelanggan mencoba lagi sebagai payment baru
Tidak ada yang diposting
Payment unknown diselesaikan oleh salah satu dari dua hal yang datang belakangan: webhook, atau job reconciliation yang bertanya langsung ke processor. Sampai saat itu pelanggan melihat pending, bukan failed dan bukan paid. Menampilkan failed untuk payment unknown adalah cara orang tertagih dua kali karena membayar lagi.
Bagaimana webhook dan reconciliation menangkap yang terlewat API?
Webhook adalah cara processor memberi tahu apa yang terjadi setelah panggilan Anda kembali. Webhook dikirim minimal sekali dan bisa datang tidak berurutan atau tidak datang, jadi handler menyimpan event id dengan unique index dan mengabaikan pengulangan. Panduan Stripe sendiri adalah mencatat event id yang sudah diproses dan melewati event yang sudah tercatat. Balas cepat dan kerjakan dari baris yang tersimpan.
app.post("/webhooks/psp", async (req, res) => {
// 1. Verify the signature over the RAW body first (see your PSP's docs for the exact scheme).
// 2. Record the event id. A duplicate delivery hits the unique index and does nothing.
const inserted = await db.query(
"INSERT INTO psp_events (event_id, payload) VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING RETURNING event_id",
[event.id, event],
);
res.sendStatus(200); // acknowledge fast; do the work from the stored row
if (inserted.rowCount === 0) return; // already processed: webhooks are delivered at least once
await applyEvent(event); // runs transition() + ledger posting in one transaction
});
Webhook bisa hilang, jadi desain yang hanya mengandalkan webhook punya lubang. Pengamannya adalah job reconciliation harian yang mengimpor laporan settlement processor dan membandingkannya dengan ledger Anda. Full outer join menampilkan ketiga bentuk kegagalan dalam satu hasil: ada di ledger kita tetapi tidak di processor, ada di processor tetapi tidak di ledger kita, dan nominal yang berbeda.
-- psp_report_lines is the settlement file you import each day: (ref, kind, amount_minor).
-- A FULL OUTER JOIN shows all three failure shapes in one result.
SELECT coalesce(l.ref, p.ref) AS ref,
l.amount_minor AS ours,
p.amount_minor AS theirs,
CASE
WHEN p.ref IS NULL THEN 'in our ledger, not at PSP'
WHEN l.ref IS NULL THEN 'at PSP, not in our ledger'
ELSE 'amount differs'
END AS problem
FROM (
SELECT t.external_ref AS ref, sum(e.amount_minor) AS amount_minor
FROM ledger_transactions t
JOIN ledger_entries e ON e.transaction_id = t.id AND e.direction = 'debit'
WHERE t.kind = 'capture' AND t.created_at >= $1 AND t.created_at < $2
GROUP BY t.external_ref
) l
FULL OUTER JOIN (
SELECT ref, amount_minor FROM psp_report_lines WHERE kind = 'capture' AND day = $3
) p USING (ref)
WHERE l.amount_minor IS DISTINCT FROM p.amount_minor;
-- Zero rows means the day agrees. Every row is a ticket, and none is fixed by editing the ledger.
Reconciliation tidak pernah mengedit ledger agar angkanya cocok. Setiap baris menjadi tiket dengan perbaikan yang berupa entri baru atau transisi state, misalnya menyelesaikan payment unknown menjadi captured dan memposting entrinya. Job ini juga monitoring Anda: jumlah baris tidak cocok per hari adalah satu angka yang memberi tahu sistem sehat.
Bagaimana refund dan koreksi bekerja di ledger append-only?
Refund adalah transaksi baru yang memindahkan uang kembali, bukan edit atas capture awal. Contoh hitungan di bawah menelusuri USD 19.99 lewat capture, payout, dan refund parsial dalam sen. Perhatikan payout dipotong fee 88 sen dari file settlement, jadi 1999 dikurangi 88 adalah 1911 yang masuk ke bank, dan setiap transaksi seimbang secara konstruksi.
-- Worked example: USD 19.99 captured, then a USD 5.00 refund. Every amount is cents.
-- capture (PSP event evt_cap_1) DR psp_clearing 1999 CR revenue 1999
-- payout (settlement line st_1) DR bank 1911 DR psp_fees 88 CR psp_clearing 1999
-- (the settlement file shows an 88 cent fee, so 1999 - 88 = 1911 reaches the bank)
-- refund (PSP event evt_ref_1) DR revenue 500 CR psp_clearing 500
--
-- psp_clearing: debit 1999, credit 1999, credit 500 -> net 500 credit (owed back on the next payout)
-- revenue : credit 1999, debit 500 -> net 1499 credit
-- Trial balance: total debits must equal total credits, to the cent, forever.
SELECT sum(CASE direction WHEN 'debit' THEN amount_minor ELSE -amount_minor END) AS should_be_zero
FROM ledger_entries;
Jaga refund dengan aritmetika: refund yang sudah diposting ditambah nominal baru tidak boleh melebihi nominal yang di-capture. Setelah refund 500, masih ada 1499 yang bisa di-refund, jadi permintaan kedua sebesar 1600 gagal karena 500 tambah 1600 adalah 2100, lebih dari 1999. Entri yang salah dikoreksi dengan reversal yang menunjuk ke aslinya lewat kolom reverses, diikuti entri yang benar, sehingga keduanya tetap terlihat selamanya.
Apa yang masuk audit trail dan checklist sebelum rilis?
Audit trail adalah yang sudah Anda bangun: tabel payment events mencatat setiap transisi beserta sumber dan reference-nya, dan ledger mencatat setiap pergerakan. Bersama-sama keduanya menjawab siapa memindahkan uang ini, kapan, dan mengapa, tanpa membaca log aplikasi. Sebelum rilis, periksa desain Anda dengan daftar ini.
Uang disimpan sebagai integer minor unit dengan currency di setiap baris, dan tidak ada float yang menyentuh nominal.
Entri ledger append-only, ditegakkan oleh trigger dan permission yang dicabut, dan setiap transaksi dicek seimbang saat commit.
Setiap request pembayaran dimulai dengan insert idempotency key, dan baris key serta payment di-commit bersamaan.
Payment state machine punya state unknown yang eksplisit, dan pelanggan melihat pending selama payment berada di sana.
Handler webhook melakukan dedupe pada event id dan ledger melakukan dedupe pada reference processor.
Job reconciliation harian membandingkan ledger dengan laporan settlement, dan baris yang tidak cocok memanggil seseorang.
Untuk sistem kecil di satu instance Postgres, semua ini muat dalam beberapa tabel dan dua background job. Biayanya disiplin, bukan infrastruktur, dan jauh lebih murah daripada menjelaskan kepada pelanggan mengapa mereka tertagih dua kali.
Perlakukan unknown sebagai state kelas satu, buat setiap penulisan aman diulang, catat uang sebagai entri seimbang yang append-only, dan biarkan job reconciliation membuktikan pembukuan terhadap processor setiap hari. Kebenaran di sini adalah kumpulan constraint di database, bukan sifat dari kode yang hati-hati.