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.
What works, against stub rails. A payment goes end to end: a merchant authenticates, creates a PaymentIntent, confirms it,
vpay-workerpolls the charge, settlement commits, and a signed webhook is delivered.just demowalks six of those on both rails to every outcome each rail documents, and a real browser has driven the hosted and the embedded checkout page (just test-e2e). Every rail in every one of those runs is awiremock/wiremockcontainer reached over HTTP.What has never happened. No payer has been prompted on a handset, no money has moved, no webhook has reached a merchant endpoint outside this repository, no rail has ever refunded anything, and no cluster has ever run vpay. The dashboard supports staff sign-in and tenant-bound reads against the local test stack; it has not run in a deployment.
(This said "no HTTP call to MTN's or Orange's own endpoints — not production, not even their sandboxes" until 2026-09-16, and had been wrong since 2026-09-15, when one EUR
mtn_momoPaymentIntent was created, confirmed and settled against MTN's real sandbox. The payer number was an MTN-sandbox test MSISDN the sandbox settles by itself. Orange's rail has still never been called, and neither has MTN's Disbursements product, which is what a refund on that rail is. Seedocs/status.md.)Read
docs/status.mdbefore forming any expectation of what works. It is machine-checked in both directions:cargo xtask verify-statusfails the build if the code carries an unimplemented path that page does not declare, and if that page declares one no shipping code carries any more.
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
(ADR-0010,
docs/flows/merchant-auth.md).
A Stripe SDK cannot do that handshake by itself, but it does not have to:
stripe-node takes an arbitrary config.authenticator, and
@vaam-apps/vpay-sdk/stripe supplies one, so new Stripe("", { authenticator, host, port, protocol }) reaches vpay with an empty key. That is
proven by sdks/stripe-compat, which drives the real
stripe package against a live compose stack in CI's e2e (compose) job — as
far as a confirmed intent polling through to succeeded and a delivered
webhook verifying with stripe.webhooks.constructEvent. See
docs/flows/stripe-sdk-compat.md for every
divergence, and examples/merchant-curl for the
underlying two-step flow.
vpay also ships its own merchant SDKs — sdks/rust (vpay-sdk)
and sdks/nodejs (@vaam-apps/vpay-sdk) — plus a browser
client, sdks/stripe-js (@vaam-apps/vpay-stripe-js), for
the payer-facing surface. The Rust SDK is what
examples/merchant-demo and just demo drive
against a running vpay-server. No test inside (struck 2026-09-23: wrong since 2026-09-10). Most of that
package's tests still answer themselves through a sdks/nodejs itself has
ever spoken to a vpay — every server in that package's own tests is a
node:http stubnode:http stub, but
sdks/nodejs/src/invoices.live.test.ts (2026-09-10) and
refunds.live.test.ts (2026-09-16) drive a real vpay-server over a socket,
and CI's e2e job runs them. Beyond the SDK's own tests, sdks/stripe-compat
and examples/shop also drive a live stack from Node. The two merchant SDKs are held to the same
capability matrix, machine-checked in both directions on every just verify —
see docs/sdks/parity.md
(ADR-0015) for where they agree and the dated,
owned list of where they still don't.
Two payer-device surfaces sit beside that browser client and are not
merchant SDKs either: sdks/flutter/vpay_checkout_flutter
(ADR-0021) and, since 2026-09-22,
sdks/tauri/tauri-plugin-vpay-checkout
(ADR-0023) — vpay's hosted checkout
opened in the payer's own browser, answered by polling the payment intent
and never by reading a URL. Each has its own table in
docs/sdks/parity.md, and neither is gated by
just ci: see docs/status/mobile-flutter-plugin.md
and docs/status/mobile-tauri-plugin.md
for what each has and has not actually been run against.
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 (either mode). A stub rail is a WireMock
host in configuration — the same mechanism production uses to reach a real
rail. cargo xtask verify-no-mocks walks cargo metadata's dependency graph
from each shipping binary and fails the build otherwise.
(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, in both directions. Tests for unbuilt features are
#[ignore]d with a reason; just verify-ignored pins the workspace at 0
of them, so a green run never overstates coverage.
GET /healthz, the merchant OP (POST /v1/oauth/token,
GET /v1/oauth/.well-known/openid-configuration, GET /v1/oauth/jwks.json),
and behind a merchant bearer token and a scope check:
| Resource | Methods |
|---|---|
/v1/payment_intents |
POST, GET, GET {id}, POST {id}/confirm, POST {id}/cancel |
/v1/checkout/sessions |
POST, GET, GET {id}, POST {id}/expire |
/v1/customers |
POST, GET, GET {id}, POST {id}, DELETE {id} |
/v1/invoices |
POST, GET |
/v1/invoices/{id} |
GET, POST, PATCH, DELETE |
/v1/invoices/{id}/finalize |
POST |
/v1/invoices/{id}/void |
POST |
/v1/invoices/{id}/mark_uncollectible |
POST |
/v1/invoices/{id}/pay |
POST |
/v1/invoice_items |
POST |
/v1/invoice_items/{id} |
GET, POST, PATCH, DELETE |
/v1/events |
GET, GET {id} |
/v1/refunds |
POST, GET |
/v1/refunds/{id} |
GET, POST |
/v1/refunds/{id}/cancel |
POST |
/v1/account_holders |
GET |
POST/PATCH are one handler on both /v1/invoices/{id} and
/v1/invoice_items/{id} — Stripe's API has no PATCH, so a merchant's
existing client sends POST; PATCH is mounted beside it because a partial
update is what the verb means. There is no collection GET on
/v1/invoice_items: an invoice's lines are read from the invoice itself. See
docs/flows/invoices.md for the object model, the
state machine and what pay does on a market with no stored payment methods.
An Idempotency-Key is required on every POST. The table is the constant
vpay_api::V1_ROUTES, and a boundary test walks it — it does not list paths of
its own — asserting every entry answers 401 without a token
(every_registered_v1_path_answers_401_without_a_token,
backends/tests/integration/tests/payment_intents.rs).
GET /v1/balance is routed nowhere and answers the honest 404 from the
nest's fallback. POST /v1/refunds was beside it in that sentence until
2026-09-16, when RFC-0003 § 2 mounted the create, the update, the list and
the cancel alongside the read — so a merchant can now create a refund, and
the database half it reaches has been there since 2026-09-15:
vpay_db::Refunds::create inserts the row and reserves its amount against
the intent in one transaction (RFC-0003 § 3), exercised against a real
Postgres.
A 201 from that route does not mean money came back, and nothing in
this repository can make it mean that yet. The refund is written as
pending, charge.refunded is emitted in the same transaction, the rail is
instructed — and nothing settles a pending refund, because the provider
port has no refund status read and there is no refund poll ladder (RFC-0003
open question 8, open). The rail half moved on 2026-09-15 and is in two
states, neither of them Unsupported:
mtn_momo::refund makes MTN's Disbursements transfer call (RFC-0003 § 5) —
but no REAL MTN Disbursements credential exists in this project and MTN's
Disbursements product has never been called from this repository, so it is
WireMock-proven and rail-unproven — the only subscription key anywhere is the
stub the e2e/demo stack points at a WireMock container, which is what the SDKs'
live refund suites drive — while orange_money::refund is a declared
NotImplemented token, because an Orange refund is an outbound transfer this
repository has no specification to write one against. Both rails declare
supports_refunds: true: that gap is vpay's, not the rails'.
Two other surfaces exist: /v1/browser, which a payer's own page calls with a
publishable key and an intent's client_secret instead of a bearer token
(docs/flows/browser-checkout.md), and
POST /provider/{code}/callback, the one route a rail calls — proven against
WireMock, never called by MTN or Orange.
backends/
crates/ vpay-core, -config, -db, -ledger, -provider, adapters, -api, -worker, -testkit
apps/ vpay-server (one musl → scratch image; `worker` is a subcommand)
tests/ integration (testcontainers) · conformance (shared adapter suite) · webhook-receiver
frontends/
packages/ @vpay/tokens · @vpay/api-client · @vpay/config
(@vpay/ui, the in-repo design system, deleted 2026-09-12 —
both apps compose the published @vaam-apps/ui instead)
apps/ checkout (the payment page vpay serves) · dashboard (the staff console, read-only; see below)
tests/ e2e (Cypress)
sdks/
rust/ vpay-sdk — merchant SDK (workspace crate)
nodejs/ @vaam-apps/vpay-sdk — the same, zero-dependency Node ≥ 22 ESM
stripe-js/ @vaam-apps/vpay-stripe-js — the browser client for a payer's page
flutter/ vpay_checkout_flutter — payer surface: the hosted page in the payer's own browser (ADR-0021)
tauri/ tauri-plugin-vpay-checkout — the same, on Tauri v2; its own cargo workspace (ADR-0023)
stripe-compat/ — the official `stripe` package, driven against a real stack
examples/ merchant-demo (`just demo`) · shop · checkout-browser · merchant-curl
merchant-node · merchant-stripe-node · webhook-receiver
docs/ README.md (the index) · adr/ · rfc/ · flows/ · reference/ · runbooks/ · sdks/ · api/ · plans/ · status.md
schemas/ vpay.cstack (compiled by vpay-db and gated by `just check-schema` — see docs/status.md)
deploy/ helm/vpay (rendered and schema-validated; never applied to a cluster)
.xtask/ repo automation and the verify gates
schemas/vpay.cstack is no longer outside the build: vpay-db compiles it
(include_server_schema!) and just check-schema runs cratestack check
against the pinned CLI inside just verify. Fourteen of the file's
twenty models carry statements vpay-server actually runs — currencies, providers,
disabled_clients, customers, events, webhook_deliveries,
checkout_sessions, invoices, invoice_items, manual_payments, staff_members,
staff_sessions, oauth_authorization_codes and credentials; the six that do not are
payment_intents, charges, refunds, ledger_transactions and
ledger_entries, plus rate_limit_windows, whose SQL remains hand-written.
(Measured 2026-09-16 by counting @@allow arms; this said "nine of thirteen"
and had been stale since S4b and S5 added four models and moved five tables.
Re-measured 2026-09-23 by counting the .run(..)/.run_in_tx(..) calls
themselves — 36 in non-test vpay-db code, over these fourteen; it said
"thirteen of nineteen" until then, one merge behind model ManualPayment,
#251.) backends/migrations remains the
authoritative schema, and this file has diverged from it on constraints
CrateStack's grammar cannot express. See
docs/reference/vpay-db.md.
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 in frontends/apps (examples/shop is on 16),
React 19, TypeScript strict. Design system on
Tailwind 4 + daisyUI 5 (bumblebee) + class-variance-authority +
@base-ui/react. Storybook 10 with the a11y addon. Vitest for units, Cypress
for e2e.
just install # toolchains + pnpm deps
just up # Postgres + a WireMock host per railjust with no argument lists every task.
Prerequisites: Docker (with Compose v2.24+ — the demo overlay uses
!reset), the Rust toolchain rust-toolchain.toml pins, just, jq, curl
and openssl. pnpm is needed only to work on the web packages; just demo
builds every image it needs in Docker. The Node baseline is .nvmrc —
22.23.2 — and .npmrc sets engine-strict=true, so pnpm install fails
rather than warns on an older Node.
just demojust demo is just demo-up then just demo-walk, and both exist separately
so the walkthrough is re-runnable against a stack that is already up. just demo-status says what is running and under which project; just demo-down
removes the containers and their volumes.
It generates a throwaway RS256 key for the server's OAuth provider and a second
one for a demo merchant (.e2e/, git-ignored, both discarded with the stack),
registers the merchant's public JWK in a demo profile overlay, and brings
up nine services with up --wait rather than a sleep: Postgres, both
WireMock rail stubs, the WireMock webhook receiver, vpay-server,
vpay-worker, vpay-checkout (the payment page), vpay-shop (the demo
merchant's storefront) and dashboard (the staff console). (This said
eight, without dashboard, until 2026-09-23; the justfile's
demo_services has named nine since exp28 on 2026-09-07.) It then runs
examples/merchant-demo, a Rust binary built on the
real merchant SDK.
Six steps, the fourth of which is a table:
-
the OP's discovery document and JWKS — its issuer and the
kidit signs with; -
an access token obtained with
client_credentials+private_key_jwt, shown as its decodediss/aud/sub/expclaims (never the token itself); -
a
/v1call without a token — a401carrying vpay's error envelope, so you can see the authentication boundary is real; -
six payments, on both rails, to every outcome each rail documents. Each one creates a PaymentIntent through the SDK, reads it back, confirms it, waits for
vpay-workerto settle it, and then reads the webhook that settlement produced out of the receiver's own request journal and verifies itsVpay-Signaturewith the SDK:# Rail Outcome last_payment_error.codeEvent delivered 1 mtn_momothe payer approves → succeeded— payment_intent.succeeded2 mtn_momono balance → requires_payment_methodinsufficient_fundspayment_intent.payment_failed3 mtn_momothe prompt expires → requires_payment_methodpayer_timeoutpayment_intent.payment_failed4 orange_moneyrequires_action+ the redirect URL →succeeded— payment_intent.succeeded5 orange_moneythe hosted page expires → requires_payment_methodpayer_timeoutpayment_intent.payment_failed6 orange_moneythe rail refuses → requires_payment_methodprovider_errorpayment_intent.payment_failed -
one hosted and one embedded Checkout Session, on a fresh intent each, read back and printed as a merchant would use them. It stops there — the program has no browser, and both sessions are still
openwhen it exits; -
GET /v1/account_holders— the three-way answer a name lookup has.
Every outcome is chosen at the rail stub, never in the demo. MTN's is
selected by the payer's MSISDN and Orange's by the amount, because those are
the only fields of each rail's protocol a merchant actually controls. The
three MSISDNs the walkthrough pays from — 237600000ce0, 237600000f01,
237600000f02 — are not phone numbers: the last three characters are a
hex steering code the stub keys its scenario on, and step 6 shows
GET /v1/account_holders refusing one of them with a 400 because it is not
a Cameroon E.164 number. Nothing rewrites stored state to
make an outcome happen. The stubs are WireMock containers reached over HTTP
exactly as a real rail would be — that is the rule in AGENTS.md: a
stub rail is a host, never a linked implementation — and MTN's and Orange's
real endpoints have never been called by this code. A succeeded here means
vpay-worker asked a stub and the stub said SUCCESSFUL; it does not mean
anyone paid.
Every payment above is XAF, on both rails, and that is a property of the
demo overlay alone — .e2e/application-demo.yml, the file just gen-demo-keys writes. The demo shop prices its catalogue in XAF, offers a
payer both rails, and /v1 refuses a confirm whose intent currency is not the
rail's settlement currency; one currency for both rails is what makes the
shop's MTN button payable. Do not read that as "MTN accepts XAF". It does
not: MTN's real sandbox rejects XAF, which is why
config/application.yml still puts mtn_momo on currency: EUR and why
application-sandbox.yml inherits it. Configuration either way — never a code
branch.
The dashboard is the one service of the demo file set that stays down,
and that is a statement rather than an optimisation: it renders a scaffold
notice and a status-badge reference, makes no call to Corrected 2026-09-23: wrong since exp28 (2026-09-07), when the
dashboard gained staff sign-in and joined vpay-server and has no
login, so there is no screen that could show the six payments the walkthrough
just made.demo_services. It starts with the
rest of the demo: a staff member signs in with a password and TOTP (just demo-staff creates one) and reads one merchant's payments, refunds,
deliveries, customers and checkout sessions through /dash/v1. It shows the
shop's tenant, not the walkthrough's, on purpose.
docs/runbooks/demo/dashboard-sign-in.md
is how to get in and why. It is read-only, and it has never run in a
deployment. The app has called them since 2026-09-12, through its
server-side BFF under /dash/v1's two read routes and staff sign-in … nothing in the
app calls them yet.app/api/dash/.
Running two demos on one machine is what the just variables are for:
demo_project picks the Compose project (so different containers, network and
pgdata volume) and demo_port, demo_receiver_port, demo_orange_port,
demo_checkout_port and demo_shop_port are the published host ports; the
server still binds 8080 inside its container.
just demo_port=18080 demo_receiver_port=18083 demo
just demo_project=vpay-demo demo-down # teardown needs no portdocs/runbooks/demo.md is the full procedure — the
exact commands, the real output of a real run, what that run proves and what it
does not, and the hazards it does not close.
docs/runbooks/checkout.md is where to start if
you want to buy something from the demo shop in a browser.
Three commands, with genuinely different requirements:
| Command | Needs | Runs |
|---|---|---|
just verify |
Rust, and the pinned cratestack CLI on PATH; seconds |
the gates the verify recipe lists in the justfile — fifteen of them on this commit — and one advisory report, verify-docs, which never fails. The recipe echoes its own count on success, so the justfile is the number and this sentence is not. See AGENTS.md for what each gate refuses. check-schema fails rather than skips when the CLI is missing, because a skipped check checked nothing |
just test |
Docker, and Node | cargo nextest run --workspace, cargo test --doc --workspace and pnpm -r test. The Postgres-backed suites use testcontainers and fail loudly without a reachable daemon — they never skip, so a green run is a real one. The adapter conformance suite needs Docker too: it starts a real wiremock/wiremock container per rail rather than an in-process HTTP double, because a stub rail is a host reached over HTTP (ADR-0006) |
just test-e2e |
Docker, and Cypress's binary | builds the images, boots compose.yml + compose.e2e.yml + compose.demo.yml, runs the browser suite, tears the stack down. Four specs, 11 tests. This is what CI's e2e job does |
just verify-ignored is the count that keeps the suite honest. Measured on
this tree, 2026-09-07: 0 ignored, 46 test binaries, 1563 tests listed.
Re-measured 2026-09-20: 0 ignored, 48 test binaries, 2028 tests listed —
expected_suites moved 46 → 47 → 48 across 2026-09-12 and 2026-09-13 (see the
justfile's own dated log), and the total climbed with it. Two of those three
are pinned exactly — expected_ignored and expected_suites — and moving
either fails the recipe until it and docs/status.md move with it. The third
is a floor, not a pin (min_tests, currently 1080): the total is free to
rise, and does, so read the count above as a snapshot of the day it was
measured, not a promise it will still read the same tomorrow.
just ci is what to run before opening a PR: CI's self-checks, rust, web
and supply-chain steps, in CI's order. The two jobs it does not cover are CI's
e2e (compose) (just test-e2e) and deploy (helm chart) (just helm-check).
Every mode of the one binary takes 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 a mode to
see the live flag set — that is more trustworthy than any doc if the two
disagree. (This said "Both binaries … on either" until 2026-09-23, sixteen
days after the two became one.)
cargo run -p vpay-server -- --help
cargo run -p vpay-server -- worker --helpOne binary since 2026-09-07 (issue #77): with no subcommand it serves the API,
worker runs the job loop, and staff add creates a dashboard account. It was
two packages and two images (vpay-server, vpay-worker-bin) before that.
vpay-server signs merchant tokens, so it needs an RS256 signing key before it
will start. Generate one once, offline:
cargo xtask gen-signing-key --out ./secrets # writes ./secrets/oauth-signing-key.pemThe private key stays in that file — nothing prints it, logs it or stores it in
the database. In a real deployment it is a Kubernetes Secret and
--oauth-signing-key-file points at the mount.
# The rail credentials in config/application.yml are ${VAR} placeholders, and
# an unresolved one is a fatal, named startup error — not an empty string.
export MTN_SUBSCRIPTION_KEY=dev MTN_API_KEY=dev \
MTN_API_USER=11111111-2222-3333-4444-555555555555 \
MTN_DISBURSEMENT_SUBSCRIPTION_KEY= MTN_DISBURSEMENT_API_KEY= \
MTN_DISBURSEMENT_API_USER= \
ORANGE_MERCHANT_KEY=dev ORANGE_CLIENT_ID=dev ORANGE_CLIENT_SECRET=dev
# The three MTN_DISBURSEMENT_* names were added on 2026-09-15 with
# `mtn_momo::refund` (RFC-0003 section 5). Empty is the right value here: no
# REAL MTN Disbursements credential exists in this project, and `refund`
# answers ProviderError::Config naming the blank one. (The e2e/demo stack sets
# stub values instead, aimed at a wiremock container -- `just gen-demo-keys`.)
# They must still be *set* —
# unset is an unresolved placeholder, which is the fatal error above.
# flags win over env vars
cargo run -p vpay-server -- \
--config config/application.yml \
--database-url postgres://vpay:vpay@localhost:5432/vpay \
--oauth-signing-key-file ./secrets/oauth-signing-key.pem \
--bind 127.0.0.1:8080 --log-format textEvery one of those flags has an env var — VPAY_CONFIG, DATABASE_URL,
VPAY_OAUTH_SIGNING_KEY_FILE, VPAY_BIND, VPAY_LOG_FORMAT — which is how
compose.e2e.yml drives the same binary; a test fails if one is renamed or
dropped. The Postgres those URLs point at is the one just up starts.
Both modes call a payment rail (this said "both binaries" until
2026-09-23; there has been one binary since 2026-09-07). vpay-server calls one when a merchant
confirms an intent; vpay-server worker runs the job loop
(vpay_worker::run_loop) that claims the poll_charge job the confirm
committed, asks the rail for the charge's status on a poll ladder, and commits
the charge, the intent and one event in a single transaction. It reaps leases
stranded by a crash at boot and on its own timer, and prints one job loop gauge line a minute. Whether the rail either of them reaches is MTN, Orange or
a WireMock stub is a line in config/application.yml, and to date it has only
ever been a stub.
--config and --database-url are required by every mode and
--oauth-signing-key-file by the serve mode alone (the worker issues no
tokens and does not accept the flag); all three are genuinely consumed, and
a missing one refuses to start before the port is bound. All three exit
78 (EX_CONFIG — "fix your configuration"), in serve and in worker
alike, so a supervisor may read 78 as "the operator forgot something" and
69 as "wait for Postgres". Two of the three exit
Corrected 2026-09-10 (issue #87): that was true until this commit and is
not now. 78 and one does not:
a missing --database-url exits 1, because main raises a bare
anyhow error there and exit_code_for has nothing to classify.--database-url raises a typed
StartupError::MissingDatabaseUrl from both call sites, and the two
subprocess cases that fail if either one reverts are a_missing_database_url_is_exit_78_naming_the_problem and its worker:: twin in backends/apps/vpay-server/tests/cli.rs.
Measured by hand as well, on the shipping binary: 78 in both modes, with a
message naming --database-url and DATABASE_URL. The URL
a merchant's tokens carry comes from Config's deployment.public_base_url in
the YAML, which the OP's issuer is derived from
(vpay_api::op::issuer_for → {public_base_url}/v1/oauth). There is no
--public-base-url flag: it was accepted, parsed and read by nothing, and was
removed on 2026-09-03, so a deployment that sets it now fails to start rather
than being silently ignored.
--observability-bind (VPAY_OBSERVABILITY_BIND, default 0.0.0.0:9090) is a
second listener in both serve and worker modes (this said "both
binaries" until 2026-09-23), serving GET /livez (a static ok, the
liveness probe) and GET /metrics (Prometheus text). Neither is on the
--bind port, because that one is fronted by an Ingress and /metrics is an
operational map of the deployment. /healthz stays on 8080 and stays the
readiness probe. Nothing has ever scraped /metrics — every series it
exports is one a scrape would find, never one anyone has watched over time.
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. - Rootless Docker.
testcontainerstalks to/var/run/docker.sockby default. If yourdockerCLI uses a rootless context, point the tests at it:DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock cargo nextest run --workspace. The Postgres-backed suites needpostgres:16-alpinepulled. - musl target.
rustup target add x86_64-unknown-linux-muslbeforejust build-dist.backends/Dockerfilebuilds the host's implicit musl target rather than hardcoding the x86_64 triple (ADR-0014). - A stale
pgdatavolume. The demo shop's database is created once, from Postgres's entrypoint, on an empty data directory. A volume from before the shop landed has noshopdatabase andvpay-shopdies inzen migrate deploy.just demo-downremoves volumes, which is the fix.
Start with CONTRIBUTING.md, then read the code and tests for the change you are making. docs/status.md states the current limits. docs/README.md indexes optional API, flow, runbook, and decision references. AGENTS.md contains the full policy for high-risk changes.
For a readable, illustrated guide to vpay as a whole, see the human
documentation at vpay-oss.vaam.store
(vaam-apps/vpay-docs). It covers
what vpay is, how a payment moves, how to integrate and how to operate it. It
describes one vpay release at a time and names that release on every page.
It is summarised from this repository's docs/, which stays the source of
truth: where the two disagree, this repository is right.
Apache-2.0. See LICENSE.