4. The buy flow and the money
Every statement here is read from monetize main c9b473a5; the file:line after a statement is where it stands. Chapter 6 holds the data model the flows write.
4.1 Who takes part
| who | what it is | where |
| the buyer | a person in a browser on a product site (https://www.gunnar.rs, https://www.holger.rs, or a sold estate's own front) |
|
| the product site | serves the buy pages and relays every buy call to the ONE box; it holds no money logic | crates/monetize-site-buy |
| the ONE box | the one monetize: quotes, reserves, captures, lays the machines, pairs the estate, keeps the ledger | crates/monetize-server |
| the vendor | Stripe, as a signed plugin (stripe.wasm), or the open invoice / sandbox vendors |
crates/monetize-pay |
| the cloud | UpCloud, as a signed plugin (upcloud.wasm), or the open server / sandbox clouds |
crates/monetize-cloud |
The site is a thin relay by construction: monetize-site-buy is one crate both Gunnar and Holger compile in, and the only product-specific part is the BuySite trait (chapter 3, §3.6): the site's origin, who is signed in, how it mints an actor ticket for the buyer. Every door of the buy flow maps to exactly one gRPC call on the ONE box (crates/monetize-site-buy/src/relay.rs:8-34):
| site door | ONE box call |
GET /auth/signup/ladder |
Signup.Plans (served from the site's cache, refreshed every 300 s; ladders.rs:31) |
GET /auth/signup/name |
Signup.CheckName |
POST /auth/signup/start |
Signup.Start |
GET /auth/signup/status |
Signup.Status, then the site's own signup_account |
GET /auth/private/ladder |
EstateOrder.Ladder (+ EstateOrder.Sells) |
POST /auth/private/start |
EstateOrder.Start |
POST /auth/private/change |
EstateOrder.ChangePlan |
GET /auth/private/status |
EstateOrder.Status |
| renew / stop renewing / terminate | EstateOrder.Renew / EstateOrder.Cancel / EstateOrder.Terminate |
GET /paid/estate/{company} |
no call: a 303 back to the order's watch page on the site |
/api/account/subscription/* |
Purchase.Quote, Purchase.Bundles, Purchase.Start, Purchase.Status, Purchase.History, Purchase.Cancel |
A grow or renew door is first gated by Products.List: the product must declare tenant_renewal, otherwise the site answers 501 (relay.rs:1481-1512).
4.2 The three things that are bought
- An account on a product site (a hosted tenant): free, or a paid plan with a byte cap and a period. A free account costs a 0 kr card check at Stripe (
mode=setup), which is the proof of a real person. - A private estate ("a private Gunnar", "a private Holger"): the buyer's own machines at the cloud, laid by the ONE box from the product's media, paired to the ONE box, paid six months ahead.
- More room for either: a bigger account plan (
Purchase.*), or a bigger step of the estate ladder (EstateOrder.ChangePlan).
4.3 The three rules of the buy pages
These are the owner's rules; the code holds them with tests (crates/monetize-site-buy/tests/sites/mod.rs:138-200, tests/one_flow_two_sites.rs:148,456,561, crates/monetize-server/src/estate_order/tests/pay_once.rs:403-428).
| rule | what it means | where it is held |
| Stripe once | one vendor page per order. A visitor who buys a private estate goes to Stripe once, for the payment; the 0 kr card check exists only for the free account. Coming back with company= draws no step, no field and no button, so no second payment is offered, also on reload |
assets/buy.js:845-855; design: a visitor used to go twice |
| e-mail once | the e-mail is typed once, at account creation. A buy by a signed-in account carries no e-mail field; the ONE box mails the address the account was made with, whatever a page sends, and refuses an account it holds no address for (NO_EMAIL_HERE) before any money |
relay.rs:272, 887-888; services/signup.rs:633-659 |
| "Pay <amount>" | the button says what is paid, as the ONE box words it (pay_words, for example "Pay 360.00 EUR"); grow buttons say "Upgrade" |
assets/buy.js:86,155-167; services/estate_order.rs:1554 |
And one more that follows from the first: every way back from the vendor is on the buying site (urls.rs:1-3): the success page is {origin}/welcome?session=…, the cancel page is the choice the buyer came from, and a paid estate order returns to /paid/estate/<company>, which sends the buyer to the order's watch page /signup?plan=private&company=<company> (urls.rs:69-75). A url the ONE box answers that is not https is refused by the site (502, relay.rs:906-908).
4.4 Buying an account
- The page reads the ladder (
Signup.Plans) and checks the name (Signup.CheckName). POST /auth/signup/start: a member of an existing account is refused (403,relay.rs:274-280); thenSignup.Startwithsuccess_url = {origin}/welcome?session={session}and a cancel page back on the site (urls.rs:32-51). The mint of the vendor page has 20 s (link.rs:66-71); past that the buyer reads "Nothing has been charged" (504).- The vendor page: a 0 kr card check for the free account, a payment for a plan row.
/welcomepollsSignup.Status(pending, created, refused; every 2 s, every 10 s after a minute, giving up after 10 min;purchase.rs:170-175).- Created and without a passkey: the page shows
invite_url = /login?signup=<session>&user=<user>(urls.rs:97-103); the buyer enrols the passkey and is signed in.
The account record is made once per session (make_paid_order_account, "a session that already has its record makes nothing", services/signup.rs:692-707), so the sign-in mail goes once.
4.5 Buying a private estate ("pay once")
A visitor types an account name and an e-mail on the private choice; a signed-in buyer sends the account's estate ticket and no e-mail (relay.rs:843-915). Then:
POST /auth/private/start ──▶ EstateOrder.Start { new_account, welcome_url, cancel_url }
│ refusals BEFORE any money: a taken or held name, an e-mail that already has
│ an account here ("sign in to it instead"), a welcome page off the site, a
│ product with no account plan, no sink (services/signup.rs:522-627)
▼
the name is HELD for max(35 min, page life + 5 min) (signup.rs:87; services/signup.rs:661-683)
▼
vendor.reserve ──▶ the one Stripe page (the button: "Pay <total>")
▼
ORDERED ─ the vendor says authorised ─▶ capture now ─▶ paid() ─▶ HELD
│ (estate_order.rs:5767-5781, 7726-7748)
│ paid(): the account is made (once, by session), the estate's secrets are
│ minted and sealed, the welcome mail goes
▼
HELD ─▶ waiting_dns ─▶ laying ─▶ installing ─▶ paired ─▶ CAPTURED (the term starts)
(phase words: estate_order.rs:151-176; the buyer reads "Payment received",
"Being prepared", "Your machines are being set up", "<Product> is being
installed", "Ready": assets/buy.js:755)
Rules of the run (crates/monetize-server/src/estate_order.rs):
run.jsonis written atomically after every transition and before the next step; the cloud journal records an intent before each create (:28-39). InStartthe vendorreserveis called before the run is first written; a refusal after that closes the page or raisesALERT estate-orders.page(:4764-4787).- Capture is at ORDERED → HELD, as soon as the vendor says the payment is authorised (rule A23,
:5769-5781). Atpairedonly the term starts:paid_until = add_term(now, …)(:6777-6789). (The module's own header,:12-25, still describes capture atpaired; the code is as said here.) - An order nobody paid within
order_ttl_secs= 3600 is closed: the page is closed or the hold is released (:5782-5800). - A paid lay that fails is laid again once (
PAID_RETRIES= 1,:7723); a second failure parks the order OWED withALERT estate-orders.owed(:6895-6917). An unpaid hold that fails gives everything back, proves no orphan is left at the cloud, and releases the reservation (:6919-6933); a vendor that reports the money captured parks the order as "must be refunded, not released". - The lay needs a free slot (
max_open= 2 lays at once) and room under the spend cap (§4.8); otherwise the paid order queues: the buyer reads "is paid and waits for room … (since )" with the day it is set up or refunded by (:1199-1206). Queue timing: looked at every 60 s; the operator is told after 6 h and every 6 h; after 3 days the order goes OWED (:6276-6289,ALERT estate-orders.queued-deadline). - A name taken anyway after payment parks the paid order with
ALERT estate-orders.account-not-made; nothing is laid for another's account (services/signup.rs:698-740).
The operator's own test buy (chapter 1, §1.6) runs this same path at the held price of EUR 0.50 (OPERATOR_TEST_MINOR = 50, estate_order.rs:1113-1144) and is booked Instance.operator_test, never revenue.
4.6 The price of a private estate, in full
Constants (EstateOrderConfig::default(), crates/monetize-server/src/estate_order.rs:549-609; the fee from PriceRule::default(), crates/monetize/src/pricing.rs:87-99):
| name | value | meaning |
currency |
EUR |
the provider's own; no FX, a mismatch is refused (estate_order/price.rs:18-24,188-202) |
margin_bp |
2000 | 20 % on the provider's price |
min_margin_bp |
1000 | the floor: at least 10 % must be left after the vendor's fee |
term_months / term_days |
6 / 0 | the prepaid term |
fee_fixed_minor / fee_bp |
25 / 150 | the vendor's fee model: 0.25 + 1.5 % |
spend_cap_monthly_minor |
200 000 | EUR 2000 a month per estate, fixed on the run at its order |
The formulas (estate_order/price.rs:154-166, 207-283, 416-524; money in minor units, integer arithmetic, every division rounded up):
cost = the provider's price for the whole lay plan, minor per month (Cloud::price)
at_margin = ceil(cost × (10000 + margin_bp) / 10000)
term_charge(m) = ceil(m × n / d) (n, d) = (term_months, 1) or (term_days, 30)
fee(c) = fee_fixed + ceil(c × fee_bp / 10000)
floor holds ⇔ (charge − fee(charge)) × d × 10000 ≥ cost × n × (10000 + min_margin_bp)
monthly = the smallest price ≥ at_margin whose term charge holds the floor
(found by bisection, floor_price_minor)
total = term_charge(monthly)
The buyer reads price_words = "provider {cost} + {margin} % = {monthly} a month; {term} prepaid: {total}" (price.rs:129-139) and the button "Pay {total}". Money is worded "{minor/100}.{minor%100:02} {CODE}" (crates/monetize-pay/src/lib.rs:107-114). At the lay the price is read again (recheck, price.rs:530-549): if the floor no longer holds, nothing is bought and the hold is released.
Worked example. Provider cost 50.00 EUR a month (5000 minor), defaults above:
at_margin = ceil(5000 × 12000 / 10000) = 6000 → 60.00 EUR a month
6-month = 6000 × 6 = 36000
fee = 25 + ceil(36000 × 150 / 10000) = 25 + 540 = 565; net = 35435
floor : 35435 × 1 × 10000 = 354 350 000 ≥ 5000 × 6 × 11000 = 330 000 000 holds
monthly = 6000, total = 36000 → "Pay 360.00 EUR"
net margin after the fee = (35435 − 30000) / 30000 = 18.1 %
A site may override the term and the margin: [estates.<label>.orders] carries term_months, term_days, grace_hours, margin_bp (crates/monetize-server/src/config.rs:879-903); the test site uses one day, an hour of grace and 25 %.
VAT is never in these prices. The page appends "+ VAT (your country's rate, added at checkout)" (crates/monetize-site-buy/src/purchase.rs:575-592); Stripe Tax adds it exclusively (crates/monetize-pay/src/lib.rs:67), and the rate charged is recorded on the sale (Sale.vendor_tax_basis_points). The tax helper rounds half up: tax_of(net, bp) = (net × bp + 5000) / 10000 (crates/monetize/src/tax.rs:254-266), so 13.60 EUR at 25 % is 3.40.
4.7 The price of account room, in full
PriceRule::default() (crates/monetize/src/pricing.rs:87-99): markup_bp 14000 (× 1.40), yearly_bp 8000 (× 0.80), fee_fixed_minor 25, fee_bp 150, floor_bp 11000 (cost + 10 % after the fee), min_accounts 20, month_max_bytes 10 GiB, prolong_ahead_months 3. Internal figures are milli-minor units, rounded up once at the end (:27-34). MONTH_MS = 365 days / 12 (:51-53).
N = max(paying accounts last month, 20)
cost_milli = ceil(base_minor × 1000 / N) + ceil(size_bytes × storage_gib_month_milli / GiB)
MONTHLY = ceil_minor( (cost_milli × markup_bp / 10000 + fee_fixed × 1000) × 10000 / (10000 − fee_bp) )
YEARLY/month = ceil(MONTHLY × yearly_bp / 10000); the year = 12 × that
floor holds ⇔ net_after_fee × 1000 ≥ cost_milli × floor_bp / 10000 × covered_ms / MONTH_MS
upgrade credit = locked month price × remaining_ms / MONTH_MS, rounded down
other currency = minor × rate_bp / 10000 rounded up, after the floor; no rate: not offered
Worked example (the one the test pins, pricing.rs:350-385): base 135.80 EUR (13580), storage 25 990 milli per GiB-month, N = 20, 10 GiB:
base share = 13 580 000 / 20 = 679 000 milli; storage = 10 × 25 990 = 259 900 milli
cost = 938 900 milli; × 1.40 = 1 314 460
grossed up = (1 314 460 + 25 000) × 10000 / 9850 = 1 359 858 milli → 13.60 EUR a month
yearly = ceil(1360 × 0.8) = 10.88 EUR a month, 130.56 EUR for the year
Account plans (crates/monetize/src/plan.rs:1-58): a 1-month plan is only 10 GiB; 12-month plans are 10, 100 and 500 GiB; a plan is prolonged at most 3 months ahead; renewal opens 31 days before the end (RENEW_WITHIN_MS, :43). The verbs are Start, Prolong, Upgrade, Bump, Renew.
4.8 Budgets, caps and ceilings
| rule | where |
Every cloud has a SpendCap: before every ensure, spent_this_month + price > ceiling is CloudError::OverCap; destroy is never capped |
crates/monetize-cloud/src/cap/mod.rs:1-42 |
[clouds.<name>] cap = { monthly_minor and/or total_minor, currency } is required for an enabled cloud; 0 refuses every purchase; the total is recomputed from the ledger at start |
crates/monetize-server/src/config.rs:1225-1270; cap/mod.rs:93-117 |
| There is no per-day budget: only per month and lifetime | |
The open server cloud declares ceilings = { cpu_millicores, ram_bytes }; a quote whose caps pass them is refused PastCeiling |
config.rs:170-175; crates/monetize/src/transaction.rs:1690-1715 |
A private estate is capped at spend_cap_monthly_minor (EUR 2000) from its order; a quote, lay, renewal or change over it is refused; the effective cap is max(run cap, current config) |
estate_order.rs:445-449, 6327-6336 |
An order the cap or max_estates (10) holds waits in the queue; the operator reads "orders wait: raise the cap" once, again when the words change, and every hour |
estate_order.rs:1149-1152, 6340-6366 |
Sign-ups are oversold at most to max_sold_percent = 300 % of the room, with a 30 min hold |
crates/monetize-server/src/oversell.rs:91,157 |
Room sold past the room (owner rule: the customer never sees a cap). When the product's main is not sealed and can_absorb refuses, the sale is still made and the estate gets one signed grow_room order (Pulled::queue_room, crates/monetize-product/src/pulled.rs:373-389); the operator reads "sold past the room … queued; the estate is told to grow its set (grow_room)" or ALERT grow.not_told (crates/monetize-server/src/grow.rs:573-611). Chapter 5 §5.6 has the grow path.
4.9 The term, the reminders, the grace and the deletion
A private estate (crates/monetize-server/src/estate_order/term.rs, TermPolicy::default() at :75-86):
| name | value |
remind_days |
30, 14, 3 days before paid_until (never earlier than half the term, :155-176) |
grace_days / grace_hours |
14 / 0 |
delete_after_days |
60 after paid_until |
warn_days |
0, 7, 14, 45, 59 after paid_until |
renew_url |
https://{site}/private?renew={company} |
check() |
the deletion is after the grace and at least 30 days out; no warning on or after it (:93-117) |
The timeline from paid_until = T:
T − 30 d estate.renewal_reminder (the latest due reminder is sent; earlier ones are skipped, never sent late)
T − 14 d estate.renewal_reminder
T − 3 d estate.renewal_reminder
T estate.lapsed the first warning; it states the deletion date
T + 7 d estate.unpaid
T + 14 d estate.suspended every machine stopped, disks kept (:820-840)
T + 45 d estate.unpaid
T + 59 d estate.unpaid
T + 60 d estate.deleted DELETING → DELETED; refused while paid_until is in the future (:870-897)
The deletion date is fixed once, by the first mail: max(T + 60 d, now + (60 − min(warn_days)) d) (:794-801). A buyer who pressed "Stop renewing" gets estate.stopped instead of the suspension mail (:769-790); an estate lapsed by a payment reversal is never deleted by itself (:848-851). Each mail is recorded once in EstateRun.reminded as <paid_until>:<kind>:<day>. A renewal is six more months at today's provider price plus the margin, running from the end of the paid period (from the payment if the estate is stopped); it opens at the largest reminder.
A hosted account (crates/monetize-server/src/nonpay.rs): the policy is [nonpay] delete_after_days and warn_days, per product under [nonpay.products.<p>]. The server's own default is 30 days, warned on day 0 and day 29 (NonpayPolicy::shipped, :172-180; 0 is refused). The kit feeds 10 days for every product it sells ([products.<p>] purge_after_days = 10 in solo.toml becomes delete_after_days = 10, warned on days 0, 5 and 9; private-monetize-ops/solo.toml:492,630, xtask/src/solo.rs:3206), so on a fed box:
paid_until Suspended: read-only at once; mail nonpay.day0 "read-only now, deleted on <date>"
+ 5 d, + 9 d mails
+ 10 d purge_tenant: the iron goes back, the caps are zeroed, signed and pushed
(source "nonpay:deleted"), TenantDeleted on the ledger, ALERT nonpay.deleted
paying before then cancels the deletion
Other constants: a purge stuck for 24 h is said; a failed purge is retried every hour; a declined payment counts as fresh for 3 days (:81-88). The account ladder's own Policy is a month period, 14 days of grace and 90 days of retention (crates/monetize/src/order.rs:99-117); its state is derived, never stored: Free, else Paid until paid_until, then Grace, then Suspended, then Retention (:131-146).
4.10 Terminate
A buyer terminates from the manage page: a fresh login and the estate's name typed back, then EstateOrder.Terminate → TERMINATING → TERMINATED. The give-back walks the machines at the cloud with pauses from 10 s up to 300 s (estate_order/terminate.rs:475-477), removes the DNS records its lay wrote (ALERT estate-orders.dns-not-found if they are not where they were written), and forgets the estate's sealed secrets (ALERT estate-orders.custody if that fails).
4.11 The transaction for room (accounts and bundles)
crates/monetize/src/transaction.rs:1-50; the step grammar is in crates/monetize/src/ledger.rs:79-88.
quote → order → run:
ledger.attempt(reference) open? resume
else pay.reserve → Reserved → resume
resume:
per resource: cloud.ensure → Ensured | Refused → abort | Unavailable → Stalled (the row stays open)
pay.capture → Captured | Declined → abort
sign the fact; push the entitlement → EntitlementPushed
abort:
cloud.destroy → Destroyed (each bought resource); pay.release → Released
reconcile (later, from the open rows):
pay.status: Captured → Captured + resume; Released / Declined / unknown → abort; Reserved → resume
Every step is on the ledger before the next external call; anything retryable stalls and is never turned into a release (:46-50). The reference of an attempt is <product>/<tenant>/<YYYY-MM-DD>+<caps> (+users:<fp>, +plan:<period>; :165-180, 227-239). Refunds: RefundRequested is written before the vendor call with the idempotency key <handle>:refund:<amount>; Reversed records a refund or dispute the vendor reports later; RoomTakenBack closes the room. Refusals before any money: NotAnIncrease, Downgrade, CannotAbsorb, CannotServe, PastCeiling, NoVendor, WrongMarket, NothingToRenew (:406-470). At price 0 no vendor is called at all; the handle is {vendor: "none"} (:51-72).