Skip to content

Repository files navigation

Nkap

build

An open mobile money gateway with a real double-entry ledger.

Nkap sits between your application and the mobile money operators — MTN MoMo first, others through the same contract — and gives you the three things every integration rebuilds badly from scratch: an accounting record you can prove, a payment state machine that never guesses, and a way to test the cases that actually break in production.

Status: pre-alpha. The core is implemented and tested. Nothing is deployable yet. See the roadmap.


Why this exists

Mobile money is the dominant means of payment across much of sub-Saharan Africa, and every team that integrates it hits the same four problems.

The silent timeout. The payment request goes out, the network drops before the reply comes back. Did the money move? Without a reference persisted before the call, nothing can ever query it again.

The double charge. The user taps "Pay" twice. Without an idempotency key carried all the way through to the operator, they pay twice — and support finds out before you do.

The invented balance. The merchant's balance is an UPDATE on a column. No trace of how it got there, no way to reconcile against the operator's statement, no way to correct it without rewriting history.

The untestable. Operator sandboxes are unstable and never reproduce the ugly cases: duplicate callbacks, late callbacks, callbacks for references you have never seen. So nobody tests them, and they all arrive in production.

These are solved problems in Western payment systems. They are solved nowhere in a free, reusable form adapted to mobile money APIs.

What Nkap is — and is not

Is Is not
A unified collection and disbursement API A licensed payment institution
An immutable double-entry ledger A custodian of funds — money stays at the operator
An automatic reconciliation engine A KYC or AML engine
A scriptable operator simulator for tests A merchant dashboard
Self-hostable, with no SaaS dependency A marketplace or a hosted service

The right-hand column matters as much as the left. It protects the project legally, and it protects the scope from feature requests.

The three rules of the ledger

  1. Append only. No UPDATE, no DELETE on entries. A mistake is corrected by a reversing entry, never by rewriting the record.
  2. Zero sum. Every entry has at least two postings whose signed amounts sum to exactly zero. Enforced in the constructor, and in the database — a rule that lives only in Java is a rule a migration script walks straight through.
  3. Integers, in minor units. Never a floating-point number. One currency per entry; FX is a separate entry through a position account.

A balance is never stored. It is the sum of an account's postings, so it can always be explained by pointing at the entries that produced it.

A settled collection of 5 000 XAF for merchant acme — gross, two postings:

  provider:mtn:float:XAF          DR  5 000
  merchant:acme:payable:XAF       CR  5 000
                                  ------------
                                      0

The operator's fee is a second entry, written when the statement says what it was:

  fees:mtn:XAF                    DR    100
  provider:mtn:float:XAF          CR    100
                                  ------------
                                      0

The fee is not part of the settlement, and that is deliberate. A payment must be recordable from what the payment itself carries; an entry that needed a fee the operator may never send could not be written at all. The operator's statement is the authority on fees, and an entry added a day later is not a correction — it is a second entry about a second real event, which is what an append-only ledger is for. Nkap charges no fee of its own. See ADR 0006.

Where Nkap sits, and why it is not Mojaloop, not a ledger engine and not another unified API: docs/positioning.md.

The rule that shapes everything else

A timeout is not a failure.

A call that did not answer moves to UNKNOWN, never to FAILED. UNKNOWN is not a terminal state: the reconciler resolves it, or a human is paged. No code in this system may conclude that a payment failed without the operator having said so explicitly.

CREATED → SUBMITTED → PENDING → SUCCEEDED   (terminal)
                   ↘         ↘  FAILED      (terminal)
                     UNKNOWN ↗  EXPIRED     (terminal)
                       ↑
              every timeout lands here

Every payment that has left CREATED and not reached a terminal state — SUBMITTED, PENDING, UNKNOWN — is re-queried by the reconciler on an exponential backoff (nkap.reconciler.*): an acknowledged submission that goes silent, or a PENDING the payer never approves, is just as unresolved as a timeout. When the window (wall-clock time since the payment became unresolved) is spent it is escalated — flagged for a human, still non-terminal, never FAILED — and logged once at WARN. Escalated payments awaiting a human are PaymentRepository.findEscalated(), or:

SELECT reference, merchant_id, amount_minor, currency, reconcile_attempts, escalated_at
FROM payment
WHERE escalated_at IS NOT NULL AND state IN ('SUBMITTED', 'PENDING', 'UNKNOWN')
ORDER BY escalated_at;

Modules

Module What lives there
core Ledger, state machine, idempotency. Pure domain — no web, no Spring, no database.
provider-api The ProviderAdapter contract an operator integration implements.
provider-mtn The MTN MoMo adapter.
conformance The test kit every adapter must pass to be merged.
simulator A scriptable fake operator that misbehaves on command.
server Spring Boot: REST, webhooks, outbox, schedulers.

core has no dependencies on purpose. It makes the accounting invariants testable in milliseconds, without a container or a database — which is what makes an outside contribution to the ledger reviewable.

Quick start (the contributor's path)

Requires Docker. No JDK. This builds Nkap from source, which is right for trying the code before you change it — if you just want to run Nkap, see Run it without cloning below.

git clone https://github.com/Deval123/nkap
cd nkap
docker compose up --build -d      # builds the gateway and the simulator from source
./examples/demo.sh

examples/demo.sh puts one payment through the failure this project exists for, and asserts every step. POST /payments is authenticated: compose.yaml provisions one obviously-named demo API key before the gateway starts, and the script sends it as Authorization: Bearer. The merchant is the key's, never a body field.

  1. the operator is scripted to accept the submission and then go silent;
  2. POST /payments returns 202 and the payment is UNKNOWN — not FAILED, because nothing answered and the gateway does not guess;
  3. GET /payments/{reference} confirms UNKNOWN, and the ledger has no entry;
  4. the reconciler re-queries the operator on a demo-fast cadence — the first re-query still fails, the next succeeds — with no callback in the scenario, so only the reconciler can resolve it;
  5. the payment resolves to SUCCEEDED, its transition attributed to RECONCILER: one ledger entry, two postings, summing to zero.

The network dropped at the worst possible moment and the accounting truth was not lost. Nothing in the run is staged: it is the real gateway, the real reconciler, and a real PostgreSQL enforcing the ledger's invariants as constraints — the zero-sum check that prints at the end is the database's, not the script's.

docker compose down -v            # stop, and wipe the database

From a cold clone — no build cache, base images not yet pulled — docker compose up --build takes about half a minute on a fast connection; most of the variable part is the one-time download of the build's dependencies (~1000 artifacts) inside the image, so a slow link makes the first run longer. Later runs reuse the layers. The demo itself finishes in about five seconds. compose.yaml builds from source, on purpose — it is the fastest way to see a change you just made in server/src take effect, without deciding what to publish first.

Run it without cloning

You clone to contribute, you pull an image to use. Every tagged release publishes ghcr.io/deval123/nkap-gateway and ghcr.io/deval123/nkap-simulator — public, no login needed to pull, linux/amd64 and linux/arm64 — tagged with the exact version and with a moving latest that always points at the newest release, never at main. docker inspect ghcr.io/deval123/nkap-gateway:latest names the exact commit and version it was built from.

This is for running Nkap for real, against your own MTN credentials — not for trying it. If you have not run Nkap before, the quick start above is a better first stop: it needs no MTN account, it is faster to get a payment moving through, and it is the same gateway. Once you know what Nkap does and you are ready to point it at MTN, come back here.

curl -fsSL -o nkap-standalone.compose.yaml \
  https://raw.githubusercontent.com/deval123/nkap/v1.0.0/nkap-standalone.compose.yaml

Pinned to a tag, not to main, so the file you get and the images it names are the same release — substitute the release you actually want; see Releases for the list.

This file provisions no API key and starts nothing without one — deriving a key from compose.yaml, the obvious shortcut, would put a fixed, well-known credential, published in this public repository, in front of a real gateway. key-init below mints a real one instead, the same way --nkap.apikey.create always does, and prints it once. It also changes none of application.yml's cadence: compose.yaml's fast timeouts exist to make a demo watchable in one sitting, and would hammer a real MTN account and burn the reconciler's escalation window in minutes if carried here. And it never runs a simulator — an operator deploying Nkap for real must never have one reachable from the same process that moves real money; see the file's own comment for the rest of that reasoning.

export NKAP_VERSION=1.0.0                 # the release you downloaded the file for
export NKAP_DB_PASSWORD=$(openssl rand -hex 32)
export NKAP_MERCHANT_ID=your-merchant-id
docker compose -f nkap-standalone.compose.yaml up -d
docker compose -f nkap-standalone.compose.yaml logs key-init

Any variable left unset fails fast with a one-line message naming it, before any container starts. The last command prints your API key — once; only its hash is ever stored, and there is no command or route that reads it back. Save it now. Bringing the stack up again after a docker compose down runs key-init again too, which mints and prints a new key for the same merchant — the old one keeps working (there is no revocation command yet, only a direct database delete; see docs/security-notes.md), so this is a second credential, not a replacement, until you deliberately stop using the first one. If you only want the one key, leave the stack running rather than cycling it, or provision it once by hand instead (see below) and remove key-init from the file.

Once it is up, fill in your real MTN credentials the same way — the file lists every variable it reads, each defaulting to unconfigured rather than to a placeholder — and provision additional keys, for additional merchants, the same command compose.yaml's own comment describes (no --nkap.apikey.token: that is what lets the gateway generate one instead of you choosing one, which is the only way a fast, unsalted hash is safe to store):

docker compose -f nkap-standalone.compose.yaml run --rm gateway \
  --nkap.apikey.create --nkap.apikey.merchant=<id>

Running on Kubernetes

charts/nkap is a Helm chart for the gateway, from the same published image — values.yaml never accepts a credential value, not the database password, not an MTN subscription key, api user or api key: every one is the name of a Secret you create yourself, and rendering fails, naming what is missing, exactly the way nkap-standalone.compose.yaml's ${VAR:?message} does. Read charts/nkap/README.md before installing — it answers three questions a compose file never had to: where the first API key appears and who else can read it, what runs the migrations with more than one replica, and whether more than one replica is safe at all.

The simulator on its own

The most numerous audience for this project is people integrating against MTN directly, who will never run the gateway at all. If that is you, and you want to test the cases that actually break in production — a timeout, a duplicate callback, a flapping status — the simulator is the most useful single piece of Nkap, and it needs nothing else:

docker run -p 8081:8081 ghcr.io/deval123/nkap-simulator

Script it, then point whatever you are testing at http://localhost:8081 in place of MTN's own base URL:

curl -X POST http://localhost:8081/_nkap/scenarios \
  -H 'Content-Type: application/json' \
  -d '{
        "rules": [{
          "scenario": {
            "name": "times-out-then-succeeds",
            "onSubmit": { "outcome": "NO_RESPONSE" },
            "onQuery": [{ "status": "SUCCESSFUL" }]
          }
        }]
      }'

The simulator is not MTN. docs/providers/mtn.md separates what was actually observed against MTN's sandbox from what this project chose to model where MTN's own behaviour is undocumented or untested; the simulator implements the second column, and a mismatch between the two is a bug in the simulator or the doc, not in MTN. Read that file for what it imitates before you trust an integration test that only ever ran against this.

Orange Money is read, not implemented. There is no Orange adapter in this repository, and 1.0.0 does not plan one. docs/providers/orange-money.md records what a review of provider-api against Orange's documented API found — including two contract findings a redirect-style operator surfaces that MTN never could. Everything on that page is assumed, and more weakly sourced than mtn.md: nobody on this project holds an Orange developer account, and the page says so rather than presenting a guess as a fact.

Integration guide and API reference

docs/integration-guide.md is for making your own backend talk to Nkap: getting a key, the first collection, what to do with UNKNOWN, receiving and verifying a webhook, a refund and what its cap refuses, and the errors an integration actually hits. docs/openapi.yaml is the same API as a schema — every path, method, status code and field. Both are checked against the running application on every build (OpenApiSpecIT, examples/run-integration-guide.sh), not merely written once and left to drift.

docs/configuration-reference.md covers every nkap.* setting, its default, and what a deployment actually experiences when it is wrong — kept honest against the code by ConfigurationReferenceTest. docs/security-notes.md covers credential handling, webhook verification, what Nkap does and does not protect, and what an operator has to do that Nkap cannot.

CHANGELOG.md says what each tagged release actually is, what it deliberately does not do, and — from v1.0.0 onward — what stability means: which parts of this project are a published contract and which are today's behaviour, not a promise.

Build

Requires JDK 21 and Maven 3.9+.

mvn test

Roadmap

v1.0.0 — MTN, end to end. One operator, done properly: collections and disbursements across MTN's countries, a ledger persisted in PostgreSQL with its invariants as database constraints, reconciliation, signed webhooks, the conformance kit, and a docker compose up that puts a payment through. The full definition of done is docs/roadmap/v1.0.0-mtn-end-to-end.md — nothing off that list ships in 1.0.0.

After 1.0.0 — the other operators. Orange Money, Wave, M-Pesa, Airtel. This is the contribution the architecture was built to accept: a new adapter is a self-contained module that has to pass the conformance kit, which is what lets a maintainer merge an operator they have no account with. The goal is every mobile money operator worth integrating.

Nkap runs on one container and one database. Kafka is an optional connector, not a requirement — see ADR 0003.

Contributing

Start with CONTRIBUTING.md. The most accessible way in is a simulator scenario: one failure mode per pull request, no operator account required.

License

Apache License 2.0 — see LICENSE.

The explicit patent grant is deliberate: it matters to the companies who will evaluate this for financial workloads.

About

An open mobile money gateway with a real double-enty ledger. A timeout is not a failure.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages