Gunnar

nordisk/monetize

monetize

monetize

The money engine for the Vetra products: plans, entitlement as signed facts, the reserve → provision → capture transaction, plugin seams for payment vendors and cloud providers, and paid signup. One gRPC server and one operator console serve many products; a product compiles in a thin client and puts one check at its own choke point.

Building

git clone ssh://git.gunnar.rs:2222/nordisk/monetize
git clone ssh://git.gunnar.rs:2222/nordisk/edda
git clone ssh://git.gunnar.rs:2222/nordisk/Skidbladnir
cd monetize
cargo build --release -p monetize-server -p monetize-ui-server

The root workspace takes some crates by path from the sibling checkouts ../edda and ../Skidbladnir, so both sit beside the clone: a clone of monetize alone does not build. A product that links monetize takes its open crates (monetize-embed, monetize-product, monetize-proto, ...) from crates.io and needs no checkout.

This page documents monetize 0.3.0 — the version monetize-server --version prints.

Docs: Integrating an application with monetize v2.0 (the picture, the data model) · today's STE manual · the plan: MONETIZE-V2.0-PLAN.md in workspace_nordisk.

License: AGPL-3.0-only, © 2026 Vetra Automation AB and Ignalina ApS. Commercial terms: COMMERCIAL-LICENSING.md.

The solo monetize box

ONE machine runs monetize for every product. monetize-admin (its own workspace, crates/monetize-admin) is that box's PID 1 on a tunnr kernel, and the same binary is its two other processes, each on its own loopback address:

processstarted aslistens on
monetize-servermonetize-admin server --config /data/monetize/monetize.toml127.0.0.1:50061
the consolemonetize-admin ui --bind 127.0.0.1:8091 --server 127.0.0.1:50061 --root /data127.0.0.1:8091
PID 1 (the edge)korp-start, no arguments:80/:443, the operator's door :8475

The edge carries /callback/<kind>/<instance> and the estate-facing gRPC services (SoldEstate, Signup, EstateOrder, Purchase, Products) to the server, refuses every other gRPC service (404), and carries the rest to the console. monetize dials no estate: every estate calls monetize. Its only outbound calls go to the cloud providers and the payment vendors.

Each product names its vendor instance. Each Stripe instance reads MONETIZE_STRIPE_<INSTANCE>_SECRET_KEY and MONETIZE_STRIPE_<INSTANCE>_WEBHOOK_SECRET, and its webhook is https://<box>/callback/stripe/<instance>:

[vendors.stripe.gunnar]
enabled = true
[vendors.stripe.holger]
enabled = true

[products.gunnar]
vendor = "stripe/gunnar"
[products.holger]
vendor = "stripe/holger"

Each product sells its own signup from its own [signups.<product>]: its plans (every id starts with <product>/), the meter their caps land on, its oversell allowance and its signup guest (the feed's signup_guest.<product>.*). The block's name is the product; Signup.CheckName, Signup.Start and Signup.Plans name the product they are for, and a call that names none is refused. The old single [signup] is refused at load.

[signups.gunnar.plans."gunnar/10gb-1m"]
amount_minor = 10000
currency = "Sek"
cap_bytes = 10737418240
period_days = 30
[signups.holger]
cap_meter = "disk_bytes"

The installer feeds the box (the kit's declaration), and after the install the operator PUTs the whole feed on the door (PUT /feed, mutual TLS, the operator's certificate only). A feed monetize-server would not start on (for example a priced product with no vendor) is refused whole, by name.

The gRPC services

gRPC is the only control plane. The one exception is the vendor webhook, which is HTTP because the vendor speaks HTTP.

servicewhat it answers
Productswhich product plugins are linked, the meters each declares and whether a cap on one is policed, the deployment's price lists and its one policy, each product's reachability, and the spend against each cloud's ceiling
Purchasea quote for an order, the start of the reserve → provision → capture transaction, the state of an attempt, and the abandonment of one
Usageone tenant's meters, every tenant's at once, a meter's history, its trend and how long there is, and the fleet total against what the iron can serve
Ledgerthe append-only rows for a reference or a tenant, what is still open, a deterministic text export of the operational state, and attempts folded to one line each
Entitlementwhat monetize believes about a tenant, signed: read one, list them, set one, a whole signed snapshot, the public key a product verifies with, reclaim of a lapsed grant, and what a tenant currently holds
Signupthe state of one self-service signup, polled by the return page a payment vendor sends the customer back to
SignupAdminevery signup, including the refused ones — the operator's read, deliberately a second service so the return page cannot enumerate them
Eventsone bounded stream of what happened, so the console stops polling; every stream opens with an epoch and a gap is reported rather than hidden

Thanks

First to its inventors, Vetra Automation AB and Ignalina ApS (Danskjävlarna).

monetize uses excellent software from others: the neat redb (Christopher Berner).

Sponsors

None so far — we would love to have one. Contact gunnar@gunnar.rs.

Files

.cargo/
.github/
.nornir/
LICENSES/
assets/
browser/
crates/
docs/
guests/
uitest/
xtask/
.gitignore
BASE-MODEL.md
COMMERCIAL-LICENSING.md
Cargo.lock
Cargo.toml
DESIGN-PRIVATE-PAY-ONCE.md
LICENSE
NOTICE
README.md
THIRD_PARTY_LICENSES.md
TRADEMARKS.md
UI.md
deny.toml
rust-toolchain.toml

The list is cut short here. Clone the repository for every file.

Newest commits

37568343aa37Merge rel-one-d/dotted-secret-names-2026-10-29 into main (lane REL-ONE-D): the intake takes every secret name the secrets writer takes, so the per-product sold relay secret.sold_smtp_url.<product> / sold_smtp_from.<product> (OX-RELAY2, PMO 227bce1) no longer makes the ONE box refuse its whole feed. RED 9a84da14, GREEN ff0eebb5. monetize-front 37/0, clippy -D warnings clean; monetize-admin 128/0; the prod feed of PMO cf221ae2 (dummy secret values) is judged OK by the box's own judge and server --check2026-10-29
ff0eebb50893GREEN monetize-front: the intake takes every secret name the secrets writer takes (secrets::valid_name), so the per-product sold relay secret.sold_smtp_url.<product> / sold_smtp_from.<product> (OX-RELAY2) that monetize-server reads is no longer refused as "not a plain word" (lane REL-ONE-D)2026-10-29
9a84da149fecRED monetize-front: a per-product secret (secret.sold_smtp_url.<product>, OX-RELAY2) is taken under its dotted name; the intake refuses it as "not a plain word", so the ONE box refuses its whole feed (lane REL-ONE-D)2026-10-29
fe7517977596Merge ox-postpub/follow-2026-10-29 into main (lane OX-POSTPUB): monetize-proto 0.3.3 (merge of ox-ci/monetize-proto-0.3.3-2026-10-28, now on crates.io), monetize-admin on estate-front 0.3.0, xtask on nornir-xtask-support 0.1.6. Gate on the merged tree (ci4, dwarf): lockcheck green, xtask tests green (the_published_line incl.), monetize-admin 128/0, cargo xtask ci --only test GREEN (guests + workspace 2792/0)2026-10-29
232dcb825f42xtask lock: nornir-xtask-support 0.1.6 from crates.io (lane OX-POSTPUB)2026-10-29