Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

docs/v2/04-buy-flow-and-money.md

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

  1. 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.
  2. 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.
  3. 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

  1. The page reads the ladder (Signup.Plans) and checks the name (Signup.CheckName).
  2. POST /auth/signup/start: a member of an existing account is refused (403, relay.rs:274-280); then Signup.Start with success_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).
  3. The vendor page: a 0 kr card check for the free account, a payment for a plan row.
  4. /welcome polls Signup.Status (pending, created, refused; every 2 s, every 10 s after a minute, giving up after 10 min; purchase.rs:170-175).
  5. 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):

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