Gunnar

nordisk/monetize

README.md

<img src="assets/monetize.webp" alt="monetize" width="180" align="right" />

# 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.

* Plugin seams for payment vendors and cloud providers. The paid implementations live in a separate private repository; this tree carries the traits, the engine, the server, the console, and the implementations nobody needs an account for.
* Signed entitlement facts a product verifies offline: one atomic load and a map lookup, no network call on the request path.
* Operator console (Leptos/WebAssembly), served by its own read-only backend.
* An append-only ledger written before every external call; every derived number is a fold of it.
* The same code runs https://monetize.vetra.se

## 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](docs/INTEGRATION-v2.md)
([the picture](docs/integration-v2.svg), [the data model](docs/datamodel-v2.svg)) · [today's STE manual](docs/integration-guide.md)
· 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:

| process | started as | listens on |
|---|---|---|
| monetize-server | `monetize-admin server --config /data/monetize/monetize.toml` | `127.0.0.1:50061` |
| the console | `monetize-admin ui --bind 127.0.0.1:8091 --server 127.0.0.1:50061 --root /data` | `127.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>`:

```toml
[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.

```toml
[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.

| service | what it answers |
|---|---|
| `Products` | which 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 |
| `Purchase` | a quote for an order, the start of the reserve → provision → capture transaction, the state of an attempt, and the abandonment of one |
| `Usage` | one 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 |
| `Ledger` | the 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 |
| `Entitlement` | what 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 |
| `Signup` | the state of one self-service signup, polled by the return page a payment vendor sends the customer back to |
| `SignupAdmin` | every signup, including the refused ones — the operator's read, deliberately a second service so the return page cannot enumerate them |
| `Events` | one 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.