A provider-agnostic payment gateway for Central Africa, with a Stripe-shaped API.
MTN MoMo and Orange Money are the first two adapters. Neither is the architecture.
This repository is a scaffold. It compiles, lints clean and its tests pass, but no HTTP call to any payment rail has ever been made by this code.
Read
docs/status.mdbefore forming any expectation of what works. That page is machine-checked:cargo xtask verify-statusfails the build if the code contains an unimplemented path that the status page does not declare.
A small payment gateway for Cameroon that merchants integrate the way they'd integrate Stripe — same object model, same idempotency semantics, same webhook signature scheme — while it talks underneath to mobile money rails that behave nothing like cards.
Authentication is the one place this comparison does not hold. /v1
does not accept an sk_live_/sk_test_-style API key. It authenticates
merchants with OAuth2 client_credentials + private_key_jwt (RFC 7523):
each merchant is a statically registered client, holding its own private
key, configured directly in vpay's YAML — vpay stores only the public half.
No Stripe SDK can authenticate against vpay as a result. See
ADR-0010 for why, and
examples/merchant-curl for the resulting
two-step flow.
Two rails ship in the MVP, and they have genuinely different payer journeys:
MTN MoMo (push) |
Orange Money (redirect) |
|
|---|---|---|
| Payer acts by | Entering a PIN on their handset | Being redirected to Orange's hosted page |
Intent status after confirm |
processing |
requires_action |
| Can the payer act before we persist? | Yes | No |
That last row is why crash safety has two enforcement points rather than one.
See docs/flows/crash-safety.md.
Both are wired into just verify and CI, because a promise nothing checks is a
promise that decays.
1. No test doubles in shipping processes. No mock, fake or stub may be
reachable from vpay-server or vpay-worker-bin. A stub rail is a WireMock
host in configuration — the same mechanism production uses to reach a real
rail. cargo xtask verify-no-mocks enforces it.
(ADR-0006)
2. Never claim a feature is done when it is not. Unwritten code returns
ProviderError::NotImplemented — it never fabricates a success. Every such path
must appear in docs/status.md, and cargo xtask verify-status fails the build
otherwise. Tests for unbuilt features are #[ignore]d with a reason, so a green
run never overstates coverage.
backends/
crates/ vpay-core, -config, -ledger, -provider, adapters, -api, -worker, -testkit
apps/ vpay-server, vpay-worker-bin (musl → scratch images)
tests/ integration (testcontainers) · conformance (shared adapter suite)
frontends/
packages/ @vpay/tokens · @vpay/ui (design system) · @vpay/api-client · @vpay/config
apps/ dashboard (Next.js)
tests/ e2e (Cypress)
examples/ merchant-curl · merchant-node · webhook-receiver
docs/ adr/ · rfc/ · flows/ · runbooks/ · api/ · status.md
schemas/ *.cstack (syntax verified, design sketch, excluded from the build — see docs/status.md)
.xtask/ repo automation and the two self-checks
Backend — Rust edition 2024, resolver 3, axum, sqlx, rustls only
(native-tls is banned in deny.toml), mimalloc, static musl binaries into
FROM scratch. Tests with cargo nextest and testcontainers.
Frontend — Next.js 15, React 19, TypeScript strict. Design system on
Tailwind + daisyUI + class-variance-authority + Headless UI, with
framer-motion and vaul for motion and sheets. Storybook with the a11y addon.
Vitest for units, Cypress for e2e.
just install # toolchains + pnpm deps
just up # Postgres + a WireMock host per rail
just test # cargo nextest + vitest
just verify # the two self-checks above
just ci # everything CI runs, in CI's orderjust with no argument lists every task.
Both binaries take a clap-based CLI where every option auto-resolves from an
environment variable, with an explicit flag beating its env var
(backends/crates/vpay-config/src/cli.rs). Run --help on either to see the
live flag set — that is more trustworthy than any doc if the two disagree:
cargo run -p vpay-server -- --help
cargo run -p vpay-worker-bin -- --help# flags win over env vars
cargo run -p vpay-server -- --bind 127.0.0.1:8080 --log-format text
# or drive it by env, as compose.yml does
VPAY_BIND=127.0.0.1:8080 VPAY_LOG_FORMAT=text cargo run -p vpay-serverNeither binary calls a payment rail. vpay-server writes rows and serves only
/healthz today; vpay-worker-bin stays up answering shutdown signals but
its job loop is not implemented, and it says so in a startup banner and a
repeating heartbeat log line. --database-url, --config and
--public-base-url are accepted but not yet consumed by anything — see
docs/status.md and
docs/flows/configuration.md.
- Cypress binary. The e2e specs (
frontends/tests/e2e, run viapnpm --filter @vpay/e2e run e2e) needpnpm exec cypress installafterwards on a machine that can reach Cypress's CDN — its binary is not fetched by a plainpnpm installand is not present in every environment. In restricted networks,CYPRESS_INSTALL_BINARY=0lets the rest of the install proceed without it.pnpm -r testno longer touches Cypress at all (@vpay/e2e's own test script ise2e, nottest), so the ordinary unit test sweep works regardless of whether the binary is installed. - musl target.
rustup target add x86_64-unknown-linux-muslbeforejust build-dist.backends/Dockerfilenow builds the host's implicit musl target rather than hardcoding the x86_64 triple, but the Dockerfiles themselves have not been built in this repo's own development environment — seedocs/status.md's Infrastructure section for why.
Start with docs/status.md, then:
- Roadmap — the phases from scaffold to a deployable gateway, and where the project stands in that sequence
- Flows — one document per process, with invariants
- ADRs — decisions and what they cost
- RFCs — proposals not yet decided
- Runbooks — what to do when an alert fires
Apache-2.0. See LICENSE.