Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,10 @@ status. The authenticated status query is the only thing that moves money.
was deleted — both apps now compose the published `@vaam-apps/ui` instead.
`@base-ui/react` leaves the repository entirely with it; `@vaam-apps/ui`'s
behaviour comes from Headless UI (`@headlessui/react`) and Radix
(`@radix-ui/react-dialog`), and its one theme registers under daisyUI's
built-in name `dark`, not `bumblebee` — `frontends/apps/checkout` keeps a
(`@radix-ui/react-dialog`), and its themes register under daisyUI's
built-in names — `dark`, the default, not `bumblebee`, and since 0.1.2 an
opt-in `light` (this said "its one theme" until 2026-09-23) —
`frontends/apps/checkout` keeps a
runtime brand-colour retarget on top of it (`src/config/theme.ts`), the
dashboard does not. `class-variance-authority` remains a real dependency
(of `@vaam-apps/ui` itself) but neither app calls it directly any more.
Expand Down
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,19 @@ deploy/ helm/vpay (rendered and schema-validated; never applied to a c

`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`. **Thirteen of the file's
nineteen models carry statements `vpay-server` actually runs** — `currencies`, `providers`,
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`, `staff_members`,
`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.)_ `backends/migrations` remains the
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`](docs/reference/vpay-db.md#cratestack).
Expand Down Expand Up @@ -411,10 +415,12 @@ helm-check`).

### Running the binaries directly

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:
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.)_

```bash
cargo run -p vpay-server -- --help
Expand Down Expand Up @@ -465,7 +471,8 @@ Every one of those flags has an env var — `VPAY_CONFIG`, `DATABASE_URL`,
`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 binaries call a payment rail.** `vpay-server` calls one when a merchant
**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
Expand Down Expand Up @@ -498,7 +505,8 @@ 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 on **both** binaries, serving `GET /livez` (a static `ok`, the
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
Expand Down
24 changes: 16 additions & 8 deletions backends/crates/vpay-api/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,15 +426,23 @@ impl PaymentIntentObject {
///
/// Neither of those is a hypothetical: both are the *current* callers of
/// `PaymentIntentObject::try_from`. `every_documented_key_is_present_including_the_null_ones`
/// below asserts that object has exactly twelve keys, and that assertion is
/// the tripwire this type exists to leave standing.
/// below asserts that object has exactly thirteen keys, and that assertion is
/// the tripwire this type exists to leave standing. _(This comment, and five
/// more on this type, [`PaymentIntentWithSecret`] and [`ExpandableIntent`],
/// said twelve until 2026-09-23 —
/// as did three in `v1::payment_intents` and one in the integration suite's
/// `webhooks.rs`. The object has had
/// thirteen since `customer` was added on 2026-09-06, and the test has
/// pinned 13 since then; only the prose lagged.)_
///
/// # `#[serde(flatten)]`, so the wire shape is the twelve keys plus one
/// # `#[serde(flatten)]`, so the wire shape is the thirteen keys plus one
///
/// A payer's client decodes one object, not a nested one:
/// `sdks/stripe-js/src/types.ts`'s `PaymentIntent` is
/// `PaymentIntentObject`'s twelve fields with `client_secret` beside them.
/// Flattening is what makes that true *by construction* rather than by two
/// `PaymentIntentObject`'s fields with `client_secret` beside them — all of
/// them but `customer`, which that interface does not declare (measured
/// 2026-09-23; the key is on the wire, TypeScript simply does not type it).
/// Flattening is what makes the server half true *by construction* rather than by two
/// structs agreeing — and it is why `client_secret` is declared after the
/// flattened field: `serde_json::Map` sorts keys on serialisation in this
/// workspace (no `preserve_order`), so declaration order does not affect the
Expand All @@ -452,7 +460,7 @@ impl PaymentIntentObject {
#[derive(Clone, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct PaymentIntentWithSecret {
/// The twelve documented keys, unchanged and rendered by the same code
/// The thirteen documented keys, unchanged and rendered by the same code
/// every other surface uses.
#[serde(flatten)]
pub intent: PaymentIntentObject,
Expand Down Expand Up @@ -555,9 +563,9 @@ impl PaymentIntentWithSecret {
pub enum ExpandableIntent {
/// `"pi_…"` — the merchant surface's answer.
Id(String),
/// The twelve documented keys, and no credential.
/// The thirteen documented keys, and no credential.
Expanded(Box<PaymentIntentObject>),
/// The twelve keys plus `client_secret`.
/// The thirteen keys plus `client_secret`.
ExpandedWithSecret(Box<PaymentIntentWithSecret>),
}

Expand Down
10 changes: 6 additions & 4 deletions backends/crates/vpay-api/src/v1/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,12 @@ pub fn load_config(path: Option<&Path>, profile: &str) -> Result<Config, ConfigE

/// Opens the pool and applies every migration, in that order.
///
/// Both binaries do this before they bind anything: a process that binds a port
/// before proving the database is reachable and up to date would start
/// accepting connections it cannot serve correctly, and `/healthz` runs a real
/// `SELECT 1`.
/// Every mode of `vpay-server` — `serve`, `worker` and `staff add` — does this
/// first, and the two that bind a port do it before they bind anything: a
/// process that binds a port before proving the database is reachable and up
/// to date would start accepting connections it cannot serve correctly, and
/// `/healthz` runs a real `SELECT 1`. (This said "both binaries" until
/// 2026-09-23; there has been one binary since 2026-09-07, issue #77.)
///
/// # Errors
///
Expand Down
7 changes: 4 additions & 3 deletions backends/crates/vpay-api/src/v1/payment_intents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,8 @@ fn charge_in_flight() -> ApiError {
/// The event's `data` is the row the `UPDATE` returned — the intent as it now
/// stands, `status: "canceled"` — rendered here rather than projected from
/// what the request asked for, for `vpay_api::v1::invoices`' reason. It is
/// the twelve-key [`PaymentIntentObject`] and carries no `client_secret`:
/// the thirteen-key [`PaymentIntentObject`] (this said "twelve-key" until
/// 2026-09-23) and carries no `client_secret`:
/// [`SecretRendering::Omit`] is what `cancel` answers a merchant with, and an
/// event body is stored, signed, delivered at-least-once and replayed on
/// every rung of the retry ladder, so a payer credential in one would outlive
Expand Down Expand Up @@ -1708,7 +1709,7 @@ async fn persist_decline(
// still `requires_payment_method`, because the
// lifecycle has no `failed` status. Rendered from the
// row and never projected from the request, for
// `vpay_api::v1::invoices`' reason; the twelve-key
// `vpay_api::v1::invoices`' reason; the thirteen-key
// object, so no `client_secret` reaches a body that is
// stored, signed and replayed.
let object = PaymentIntentObject::try_from(&intent)?;
Expand Down Expand Up @@ -2312,7 +2313,7 @@ fn not_found(id: &str) -> ApiError {
/// another's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SecretRendering {
/// The twelve-key [`PaymentIntentObject`] — what the merchant surface's
/// The thirteen-key [`PaymentIntentObject`] — what the merchant surface's
/// `confirm`, `cancel` and `list` answer with, and what
/// `vpay_worker`'s `intent_snapshot` writes into `events.data`.
Omit,
Expand Down
10 changes: 9 additions & 1 deletion backends/crates/vpay-core/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,17 @@ pub enum RefundStatus {
///
/// ```text
/// draft ──finalize──> open ──> paid | void | uncollectible
/// └────void────> void (also: DELETE, which removes it entirely)
/// └────DELETE────> (gone: a draft is removed, never voided)
/// ```
///
/// _The second line drew `draft ──void──> void` until 2026-09-23, an edge
/// that has never existed: `vpay_db::invoices::void_in_tx`'s `WHERE` names
/// `status = 'open'` alone, and migration `0036`'s
/// `number_is_assigned_at_finalize` would refuse a voided draft anyway,
/// because a non-draft invoice must carry a number and a draft has none.
/// `DELETE /v1/invoices/{id}` is the only way out of `draft` other than
/// `finalize`._
///
/// There is deliberately **no** `can_transition_to` on this type. A method
/// here would be a second copy of a rule that has to be enforced in the
/// statement to be enforced at all: every transition in `vpay_db::invoices`
Expand Down
6 changes: 5 additions & 1 deletion backends/crates/vpay-db/src/invoices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1562,7 +1562,11 @@ pub(crate) async fn finalize_in_tx(
.map_err(classify_write)
}

/// Voids a `draft` or `open` invoice inside the caller's transaction.
/// Voids an `open` invoice inside the caller's transaction.
///
/// _This line said "a `draft` or `open` invoice" until 2026-09-23, which the
/// statement below has never done: its `WHERE` names `'open'` alone, and a
/// draft is deleted instead — see "Only an `open` invoice can be voided"._
///
/// `Ok(None)` means this merchant has no such invoice in a voidable state, or
/// it has a payment intent that has not been canceled — see
Expand Down
89 changes: 61 additions & 28 deletions backends/crates/vpay-db/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ mod search_checkout_sessions;
///
/// **`procedure_router`, never `router()`.** The generated `router()`
/// merges `model_router(...)` — the CRUD CrateStack generates for every one
/// of this schema's nineteen models, creates/updates/deletes included,
/// of this schema's twenty models (nineteen until `ManualPayment`, #251),
/// creates/updates/deletes included,
/// whether or not anything routes it — with `procedure_router(...)`
/// (`cratestack-macros-0.12.0/src/include/server/axum_module/router_fn.rs`).
/// `procedure_router` is the same generated function with that merge
Expand Down Expand Up @@ -323,33 +324,41 @@ mod tests {
);
}

/// Walks candidate paths for every one of the nineteen models' generated
/// Walks candidate paths for every one of the twenty models' generated
/// CRUD, proving none of them is mounted through
/// [`super::dashboard_procedure_router`] — the mechanical version of
/// `DASH_ROUTES`' own doc's reason for keeping a route table at all:
/// axum 0.8 cannot enumerate a built `Router`, so this probes it instead
/// of trying to inspect it.
/// of trying to inspect it. _(This said "nineteen" until 2026-09-23, and
/// the list below carried nineteen, while `schemas/vpay.cstack` had
/// declared a twentieth, `model ManualPayment`, since #251. The list is
/// now checked against the schema's own `cratestack_schema::MODELS`
/// first, so that gap fails this test instead of hiding in it.)_
///
/// **Decisive.** Changing `super::dashboard_procedure_router`'s call
/// from `cratestack_schema::axum::procedure_router(...)` to
/// `cratestack_schema::axum::router(...)` (the merged form that also
/// mounts `model_router`) turns every one of the seventy-six assertions
/// below red at once, because every model's `list`/`create` path
/// mounts `model_router`) turns every one of the 160 route assertions
/// below red at once — twenty models, two paths each, four methods per
/// path. _(This said "seventy-six" until 2026-09-23, which is nineteen
/// times four and forgets the two paths; it said "seventy-two", eighteen
/// times four, on the day it was written. Neither count was ever the
/// loop's.)_ Every model's `list`/`create` path
/// (`/{plural}`) and `get`/`update`/`delete` path (`/{plural}/{id}`)
/// would start answering `405` (a route matched, the method did not)
/// instead of this crate's honest `404` (no route matched at all) —
/// `cratestack-macros-0.12.0/src/axum/model/routes.rs` is where those
/// paths and that four-method set come from, and
/// `docs/reference/vpay-db/cratestack.md`'s "the table name is decided
/// by the model name" is why the nineteen strings below are the same
/// by the model name" is why the twenty table strings below are the same
/// ones `backends/migrations/*.sql` names as tables.
#[tokio::test]
async fn no_generated_model_route_is_mounted_only_the_one_procedure_is() {
use tower::ServiceExt as _;

// A pool that never connects, exactly like every other test in this
// module. Every probed request below is refused before a statement
// could run: the nineteen models' paths never match any route at
// could run: the twenty models' paths never match any route at
// all, and the one real procedure is asked with a method it does
// not serve (`GET`, never `POST`) so this test proves routing
// without needing the lazy pool to answer anything.
Expand All @@ -358,33 +367,57 @@ mod tests {
|_: &::cratestack::axum::http::Extensions| Some("acme-cameroon-tenant".to_owned()),
));

const MODEL_TABLES: [&str; 19] = [
"currencies",
"providers",
"payment_intents",
"charges",
"refunds",
"checkout_sessions",
"ledger_transactions",
"ledger_entries",
"disabled_clients",
"events",
"webhook_deliveries",
"customers",
"staff_members",
"staff_sessions",
"oauth_authorization_codes",
// `(model, table)`. The model column is checked against
// `cratestack_schema::MODELS` — the macro's own list of every
// `model` in `schemas/vpay.cstack`, as rustc compiled it — before a
// request is sent, in both directions: a model the schema declares
// and this list lacks is one whose generated CRUD this test would
// never probe, and a name here the schema no longer declares is a
// probe of nothing. Until 2026-09-23 this was a hand-kept
// `[&str; 19]` of table names with no such check.
const MODEL_TABLES: [(&str, &str); 20] = [
("Currency", "currencies"),
("Provider", "providers"),
("PaymentIntent", "payment_intents"),
("Charge", "charges"),
("Refund", "refunds"),
("CheckoutSession", "checkout_sessions"),
("LedgerTransaction", "ledger_transactions"),
("LedgerEntry", "ledger_entries"),
("DisabledClient", "disabled_clients"),
("Event", "events"),
("WebhookDelivery", "webhook_deliveries"),
("Customer", "customers"),
("StaffMember", "staff_members"),
("StaffSession", "staff_sessions"),
("OauthAuthorizationCode", "oauth_authorization_codes"),
// Nineteenth, added when this branch's `model Credential`
// (migration 0044) merged with Lane C's transport: a model
// declared after this list was written is exactly the one whose
// generated CRUD nobody has yet proved is unmounted.
"credentials",
"invoices",
"invoice_items",
"rate_limit_windows",
("Credential", "credentials"),
("Invoice", "invoices"),
("InvoiceItem", "invoice_items"),
// Twentieth, 2026-09-23 — the case the comment above warns
// about, and it happened: `model ManualPayment` (migration 0049,
// #251) was declared without being added here, and nothing
// noticed until a skills re-verification did.
("ManualPayment", "manual_payments"),
("RateLimitWindow", "rate_limit_windows"),
];

for table in MODEL_TABLES {
let mut listed: Vec<&str> = MODEL_TABLES.iter().map(|(model, _)| *model).collect();
let mut declared: Vec<&str> = cratestack_schema::MODELS.to_vec();
listed.sort_unstable();
declared.sort_unstable();
assert_eq!(
listed, declared,
"MODEL_TABLES and schemas/vpay.cstack's models disagree. Add or remove the \
`(model, table)` pair; the table is `pluralize(to_snake_case(model))`, the name \
the migrations create"
);

for (_, table) in MODEL_TABLES {
for path in [format!("/{table}"), format!("/{table}/some_id")] {
for method in ["GET", "POST", "PATCH", "DELETE"] {
let router = super::dashboard_procedure_router(cs.clone(), auth.clone());
Expand Down
3 changes: 2 additions & 1 deletion backends/crates/vpay-db/src/schema/search_payment_intents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
//!
//! **A transport serves it.** `crate::schema::dashboard_procedure_router`
//! mounts `procedure_router` — never `router()`, which would also mount the
//! generated CRUD for every one of this schema's nineteen models — behind
//! generated CRUD for every one of this schema's twenty models (nineteen
//! until `ManualPayment`, #251) — behind
//! `vpay-api`'s `require_dashboard_procedure_token`, over a context built by
//! `crate::dashboard_transport::ExtensionAuthProvider` from the tenant that
//! middleware already resolved
Expand Down
Loading
Loading