Payment Guard: Building a SNAP-Compliant Fraud Engine with Spring Boot and Kafka
A payment aggregator built around Bank Indonesia's SNAP spec: a real-time fraud/AML rule engine, a double-entry ledger, signed webhook retries, and three real bugs that only showed up once the system actually ran end to end.
Payment Guard: Building a SNAP-Compliant Fraud Engine with Spring Boot and Kafka
Every payment service provider in Indonesia has to speak SNAP (Standar Nasional Open API Pembayaran), Bank Indonesia's national standard for Open API payments. It is not optional, and it is not documentation you can skim: every request needs an asymmetric RSA signature, every partner key needs to be verified, and every transaction has to survive a fraud check before it is allowed to touch a ledger. Payment Guard is my portfolio implementation of that world: a payment aggregator with a real-time fraud and AML rule engine sitting in front of a double-entry ledger, wired up to Kafka events and signed webhook delivery.
The goal was not to build a toy CRUD API. It was to build something where I could honestly answer "how do you know the money math is correct," and have the answer be "because I ran it, not because I read it back."
The Shape of the Problem
Four things had to be true at the same time, and none of them are negotiable in a payment system:
- A transaction above a fraud threshold must never settle without a human decision.
- The ledger has to stay balanced (every DEBIT needs a matching CREDIT) no matter what path a transaction takes to get there.
- A Kafka outage must never roll back a financial decision that already happened, but a dropped event is still a real problem worth being honest about.
- A webhook retry has to be safely dedupable by the merchant receiving it, or retries themselves become a bug.
Four Modules, One Rule: Domain Doesn't Know Spring Exists
Payment Guard is split into domain, application, infrastructure, and api as separate Maven modules, not just packages. The rule that makes this worth the extra ceremony: domain has zero dependency on Spring, JPA, or Kafka. Transaction, FraudRuleEngine, and LedgerPostingService are plain Java, which means the entire fraud-rule and ledger-math test suite runs with JUnit and AssertJ in milliseconds, no database, no context startup:
public List<LedgerEntry> postPayment(Transaction transaction, AccountId clearingAccountId,
AccountId merchantAccountId) {
LedgerEntry debit = LedgerEntry.of(transaction.id(), clearingAccountId, EntryType.DEBIT, transaction.amount());
LedgerEntry credit = LedgerEntry.of(transaction.id(), merchantAccountId, EntryType.CREDIT, transaction.amount());
return List.of(debit, credit);
}
One transaction always produces exactly one DEBIT and one CREDIT of equal amount. That is not a convention I have to remember to follow correctly elsewhere; it is a method signature that cannot produce anything else.
SNAP's Signature Scheme, Verified for Real
SNAP's service-to-service auth is an asymmetric signature: the string METHOD:PATH:hex(sha256(body)):TIMESTAMP gets signed with the partner's RSA private key, and the receiver verifies it with the partner's public key. I generated a real keypair with openssl, wired a SnapAuthenticationFilter to verify against it, and then did the test that actually matters: sign one JSON body, swap it for a different one before sending, and confirm the server rejects it.
$ curl -X POST /api/v1.0/transfer-va/payment -d "$TAMPERED_BODY" -H "X-SIGNATURE: $SIG_FOR_ORIGINAL_BODY"
{"responseCode":"4010000","responseMessage":"Invalid signature"}
That single failing curl call is worth more than any amount of code review, because it proves the signature actually binds to the payload instead of just being present.
Kafka Without Docker: Running KRaft Mode Natively
Docker Desktop on my dev machine hit a genuine VM-level storage error partway through this build (commit failed: ...metadata.db: input/output error), and later even docker ps hung. Rather than lose a day fighting someone else's VM bug, Postgres and Kafka both run as native OS processes in project-local, isolated data directories. Kafka in particular runs in KRaft mode, meaning no Zookeeper at all: modern Kafka (4.x) genuinely does not need it anymore. docker-compose.yml and a Dockerfile are still written and ready for whenever that VM issue is sorted out, but I would rather ship an honestly-documented workaround than pretend a broken tool wasn't a real part of the build.
Webhooks: Retry, Backoff, and an Idempotency Key That Actually Gets Reused
Every transaction event fans out through Kafka to a webhook dispatcher, which signs the payload with HMAC-SHA256 and delivers it with a delivery ID the merchant can use to dedupe. Failed deliveries back off at 1, 5, 15, and 60 minutes before giving up permanently. I wrote a small local mock receiver in Python that can simulate downtime on demand, and used it to actually watch a delivery fail, wait out the real backoff window, and succeed on retry using the exact same delivery ID:
--- request #1 ---
delivery-id: 6d9a2238-0f56-4deb-854b-23bfc1989685
signature valid: True
-> simulating failure (1/1)
<h1>~60s later, WebhookRetryScheduler fires automatically</h1>
--- request #2 ---
delivery-id: 6d9a2238-0f56-4deb-854b-23bfc1989685 (same id)
signature valid: True
-> accepted
Three Bugs That Only Showed Up When It Actually Ran
None of these were caught by compiling. All three only appeared once I ran the real flow end to end, which is exactly why I ran it.
Ledger foreign key ordering. The first version of CreateTransactionUseCase posted ledger entries before saving the transaction they reference. ledger_entries.transaction_id has a foreign key to transactions.id, so the very first non-flagged transaction that ran for real failed with a constraint violation. The fix was one line moved earlier; the lesson was that this is exactly the kind of bug a unit test with mocked repositories will never catch, and exactly why the infrastructure module has its own integration test suite against a real Postgres.
@PathVariable without a name. Every path variable started failing at runtime with "parameter name information not available via reflection," because the Maven compiler was not emitting the -parameters flag. Rather than add an explicit name to every @PathVariable one at a time, I fixed the actual cause: maven.compiler.parameters=true in the parent POM, once, for every module.
@Lob on a Postgres text column. Hibernate maps @Lob String to oid (a large object reference) by default, which does not match a plain text column. The webhook payload column failed schema validation at boot until I removed the annotation entirely: a text column does not need LOB semantics, it just needs to be a string.
What's Next
The MVP roadmap (domain model, fraud engine, SNAP signing, Kafka events, reconciliation, webhooks, integration tests, observability) is done and verified. The planned v2 is a RAG-backed compliance copilot: when a transaction gets flagged, an LLM summarizes the case and cites relevant AML context, but it only ever produces a recommendation, never an automated decision. That is a hard line, not a caveat. I also deliberately left blockchain out of this project entirely: nothing here needs third-party-verifiable immutability, and reaching for it anyway would be decoration, not architecture.
Lessons Learned
Keep the money math in a module that cannot import a framework. If your ledger logic can only be tested by booting Spring, you are not testing the ledger, you are testing Spring.
A signature scheme is not verified until you have watched it reject a tampered payload. Code review can confirm a signature check exists. Only a failing curl call confirms it actually checks anything.
An idempotency key is not proven until a real retry reuses it. I could have asserted this in a unit test. Watching the actual backoff window pass and the actual same delivery ID come back through was worth more.
When a dev tool breaks, document the break, don't hide it. Docker Desktop failing mid-project is a more honest engineering story than a README that pretends everything ran in containers from the start.
Comments
No comments yet. Be the first!