crates/monetize-server/src/signup.rs
//! **The engine half of the paid-signup seam**: the store that makes a webhook
//! idempotent, the checks that turn a stranger's assertions into a named
//! refusal, and the open default sink.
//!
//! ```text
//! POST /callback/<vendor> the vendor's wire (callback.rs)
//! │ parse_callback VERIFIED it and said Signup
//! ▼
//! record_pending (vendor, session) ─── already there? ──▶ answer what it says, call nothing
//! │ fresh, or a PENDING one being retried
//! ▼
//! check username · plan · amount ──▶ Refused { reason }
//! │ all three agree with the config
//! ▼
//! SignupSink::paid ──▶ Created { namespace, username }
//! │ ──▶ Unavailable: stays PENDING, tried again
//! ▼
//! store.set one functional_status line per state change
//! ```
//!
//! # Why the store and not the ledger
//!
//! The ledger's key is a REFERENCE — `<product>/<namespace>/<period>` — and it
//! is keyed that way because every row in it is about a namespace that exists.
//! A signup has no namespace until the sink has run, which is the event being
//! recorded. Writing one as a ledger attempt would mean inventing the tenant id
//! before the thing that mints it has answered, and then living with whatever
//! the sink actually called them.
//!
//! So: a second redb file, `signups.redb`, keyed the only way the two sides of
//! the redirect agree on — `(vendor, session)`. That key is the vendor's, not
//! ours, which is exactly what makes the return page able to ask before we have
//! heard anything at all.
//!
//! # The three answers a webhook can produce, and the one status code
//!
//! **Every verified signup is answered 200**, refusals included. A 5xx makes
//! Stripe retry for days and then disable the endpoint (measured 2026-09-07,
//! `4cafd1d`), and a refusal is not a delivery failure: the delivery worked
//! perfectly and the answer is "no". What became of it is read over
//! `Signup.Status`, by the party that cares — the customer's return page and
//! the operator's console.
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex};
use monetize_pay::signup::{NameVerdict, PaidSignup, SignupError, SignupOutcome, SignupSink};
use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition};
use crate::config::SignupPlan;
use crate::store::StoreError;
/// `(vendor, session) → SignupRecord` as JSON. One table: the store answers two
/// questions ("what became of this session" and "show me all of them") and both
/// are this table read two ways.
const SIGNUPS: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("signups");
/// `(vendor, session) → SignupRefund` as JSON: **what an operator did about a
/// row's money** (`SignupAdmin.Refund` / `Settle`). A second table and not a
/// field on the record, because the record is what the VENDOR's callback said
/// and the checks made of it, and a refund is a later act by somebody else —
/// the two are written by different verbs, and a restore of one must not
/// rewrite the other. Same file, same key as [`SIGNUPS`].
const REFUNDS: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("refunds");
/// **The checkout sessions THIS box minted** (STINKY ST-7): `(vendor, session)` → the estate
/// the signup began on (empty: none named). A verified webhook — or a session the reconciler
/// finds paid at the vendor — for a session not in here is another system's on the same
/// merchant account, and is never settled here.
const MINTED: TableDefinition<(&str, &str), &str> = TableDefinition::new("minted");
/// ★ **The card checks this box minted for a free account** (owner 2026-10-15), keyed
/// `(vendor instance, session)`: what the account will be once the card is checked
/// ([`CardCheckStarted`], JSON). Not a payment and never one: nothing is charged, so a
/// card check is never a [`SignupRecord`] until it has made the account.
const CARD_CHECKS: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("card_checks");
/// ★ **The welcome page a paid signup this box minted returns to** (A11 fix 7), keyed
/// `(vendor, session)` → the page with `{session}` in it: the sign-in link mailed once the
/// account is made.
const WELCOMES: TableDefinition<(&str, &str), &str> = TableDefinition::new("welcomes");
/// ★ **The names held while their vendor page runs** (owner 2026-10-15, B), keyed
/// `("<product>\u{0}<site>", username)` → [`NameHold`] (JSON): a name being paid for (or
/// card-checked) is nobody else's until the page is done, closed, or [`NAME_HOLD_MS`] is up.
const NAME_HOLDS: TableDefinition<(&str, &str), &[u8]> = TableDefinition::new("name_holds");
/// ★ How long a name is held for a vendor page: the page's own life (the Stripe guest
/// sets `expires_at` = 30 min on every signup and card-check page) and a margin.
pub const NAME_HOLD_MS: i64 = 35 * 60 * 1000;
/// ★ A name held for one vendor page ([`NAME_HOLDS`]).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct NameHold {
pub vendor: String,
pub session: String,
/// The address the page was opened for: the same buyer starting again is not shut out by
/// their own page.
pub email: String,
pub until_unix_ms: i64,
}
/// **How long a PENDING signup may stay pending before it becomes a refusal.**
///
/// 24 hours. A sink that has answered `Unavailable` for a day is not having a
/// blip; something is broken that nobody is watching, and a row that says
/// PENDING forever is a customer whose card was charged sitting on a spinner
/// with no words. Turning it into a REFUSED with a named reason at least puts
/// it in front of an operator and gives the return page something to say.
pub const PENDING_CEILING_MS: i64 = 24 * 60 * 60 * 1000;
/// The reason a signup that timed out carries. A constant because the console
/// and the test both quote it, and two spellings of one refusal is two bugs.
/// OPERATOR words: the customer reads [`words::COULD_NOT_CREATE`] instead.
pub const NEVER_ANSWERED: &str = "sink never answered";
/// **What a paying customer is told**, one constant per situation.
///
/// Kept apart from every `reason` in this file because the two have different
/// readers. A reason is for the operator and says what the product said —
/// which, for gunnar, can be `Unauthenticated: the credential is 412000 ms away
/// from this server's clock`. A customer who has just paid must never read
/// that, and must always read whether they need to pay again (they do not).
/// The operator's copy goes to the log, to `functional_status` and to
/// `SignupAdmin.List`; these go to `Signup.Status` and nowhere else.
pub mod words {
/// ★ PENDING, settling (PAID-WORDS, owner's real-card test 2026-10-26): the first seconds,
/// an account queued for the product's main, or any "not now" that is not the product
/// saying it is in maintenance (a timeout, a box that does not answer, a clock that will
/// agree again). Within [`super::SETTLE_BOUND_MS`].
pub const BEING_CREATED: &str = "Payment received. Setting up your account; this takes a few seconds.";
/// ★ PENDING past [`super::SETTLE_BOUND_MS`], where the account's sign-in link is mailed
/// once it is made (a welcome page was minted for the payment).
pub const TAKING_LONGER_MAILED: &str =
"Payment received. This is taking longer than usual; your payment is safe, and we will e-mail you the sign-in link as soon as your account is ready — you do not need to pay again.";
/// ★ PENDING past [`super::SETTLE_BOUND_MS`], with no mail promised (none is sent here).
pub const TAKING_LONGER: &str =
"Payment received. This is taking longer than usual; your payment is safe, and this page finishes on its own when your account is ready — you do not need to pay again.";
/// ★ A rename after paying that the product could not answer just now.
pub const NOT_ANSWERING_PAID: &str = "Payment received. That name could not be checked just now; try again in a moment — you do not need to pay again.";
/// PENDING because the PRODUCT said, by name, that it is in maintenance
/// ([`monetize_pay::signup::SignupError::Maintenance`]). Never for a generic "not now".
pub const MAINTENANCE: &str =
"Payment received. The shop is temporarily down for maintenance; your account will be ready as soon as it is back — you do not need to pay again.";
/// CREATED.
pub const READY: &str = "Payment received. Your account is ready.";
/// REFUSED by the product, or pending past the ceiling: the words that
/// replace a reason nobody outside the operation should read.
pub const COULD_NOT_CREATE: &str =
"Payment received, but your account could not be created automatically. Please contact us and we will set it right — you do not need to pay again.";
/// A name check or a checkout that could not be made because the product
/// or the vendor is not answering. No money has moved.
/// Not "maintenance" (PAID-WORDS): a generic "not now" is never called that.
pub const SHOP_DOWN: &str = "The shop is not answering right now. Nothing has been charged; please try again in a little while.";
/// A signup for an address that already has an account here (PLAN §3c,
/// OM-19 (4)): refused, offering sign-in — never naming the account, and
/// never merged into it. No money has moved.
pub const EMAIL_HAS_ACCOUNT: &str = "This email address already has an account here. Sign in to it instead — nothing has been charged.";
/// ★ **One free account per card per site** (owner 2026-10-15): the card checked at the
/// vendor already has a free account on this site. Never names the account.
pub const CARD_HAS_ACCOUNT: &str = "This card already has a free account here — sign in with it, or choose a plan. Nothing has been charged.";
/// ★ The card-check page closed with no card checked.
pub const CARD_NOT_CHECKED: &str = "The card check was not finished, so no account was made. Nothing has been charged — start again to make your free account.";
/// ★ Back from the vendor, the card not checked yet (3-D Secure still at the bank).
pub const CARD_BEING_CHECKED: &str = "Your card is being checked. Nothing is charged.";
/// ★ A site whose vendor cannot check a card makes no free account.
pub const CARD_CHECK_NOT_HERE: &str = "A free account cannot be made here right now. Nothing has been charged.";
/// ★ The name is held for somebody else's open payment page (owner 2026-10-15, B).
pub const NAME_BEING_BOUGHT: &str = "Someone is signing up with this account name right now. Choose another name, or try again in half an hour.";
/// ★ Back from the vendor, the payment not there yet.
pub const PAYMENT_WAITING: &str = "Waiting for your payment to be confirmed. Nothing more is needed from you.";
/// ★ The payment page closed unpaid: no account, nothing charged.
pub const PAYMENT_NOT_FINISHED: &str = "The payment was not finished, so no account was made. Nothing has been charged — start again to sign up.";
/// ★ **THE SECOND MAINTENANCE LIE, and the reason it outlived the first.**
///
/// This constant used to read "…right now while we carry out maintenance",
/// and its own doc justified that by pointing at
/// `monetize::transaction::words` — which said the same thing about the
/// same condition. That sibling stopped saying it on 2026-09-20: the
/// capacity refusals there are now COMPOSED from what was measured, and the
/// word "maintenance" is held out of them by a test. So the justification
/// this constant rested on no longer exists, and the sentence outlived the
/// argument for it by one repository.
///
/// It is the worse of the two, because a buyer told "maintenance" waits.
/// Maintenance ends by itself; a shop with no room left does not. A
/// customer who is never told that somebody has to act will wait for a
/// state that will never arrive, and will not ask — which is precisely the
/// message that would fix it.
///
/// # Why this one is not composed from numbers
///
/// Its sibling can name what is left because a raise measures it. Signup
/// reaches here from two doors — `can_absorb` answering no, and the
/// oversell ceiling — and one of them is a POLICY, not a measurement, so a
/// figure would be true of one caller and invented for the other. It says
/// what is true of both instead, and says who acts next.
///
/// [`NEVER_SHOWN`] still holds: no meter, no device, no word from the
/// machine room.
///
/// **Round 5.3** (owner 2026-10-08: "the customer must not know about a
/// cap!"): on an estate whose set grows, a signup past the room is SOLD
/// and QUEUED for the resize and never reaches this sentence; it is left
/// for a box that is never resized, and names no room left and no cap.
pub const NO_ROOM: &str =
"We cannot create an account of this size here right now. Nothing has been charged. Please ask us and we \
will set it right, or choose a smaller size.";
/// Every infrastructure word a customer sentence must not contain. The
/// tests hold every constant above, and every sentence `Signup.Status`
/// builds, to it.
pub const NEVER_SHOWN: &[&str] =
&["clock", "skew", "credential", "unauthenticated", "ms away", "grpc", "sink", "disk", "filesystem", "data set", "capacity"];
}
/// Where a signup got to. The wire's `SignupStatusResponse.State`, minus
/// `UNKNOWN` — which is not a state a record can be in, it is the absence of a
/// record, and giving it a variant here would make "we never heard of this" a
/// thing that could be STORED.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum SignupState {
/// Recorded, not yet decided: the sink has not answered, or answered
/// [`SignupError::Unavailable`] and will be asked again.
Pending,
/// The customer exists.
Created { namespace: String, username: String, at_unix_ms: i64 },
/// The customer will not be created, and this is why, in words a human is
/// meant to read.
Refused {
/// The OPERATOR's reason: what the checks or the product said.
reason: String,
at_unix_ms: i64,
/// **What the customer reads instead of `reason`**, when `reason` came
/// from somewhere whose words are not for a customer — the product's
/// refusal, or the pending ceiling. `None` means `reason` was written
/// for them: a name refusal, or an order refusal the checks above made.
///
/// `default` and skipped when empty, so rows written before the field
/// read back and a backup of them is byte-identical to before.
#[serde(default, skip_serializing_if = "Option::is_none")]
words: Option<String>,
/// **Whether another name would fix it**, as a value and not as a
/// substring of `reason`. See [`Refusal`].
///
/// `#[serde(default)]` because this table already holds rows written
/// before the field existed, and a store that would not parse its own
/// history would lose the record of a payment. Those rows read as
/// [`Refusal::Unknown`] and are not renameable — guessing that an old
/// refusal was about the name would re-run a sink for a customer whose
/// money may have gone somewhere else entirely.
#[serde(default)]
refusal: Refusal,
/// **What a human must now do about the money.** See [`Owed`]. Written
/// at the moment of the refusal, because only the code that made it
/// knows whether the sink ever ran; `#[serde(default)]` reads every row
/// written before 2026-09-18 as [`Owed::Unrecorded`], which is the safe
/// wrong answer — it sends somebody to look.
#[serde(default)]
owed: Owed,
},
}
/// **Money taken, account not created: what a human must do about it.**
///
/// Every row in the signup store is a payment that has already landed — the
/// webhook is delivered by the vendor *after* the card cleared — so a REFUSED
/// row is, without exception, a customer who has been charged and has no
/// account. Until 2026-09-18 that fact lived nowhere but in the prose of
/// `reason`: [`tax_sales`] declared only the CREATED rows and its comment said
/// the rest was "owed back by hand", and nothing anywhere counted the hands.
///
/// The arms are not four spellings of "refund". They differ in what is left on
/// the appliance, and an operator's next step differs with them:
///
/// * the checks refuse BEFORE the sink runs (`crate::signup::refusal`), so
/// nothing was created and the money is the whole of it;
/// * a sink that refuses has already created the principal, the namespace and
/// the username at its step (b) — `monetize-signup-impl` pushes the
/// entitlement at step (c) — so the NAME is spent: nothing here purges it
/// (nonpay purges only a customer who stopped paying), and a second
/// purchase of the same name is refused `NameTaken`;
/// * a name refusal is the one the CUSTOMER can still finish, with
/// `Signup.Rename`, against the money that has already moved.
///
/// This type records the fact and names the job. The money goes back through
/// [`refund`] (`SignupAdmin.Refund`, `monetize-server --refund`, 2026-09-28
/// g-refund-verb), which asks the row's own vendor and records it in
/// [`SignupRefund`]; a row refunded by hand at the vendor is closed with
/// [`settle_by_hand`]. Either takes the row off [`SignupStore::owed_back`].
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub enum Owed {
/// **A row written before this field existed** — money moved, no account,
/// and which of the arms below it is was never written down. Not folded
/// into any of them: guessing that an old row's sink never ran would tell
/// an operator that a name is free when the appliance is holding it.
#[default]
Unrecorded,
/// **Refund, and nothing else.** The checks refused before the sink was
/// called: no principal, no namespace, no name spent.
Refund,
/// **Refund, and the name may be spent.** The sink ran and did not answer
/// `Created` — it refused, or it stopped answering altogether — and it
/// creates the principal before it pushes the entitlement. Nothing here
/// knows how far it got, so the appliance has to be asked.
RefundAndTheNameMayBeSpent,
/// **The customer can still finish this one**, with another name, against
/// the money already taken (`Signup.Rename`). It is owed back only if they
/// never do — which is why it is counted with the rest rather than treated
/// as settled.
RenameOrRefund,
}
impl Owed {
/// `unrecorded` | `refund` | `refund_and_the_name_may_be_spent` |
/// `rename_or_refund` — the wire and log spelling, one writer.
pub fn name(self) -> &'static str {
match self {
Owed::Unrecorded => "unrecorded",
Owed::Refund => "refund",
Owed::RefundAndTheNameMayBeSpent => "refund_and_the_name_may_be_spent",
Owed::RenameOrRefund => "rename_or_refund",
}
}
/// **The job, in words, for the operator who reads the queue.** One writer,
/// because a console and a log line that described this differently would
/// be two different instructions for one piece of somebody's money.
pub fn remedy(self) -> &'static str {
match self {
Owed::Unrecorded => {
"Refund this payment (SignupAdmin.Refund; `monetize-server --refund <vendor>:<session>`), and ASK THE APPLIANCE whether the username exists: this row \
was written before monetize recorded how far the signup got, so whether a principal was created is unknown."
}
Owed::Refund => {
"Refund this payment (SignupAdmin.Refund; `monetize-server --refund <vendor>:<session>`). Nothing was created for it: \
the checks refused before the account was made."
}
Owed::RefundAndTheNameMayBeSpent => {
"Refund this payment (SignupAdmin.Refund; `monetize-server --refund <vendor>:<session>`), AND deal with the name: the account creation had already begun, so the \
principal, the namespace and the username may be on the appliance for good. Nothing purges them automatically, and \
a second purchase of the same name will be refused as taken."
}
Owed::RenameOrRefund => {
"Ask the customer for another username (Signup.Rename finishes the signup against the money already taken); \
refund this payment (SignupAdmin.Refund; `monetize-server --refund <vendor>:<session>`) if they will not take one."
}
}
}
}
/// **What an operator has done about a signup's money** — the [`REFUNDS`] row.
///
/// Written in two steps around the vendor call, and that order is the whole
/// point: [`SignupRefund::Requested`] is committed BEFORE the vendor is asked,
/// [`SignupRefund::Done`] after it said yes. A crash (or a lost reply) between
/// the two leaves `Requested`, and the next [`refund`] asks the vendor AGAIN
/// with the RECORDED amount — the same idempotency key
/// ([`refund_key`], Stripe's `<handle>:refund:<amount>`) — so the vendor
/// answers the first refund and gives nothing back twice.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum SignupRefund {
/// Recorded, the vendor about to be (or already) asked; its yes not heard.
/// **Still owed back**: nobody here knows the money moved.
Requested {
amount: monetize_pay::Money,
reason: String,
at_unix_ms: i64,
/// A CREATED signup refunded with `force`.
#[serde(default)]
forced: bool,
},
/// The vendor refunded it.
Done {
/// [`refund_key`]: the key the vendor holds this refund under.
refund_id: String,
amount: monetize_pay::Money,
reason: String,
requested_at_unix_ms: i64,
at_unix_ms: i64,
#[serde(default)]
forced: bool,
},
/// An operator refunded it BY HAND at the vendor and said so, with a note.
/// No money moved here.
SettledByHand { note: String, at_unix_ms: i64 },
}
impl SignupRefund {
/// **Is the money back?** `Done` or settled by hand; a `Requested` one is
/// still owed.
pub fn settles(&self) -> bool {
!matches!(self, SignupRefund::Requested { .. })
}
}
/// **An account at the shop, without room here** (one shop, owner 2026-10-07):
/// a signup plan whose `amount_minor` is 0. It sells no room and asks no
/// vendor — the person gets the account they see and grow what they bought
/// under — so `Signup.Start` makes it at once ([`ACCOUNT_VENDOR`], a session
/// of its own, [`ACCOUNT_SESSION_PREFIX`]). It is never offered as room
/// (`Signup.Plans`), is no sale, and its fact never lapses and holds no room
/// ([`NO_ROOM_CAP`]).
pub const ACCOUNT_VENDOR: &str = "account";
/// **The "vendor" of an account fact the poller GAVE** (2026-10-08): an account that held no
/// fact at a selling shop (an invite's, an operator's, a failed sign-up's) — its source reads
/// `account:given:<reference>`, so its attestation line says it was given, not signed up.
pub const GIVEN_VENDOR: &str = "given";
/// How an account-at-the-shop session id begins: 128 random bits follow, hex.
pub const ACCOUNT_SESSION_PREFIX: &str = "acct_";
/// **The cap an account without room holds**: one byte. Not zero — gunnar
/// reads a zero cap as NO cap (a typo in a plan must never close every write,
/// gunnar-server `entitlement`), and an account here is not unlimited room.
pub const NO_ROOM_CAP: u64 = 1;
/// **A session of the shop's own** for an account at the shop:
/// [`ACCOUNT_SESSION_PREFIX`] + 128 bits from the system's randomness, hex.
/// It is the credential the welcome page and the passkey registration carry,
/// as a vendor's session is for a paid signup — so never guessable.
pub fn account_session() -> Result<String, String> {
use std::io::Read as _;
let mut b = [0u8; 16];
std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut b)).map_err(|e| format!("/dev/urandom: {e}"))?;
Ok(format!("{ACCOUNT_SESSION_PREFIX}{}", b.iter().map(|x| format!("{x:02x}")).collect::<String>()))
}
/// **Whose session it is**: an account-at-the-shop session is
/// [`ACCOUNT_VENDOR`]'s, whatever vendor a return page names (a console asks
/// with the one it always names); any other is the named vendor's.
pub fn vendor_of<'a>(named: &'a str, session: &str) -> &'a str {
if session.starts_with(ACCOUNT_SESSION_PREFIX) {
ACCOUNT_VENDOR
} else {
named
}
}
/// True for an account at the shop: a plan with no price (and no room).
pub fn is_account_plan(plan: &SignupPlan) -> bool {
plan.amount_minor == 0
}
/// **The idempotency key a refund of `session` for `amount_minor` is sent
/// under** — the one monetize-impl's Stripe builds (`stripe/mod.rs`,
/// `Stripe::refund`: `<handle.id>:refund:<amount>`), and the id
/// `SignupAdmin.Refund` answers. The handle of a signup's payment is its
/// session. Same amount, same key: that is what makes a retry safe, and why a
/// retry re-sends the RECORDED amount and never a newly asked one.
pub fn refund_key(session: &str, amount_minor: u64) -> String {
format!("{session}:refund:{amount_minor}")
}
/// **What kind of refusal it was** — the value a page branches on, so that no
/// page has to match on prose.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub enum Refusal {
/// A row from before this field existed. Not renameable — see
/// [`SignupState::Refused`].
#[default]
Unknown,
/// **The username was the problem and only the username**: taken, reserved,
/// or not a name. `Signup.Rename` takes a different one against the money
/// that has already moved.
Name,
/// Final. A plan nobody sells, an amount that is not the plan's price, a
/// product that refused the entitlement. Nothing the customer can type
/// changes any of it.
Final,
}
impl Refusal {
/// **May a different username finish this signup?** Answered in one place so
/// the store, the service and the page cannot disagree about it.
pub fn renameable(self) -> bool {
self == Refusal::Name
}
}
impl SignupState {
pub fn is_pending(&self) -> bool {
matches!(self, SignupState::Pending)
}
/// **What is owed back for this record**, or `None` when nothing is: a
/// signup that is still pending may yet become an account, and a created one
/// IS the account. Only a refusal is money taken for nothing — see [`Owed`].
pub fn owed(&self) -> Option<Owed> {
match self {
SignupState::Refused { owed, .. } => Some(*owed),
SignupState::Pending | SignupState::Created { .. } => None,
}
}
/// When it stopped being pending; `0` while it still is.
pub fn decided_at_unix_ms(&self) -> i64 {
match self {
SignupState::Pending => 0,
SignupState::Created { at_unix_ms, .. } | SignupState::Refused { at_unix_ms, .. } => *at_unix_ms,
}
}
}
/// One row: what the vendor said, and what became of it.
///
/// It keeps the whole [`PaidSignup`] — **including the e-mail** — for one
/// reason only: a sink that answered [`SignupError::Unavailable`] is asked
/// again later, and it cannot be asked again without the thing it was asked
/// about. Nothing reads the address except a retry; `Signup.Status` and
/// `SignupAdmin.List` both leave it behind, because a return page is
/// unauthenticated and a console is not a mailing list.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct SignupRecord {
pub signup: PaidSignup,
pub state: SignupState,
/// When the webhook landed HERE. Not [`PaidSignup::paid_at_unix_ms`], which
/// is the vendor's clock on the money; the difference between the two is
/// how long the vendor sat on the event and is worth being able to see.
pub recorded_at_unix_ms: i64,
/// **The estate the signup began on** (ONE monetize, several sites of one product): the
/// caller's own bearer named it at `Signup.Start` ([`crate::auth::CallerEstate`]). Kept so
/// a later settle makes the account on that estate too. `None`: the product's home, or a
/// paid signup, whose account names its estate ([`crate::registry::Registry::estate_of_vendor`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estate: Option<String>,
/// ★ **The card checked for a free account** (owner 2026-10-15: one free account per
/// card per site): the vendor's FINGERPRINT of it, never the number. `None`: an account
/// made without a card check (before 2026-10-15, or a site with no vendor at all), and
/// every paid signup. Skipped when empty, so older rows and their backups read the same.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub card: Option<CardOnFile>,
}
/// ★ **The card a free account was checked with** ([`SignupRecord::card`]).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct CardOnFile {
/// The vendor instance that checked it (`stripe/gunnar-test2`): a fingerprint is only
/// comparable on the vendor account that made it.
pub vendor: String,
/// The vendor's fingerprint of the card number.
pub fingerprint: String,
/// The vendor's customer and the saved payment method (a later paid plan may reuse them).
#[serde(default)]
pub customer: String,
#[serde(default)]
pub payment_method: String,
/// The vendor's reference for the check (Stripe: the SetupIntent).
#[serde(default)]
pub reference: String,
pub checked_at_unix_ms: i64,
}
/// ★ **A card check minted for a free account, before the card is checked** (owner
/// 2026-10-15). Everything the account needs once the vendor says the card is checked —
/// kept HERE, never read back off the vendor's page.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct CardCheckStarted {
pub product: String,
pub plan: String,
pub username: String,
pub email: String,
/// The plan's currency (the account's [`PaidSignup::amount`] is zero in it).
pub currency: monetize_pay::Currency,
/// The estate the signup began on (the caller's bearer), and its site.
#[serde(default)]
pub estate: Option<String>,
#[serde(default)]
pub site: Option<String>,
/// The console's welcome page, `{session}` in it: where the sign-in link leads.
pub success_url: String,
pub started_at_unix_ms: i64,
/// **Refused, and the sentence the buyer reads** — set once, final for this session
/// (the card already has a free account here, or the page closed unchecked).
#[serde(default)]
pub refused: Option<String>,
}
/// What [`SignupStore::record_pending`] found.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Recorded {
/// Nobody had seen this `(vendor, session)`; it is PENDING now.
Fresh,
/// It was already there. **The second webhook for a session does nothing** —
/// this carries what the first one made of it, so the caller can answer 200
/// with the truth instead of running the sink again.
Already(Box<SignupRecord>),
}
/// **The signup store: idempotency and status, keyed `(vendor, session)`.**
pub struct SignupStore {
db: Database,
/// **The sessions a sink call is running for, right now, in this process.**
///
/// `record_pending` makes a webhook's claim race-free, but a PENDING row is
/// retried from three places — a vendor re-delivery, the return page's
/// `Signup.Status` poll, and the poller's background re-settle — and two of
/// them can reach the sink for the same session at once. With gunnar's sink
/// that is not harmless: the second caller can find the principal the first
/// has just made and be told the name is TAKEN, and whichever `set` lands
/// last decides whether a paying customer has an account. So one call per
/// session at a time, and the loser answers PENDING without calling
/// anything. In memory, because one process serves this store: redb holds
/// the file lock.
in_flight: Mutex<HashSet<(String, String)>>,
/// **Why each still-PENDING session is still pending**: the sink's last
/// `Unavailable` words, for the operator's console and for choosing the
/// customer's sentence. In memory and not in the row, because it changes on
/// every retry and is only true of this process's last attempt; after a
/// restart it is empty until the next retry, at most one poll tick away.
waiting: Mutex<HashMap<(String, String), NotNow>>,
/// **The vendor has confirmed this session paid**, by its own ledger:
/// `paid_at` as the reconciler ([`crate::reconcile`]) read it back. Noted
/// beside a row the store already holds — a webhook's or the reconciler's
/// own — so a console can tell "the vendor says this money is real" from
/// "a callback said so". In memory like `waiting`, and for the same
/// reason: it is re-established by the next pass, at most one tick away,
/// and it is a fact ABOUT the row, not part of the payment record.
confirmed: Mutex<HashMap<(String, String), i64>>,
}
/// One session's sink call, held by [`SignupStore::fly`]; dropping it frees the
/// session for the next caller.
pub struct Flight<'a> {
store: &'a SignupStore,
key: (String, String),
}
impl Drop for Flight<'_> {
fn drop(&mut self) {
self.store.in_flight.lock().unwrap_or_else(|p| p.into_inner()).remove(&self.key);
}
}
fn parse_signup(vendor: &str, session: &str, bytes: &[u8]) -> Result<SignupRecord, StoreError> {
serde_json::from_slice(bytes).map_err(|e| StoreError::corrupted(format!("signup row {vendor}:{session} will not parse: {e}")))
}
impl SignupStore {
/// The most rows [`SignupStore::list`] returns when the caller names no
/// limit. A console page, not an export.
pub const LIST_CEILING: usize = 500;
pub fn open(path: &Path) -> Result<Self, StoreError> {
Self::init(Database::create(path)?)
}
pub fn in_memory() -> Result<Self, StoreError> {
Self::init(Database::builder().create_with_backend(redb::backends::InMemoryBackend::new())?)
}
fn init(db: Database) -> Result<Self, StoreError> {
let txn = db.begin_write()?;
txn.open_table(SIGNUPS)?;
txn.open_table(REFUNDS)?;
txn.open_table(MINTED)?;
txn.open_table(CARD_CHECKS)?;
txn.open_table(NAME_HOLDS)?;
txn.open_table(WELCOMES)?;
txn.commit()?;
Ok(Self { db, in_flight: Mutex::new(HashSet::new()), waiting: Mutex::new(HashMap::new()), confirmed: Mutex::new(HashMap::new()) })
}
/// **This box minted `(vendor, session)`** for a signup on `estate` (ST-7).
pub fn record_minted(&self, vendor: &str, session: &str, estate: Option<&str>) -> Result<(), StoreError> {
let txn = self.db.begin_write()?;
txn.open_table(MINTED)?.insert((vendor, session), estate.unwrap_or(""))?;
txn.commit()?;
Ok(())
}
/// ★ **The welcome page of a paid signup this box minted** (A11 fix 7), `{session}` in it.
pub fn record_welcome(&self, vendor: &str, session: &str, welcome_url: &str) -> Result<(), StoreError> {
let txn = self.db.begin_write()?;
txn.open_table(WELCOMES)?.insert((vendor, session), welcome_url)?;
txn.commit()?;
Ok(())
}
/// The welcome page kept for `(vendor, session)`, if any.
pub fn welcome_of(&self, vendor: &str, session: &str) -> Result<Option<String>, StoreError> {
let txn = self.db.begin_read()?;
let t = txn.open_table(WELCOMES)?;
Ok(t.get((vendor, session))?.map(|v| v.value().to_owned()))
}
/// ★ **A card check minted for a free account** (owner 2026-10-15), kept until the card is
/// checked. A second write for the same `(vendor, session)` replaces the first.
pub fn record_card_check(&self, vendor: &str, session: &str, started: &CardCheckStarted) -> Result<(), StoreError> {
let bytes = StoreError::encode("a card check", started)?;
let txn = self.db.begin_write()?;
txn.open_table(CARD_CHECKS)?.insert((vendor, session), bytes.as_slice())?;
txn.commit()?;
Ok(())
}
/// ★ **Hold `username` on `site` for a vendor page** (replaces any hold of it).
pub fn hold_name(&self, product: &str, site: &str, username: &str, hold: &NameHold) -> Result<(), StoreError> {
let key = format!("{product}\u{0}{site}");
let bytes = StoreError::encode("a name hold", hold)?;
let txn = self.db.begin_write()?;
txn.open_table(NAME_HOLDS)?.insert((key.as_str(), username), bytes.as_slice())?;
txn.commit()?;
Ok(())
}
/// ★ **The live hold on `username` on `site`**, or `None` (none, or its time is up).
pub fn name_hold(&self, product: &str, site: &str, username: &str, now_unix_ms: i64) -> Result<Option<NameHold>, StoreError> {
let key = format!("{product}\u{0}{site}");
let txn = self.db.begin_read()?;
let t = txn.open_table(NAME_HOLDS)?;
let Some(v) = t.get((key.as_str(), username))? else { return Ok(None) };
let hold: NameHold = serde_json::from_slice(v.value()).map_err(|e| StoreError::corrupted(format!("name hold {username} will not parse: {e}")))?;
Ok((hold.until_unix_ms > now_unix_ms).then_some(hold))
}
/// ★ **Release every name held for `(vendor, session)`**: its page closed, refused, or
/// made the account. Answers how many.
pub fn release_names_of(&self, vendor: &str, session: &str) -> Result<usize, StoreError> {
let txn = self.db.begin_write()?;
let n = {
let mut t = txn.open_table(NAME_HOLDS)?;
let mut gone: Vec<(String, String)> = Vec::new();
for row in t.iter()? {
let (k, v) = row?;
if let Ok(h) = serde_json::from_slice::<NameHold>(v.value()) {
if h.vendor == vendor && h.session == session {
gone.push((k.value().0.to_owned(), k.value().1.to_owned()));
}
}
}
for (a, b) in &gone {
t.remove((a.as_str(), b.as_str()))?;
}
gone.len()
};
txn.commit()?;
Ok(n)
}
/// The card check this box minted as `(vendor, session)`, or `None`: not one of ours.
pub fn card_check(&self, vendor: &str, session: &str) -> Result<Option<CardCheckStarted>, StoreError> {
let txn = self.db.begin_read()?;
let t = txn.open_table(CARD_CHECKS)?;
let Some(v) = t.get((vendor, session))? else { return Ok(None) };
serde_json::from_slice(v.value()).map(Some).map_err(|e| StoreError::corrupted(format!("card check {vendor}:{session} will not parse: {e}")))
}
/// The card check `(vendor, session)` is refused, final: `words` is what the buyer reads.
pub fn refuse_card_check(&self, vendor: &str, session: &str, words: &str) -> Result<(), StoreError> {
if let Some(mut started) = self.card_check(vendor, session)? {
started.refused = Some(words.to_owned());
self.record_card_check(vendor, session, &started)?;
}
Ok(())
}
/// ★ **Put the checked card on a recorded signup** ([`SignupRecord::card`]). `Ok(false)`:
/// no such row.
pub fn set_card(&self, vendor: &str, session: &str, card: &CardOnFile) -> Result<bool, StoreError> {
let txn = self.db.begin_write()?;
let wrote = {
let mut t = txn.open_table(SIGNUPS)?;
let held: Option<Vec<u8>> = t.get((vendor, session))?.map(|v| v.value().to_vec());
match held.map(|b| parse_signup(vendor, session, &b)).transpose()? {
Some(mut rec) => {
rec.card = Some(card.clone());
let bytes = StoreError::encode("a signup record", &rec)?;
t.insert((vendor, session), bytes.as_slice())?;
true
}
None => false,
}
};
txn.commit()?;
Ok(wrote)
}
/// ★ **The free account this card already has on this site**, as `(session, username)`,
/// or `None` (owner 2026-10-15: at most ONE free account per card).
///
/// **Per site, and on the vendor account that checked it**: each site sells through its
/// own Stripe account (`stripe/gunnar`, `stripe/gunnar-test2`), and a card's fingerprint
/// is the same only on ONE vendor account — the same card checked on another site's
/// account reads as another card, so a per-vendor key across sites could not be kept
/// anyway. The site is the record's estate, else `home` (as
/// [`Self::has_account_with_email_on`] reads it). An account still being made (PENDING:
/// a pulled product's main has not acked yet) counts — it will be one; a refused row
/// never made one.
pub fn free_account_with_card_on(&self, product: &str, site: Option<&str>, home: Option<&str>, vendor: &str, fingerprint: &str) -> Result<Option<(String, String)>, StoreError> {
let fingerprint = fingerprint.trim();
if fingerprint.is_empty() {
return Ok(None);
}
let named = |e: Option<&str>| e.map(str::trim).filter(|e| !e.is_empty()).map(str::to_owned);
let mine = named(site).or_else(|| named(home));
Ok(self.export_all()?.into_iter().find_map(|rec| {
let card = rec.card.as_ref()?;
let theirs = named(rec.estate.as_deref()).or_else(|| named(home));
let held = rec.signup.product == product && theirs == mine && card.vendor == vendor && card.fingerprint == fingerprint && !matches!(rec.state, SignupState::Refused { .. });
held.then(|| (rec.signup.session.clone(), rec.signup.username.clone()))
}))
}
/// **Whether this box minted `(vendor, session)`** — `Some(estate)` (`None` inside: no
/// estate named) — or `None`: another system's session (ST-7).
pub fn minted(&self, vendor: &str, session: &str) -> Result<Option<Option<String>>, StoreError> {
let txn = self.db.begin_read()?;
let t = txn.open_table(MINTED)?;
Ok(t.get((vendor, session))?.map(|v| Some(v.value().to_owned()).filter(|e| !e.is_empty())))
}
/// The reconciler read this session back from the vendor as paid at
/// `paid_at_unix_ms`. Noted, not stored — see `confirmed`.
pub fn note_vendor_confirmed(&self, vendor: &str, session: &str, paid_at_unix_ms: i64) {
self.confirmed.lock().unwrap_or_else(|p| p.into_inner()).insert((vendor.to_owned(), session.to_owned()), paid_at_unix_ms);
}
/// When the vendor's own ledger last confirmed this session paid, if the
/// reconciler has read it back in this process.
pub fn vendor_confirmed(&self, vendor: &str, session: &str) -> Option<i64> {
self.confirmed.lock().unwrap_or_else(|p| p.into_inner()).get(&(vendor.to_owned(), session.to_owned())).copied()
}
/// **Take the one sink call for `(vendor, session)`**, or `None` when
/// another thread holds it. See `in_flight`.
pub fn fly(&self, vendor: &str, session: &str) -> Option<Flight<'_>> {
let key = (vendor.to_owned(), session.to_owned());
let fresh = self.in_flight.lock().unwrap_or_else(|p| p.into_inner()).insert(key.clone());
fresh.then_some(Flight { store: self, key })
}
/// The sink's last "not now" for a pending session, if this process has had one.
pub fn waiting_on(&self, vendor: &str, session: &str) -> Option<String> {
self.not_now(vendor, session).map(|n| n.why)
}
/// **The last "not now" the product gave for this signup**, its words and whether it was
/// its maintenance, by name (RA4-20). In memory, like the rest of `waiting`.
pub fn not_now(&self, vendor: &str, session: &str) -> Option<NotNow> {
self.waiting.lock().unwrap_or_else(|p| p.into_inner()).get(&(vendor.to_owned(), session.to_owned())).cloned()
}
/// **Whether the "stays pending" line is said** — once per CHANGE, never
/// every tick: `previous` is the reason last noted for this signup (what
/// [`SignupStore::waiting_on`] keeps), `why` the one this pass got.
pub fn says_pending(previous: Option<&str>, why: &str) -> bool {
previous != Some(why)
}
fn note_waiting(&self, vendor: &str, session: &str, why: Option<NotNow>) {
let mut w = self.waiting.lock().unwrap_or_else(|p| p.into_inner());
let key = (vendor.to_owned(), session.to_owned());
match why {
Some(why) => {
w.insert(key, why);
}
None => {
w.remove(&key);
}
}
}
/// **Every PENDING row, oldest first** — what the background re-settle
/// walks. A full scan like [`SignupStore::list`], and not capped by its
/// ceiling: a paid signup the 501st row back is still owed an account.
pub fn pending(&self) -> Result<Vec<SignupRecord>, StoreError> {
let txn = self.db.begin_read()?;
let t = txn.open_table(SIGNUPS)?;
let mut out = Vec::new();
for entry in t.iter()? {
let (_, v) = entry?;
if let Ok(rec) = serde_json::from_slice::<SignupRecord>(v.value()) {
if rec.state.is_pending() {
out.push(rec);
}
}
}
out.sort_by_key(|r| r.recorded_at_unix_ms);
Ok(out)
}
/// **THE OWED-BACK QUEUE: every payment that was taken for an account that
/// was never created**, oldest first.
///
/// A full scan like [`SignupStore::pending`] and, like it, NOT capped by
/// [`SignupStore::LIST_CEILING`]: the ceiling is a console page's, and the
/// 501st row back is still somebody's hundred kronor. It is the store read a
/// third way rather than a second table, for the reason the module header
/// gives — this table is the record of the money, and a queue kept beside it
/// could disagree with it after a restore.
///
/// It survives a restart because the rows do: redb, `signups.redb`, the same
/// file the idempotency key lives in. A row LEAVES it when its money is
/// back: a [`SignupRefund::Done`] ([`refund`]) or a
/// [`SignupRefund::SettledByHand`] ([`settle_by_hand`]). A refund still
/// `Requested` stays — the vendor's yes was never heard.
pub fn owed_back(&self) -> Result<Vec<SignupRecord>, StoreError> {
let settled: HashSet<(String, String)> =
self.export_refunds()?.into_iter().filter(|(_, _, r)| r.settles()).map(|(v, s, _)| (v, s)).collect();
let mut out: Vec<SignupRecord> = self
.export_all()?
.into_iter()
.filter(|r| r.state.owed().is_some() && !settled.contains(&(r.signup.vendor.clone(), r.signup.session.clone())))
.collect();
out.sort_by_key(|r| r.recorded_at_unix_ms);
Ok(out)
}
/// **How many payments are owed back**, for the health line. `Err` is
/// propagated and never folded to 0: a store that would not answer is not a
/// deployment that owes nobody anything.
pub fn owed_back_count(&self) -> Result<usize, StoreError> {
Ok(self.owed_back()?.len())
}
/// **Claim `(vendor, session)`, or report who claimed it first.**
///
/// The read and the write are ONE redb transaction on purpose: two
/// deliveries of the same webhook can arrive on two threads, and a
/// check-then-write with a gap between them creates two accounts for one
/// payment. redb serialises writers, so the loser of the race sees the
/// winner's row and answers [`Recorded::Already`].
pub fn record_pending(&self, signup: &PaidSignup, now_unix_ms: i64) -> Result<Recorded, StoreError> {
self.record_pending_on(signup, now_unix_ms, None)
}
/// [`SignupStore::record_pending`], keeping the estate the signup began on
/// ([`SignupRecord::estate`]).
pub fn record_pending_on(&self, signup: &PaidSignup, now_unix_ms: i64, estate: Option<&str>) -> Result<Recorded, StoreError> {
let txn = self.db.begin_write()?;
let found = {
let mut t = txn.open_table(SIGNUPS)?;
let held: Option<Vec<u8>> = t.get((signup.vendor.as_str(), signup.session.as_str()))?.map(|v| v.value().to_vec());
match held {
Some(bytes) => serde_json::from_slice::<SignupRecord>(&bytes).ok().map(Box::new).map(Recorded::Already),
None => {
let rec = SignupRecord { signup: signup.clone(), state: SignupState::Pending, recorded_at_unix_ms: now_unix_ms, estate: estate.map(str::to_owned), card: None };
let bytes = StoreError::encode("a signup record", &rec)?;
t.insert((signup.vendor.as_str(), signup.session.as_str()), bytes.as_slice())?;
Some(Recorded::Fresh)
}
}
};
txn.commit()?;
// A row that will not deserialize is a row from a future or a broken
// format. It is NOT treated as absent — overwriting it would destroy
// the only record of a payment — so the claim fails loudly instead.
found.ok_or_else(|| StoreError::corrupted(format!("signup row {}:{} will not parse", signup.vendor, signup.session)))
}
/// Move a recorded signup to its outcome. A no-op with `Ok(false)` when
/// nobody claimed the key first — a `set` without a `record_pending` is a
/// caller bug, not a row to invent.
pub fn set(&self, vendor: &str, session: &str, state: SignupState) -> Result<bool, StoreError> {
let txn = self.db.begin_write()?;
let wrote = {
let mut t = txn.open_table(SIGNUPS)?;
let held: Option<Vec<u8>> = t.get((vendor, session))?.map(|v| v.value().to_vec());
match held.map(|b| parse_signup(vendor, session, &b)).transpose()? {
Some(mut rec) => {
rec.state = state;
let bytes = StoreError::encode("a signup record", &rec)?;
t.insert((vendor, session), bytes.as_slice())?;
true
}
None => false,
}
};
txn.commit()?;
Ok(wrote)
}
/// **Replace what the row says the customer asked for, and what became of
/// it, in one write.**
///
/// [`SignupStore::set`] moves the outcome and leaves the [`PaidSignup`]
/// alone, which is right for every path except a rename: there the NAME
/// changed, and a row whose stored signup still said the old one would make
/// `SignupAdmin.List` show an operator a name nobody has and a retry ask the
/// sink for it again. `recorded_at_unix_ms` is kept, because when the
/// webhook landed here did not change.
pub fn rewrite(&self, vendor: &str, session: &str, signup: &PaidSignup, state: SignupState) -> Result<bool, StoreError> {
let txn = self.db.begin_write()?;
let wrote = {
let mut t = txn.open_table(SIGNUPS)?;
let held: Option<Vec<u8>> = t.get((vendor, session))?.map(|v| v.value().to_vec());
match held.map(|b| parse_signup(vendor, session, &b)).transpose()? {
Some(rec) => {
let next = SignupRecord { signup: signup.clone(), state, recorded_at_unix_ms: rec.recorded_at_unix_ms, estate: rec.estate.clone(), card: rec.card.clone() };
let bytes = StoreError::encode("a signup record", &next)?;
t.insert((vendor, session), bytes.as_slice())?;
true
}
None => false,
}
};
txn.commit()?;
Ok(wrote)
}
/// A row that will not parse is an ERROR, never "no such signup": read as
/// absent, a settle would ask the sink again and a reversal would be
/// answered "not ours".
/// Tests: overwrite a row with bytes that will not parse.
#[cfg(test)]
pub(crate) fn break_row(&self, vendor: &str, session: &str) {
let txn = self.db.begin_write().unwrap();
txn.open_table(SIGNUPS).unwrap().insert((vendor, session), b"{not json".as_slice()).unwrap();
txn.commit().unwrap();
}
pub fn get(&self, vendor: &str, session: &str) -> Result<Option<SignupRecord>, StoreError> {
let txn = self.db.begin_read()?;
let t = txn.open_table(SIGNUPS)?;
t.get((vendor, session))?.map(|v| parse_signup(vendor, session, v.value())).transpose()
}
/// What [`refund`] / [`settle_by_hand`] recorded for this session. A row
/// that will not parse is an ERROR, never "no refund": reading it as absent
/// would let a second refund be requested over the first.
pub fn refund_of(&self, vendor: &str, session: &str) -> Result<Option<SignupRefund>, StoreError> {
let txn = self.db.begin_read()?;
let t = txn.open_table(REFUNDS)?;
match t.get((vendor, session))? {
None => Ok(None),
Some(v) => serde_json::from_slice(v.value())
.map(Some)
.map_err(|e| StoreError::corrupted(format!("refund row {vendor}:{session} will not parse: {e}"))),
}
}
/// **Write the refund row, only if there is none** — and answer what is
/// there now. One write transaction, so two callers cannot both think they
/// wrote the first `Requested` (in-process they are also serialised by the
/// flight, [`SignupStore::fly`]).
pub fn claim_refund(&self, vendor: &str, session: &str, first: &SignupRefund) -> Result<SignupRefund, StoreError> {
let txn = self.db.begin_write()?;
let now = {
let mut t = txn.open_table(REFUNDS)?;
let held: Option<Vec<u8>> = t.get((vendor, session))?.map(|v| v.value().to_vec());
match held {
Some(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| StoreError::corrupted(format!("refund row {vendor}:{session} will not parse: {e}")))?,
None => {
let bytes = StoreError::encode("a refund row", first)?;
t.insert((vendor, session), bytes.as_slice())?;
first.clone()
}
}
};
txn.commit()?;
Ok(now)
}
/// Overwrite the refund row: `Requested` → `Done`, or → settled by hand.
pub fn put_refund(&self, vendor: &str, session: &str, r: &SignupRefund) -> Result<(), StoreError> {
let txn = self.db.begin_write()?;
{
let mut t = txn.open_table(REFUNDS)?;
let bytes = StoreError::encode("a refund row", r)?;
t.insert((vendor, session), bytes.as_slice())?;
}
txn.commit()?;
Ok(())
}
/// **Every refund row, in key order** — what the owed-back filter, the tax
/// fold and a backup read. A row that will not parse fails the read, for
/// the reason [`SignupStore::refund_of`] gives.
pub fn export_refunds(&self) -> Result<Vec<(String, String, SignupRefund)>, StoreError> {
let txn = self.db.begin_read()?;
let t = txn.open_table(REFUNDS)?;
let mut out = Vec::new();
for entry in t.iter()? {
let (k, v) = entry?;
let (vendor, session) = k.value();
let r = serde_json::from_slice(v.value())
.map_err(|e| StoreError::corrupted(format!("refund row {vendor}:{session} will not parse: {e}")))?;
out.push((vendor.to_owned(), session.to_owned(), r));
}
Ok(out)
}
/// **The e-mail a tenant signed up with**, for `product`: the address of
/// the newest signup that CREATED `namespace` (the same fold as the
/// signup's entitlement, `created_signups`), or `None` (an account an
/// operator made by hand, or a signup still pending).
///
/// Why (owner 2026-09-28, test2.gunnar.rs): a tenant logged in to the
/// console ordered 10 GiB and Stripe's page asked for "Kontaktinformation /
/// E-postadress" again. monetize already holds the address the buyer typed
/// and confirmed at the signup; the purchase hands it to the vendor as the
/// payer, and the Stripe vendor opens the page with `customer_email` set.
/// A full scan, like [`SignupStore::list`], and for the same reason.
pub fn email_of(&self, product: &str, namespace: &str) -> Result<Option<String>, StoreError> {
Ok(created_signups(self, product)?
.into_iter()
.find(|(_, tenant, _)| tenant == namespace)
.map(|(rec, _, _)| rec.signup.email.trim().to_owned())
.filter(|e| !e.is_empty()))
}
/// **Whether an account of `product` already answers to `email`** (PLAN
/// §3c, OM-19 (4), the owner 2026-10-10 23:49Z): the address of a signup
/// that CREATED an account, compared trimmed and case-blind. A signup
/// still pending is no account yet, so a buyer who left a checkout unpaid
/// may start again. A full scan, like [`SignupStore::email_of`].
pub fn has_account_with_email(&self, product: &str, email: &str) -> Result<bool, StoreError> {
let wanted = email.trim().to_lowercase();
if wanted.is_empty() {
return Ok(false);
}
Ok(created_signups(self, product)?.iter().any(|(rec, _, _)| rec.signup.email.trim().to_lowercase() == wanted))
}
/// ★ ST-6: **[`Self::has_account_with_email`] on ONE SITE** (owner 2026-10-11: one
/// address, one account per site — gunnar.rs and test2.gunnar.rs are two sites of one
/// product on one box). A created account's site is the estate its signup began on
/// ([`SignupRecord::estate`]), else its namespace's (`@<estate>/…`), else `home` (the
/// product's own estate); `site` is the caller's, else `home`. Another site's account with
/// this address is no account here.
pub fn has_account_with_email_on(&self, product: &str, site: Option<&str>, home: Option<&str>, email: &str) -> Result<bool, StoreError> {
let wanted = email.trim().to_lowercase();
if wanted.is_empty() {
return Ok(false);
}
let named = |e: Option<&str>| e.map(str::trim).filter(|e| !e.is_empty()).map(str::to_owned);
let mine = named(site).or_else(|| named(home));
Ok(created_signups(self, product)?.iter().any(|(rec, tenant, _)| {
let theirs = named(rec.estate.as_deref())
.or_else(|| monetize_product::TenantId(tenant.clone()).estate().map(|(e, _)| e.to_owned()))
.or_else(|| named(home));
theirs == mine && rec.signup.email.trim().to_lowercase() == wanted
}))
}
/// **Every signup, newest first**, at most `limit` (0 ⇒
/// [`SignupStore::LIST_CEILING`]).
///
/// redb orders by the key, which is `(vendor, session)` — alphabetical, not
/// chronological — so this reads the table and sorts by when the row landed
/// here. That is a full scan, and it is the right trade for this table: a
/// deployment that has taken a hundred thousand signups has a bigger
/// problem than a console page, and a second time-keyed table (the ledger's
/// `recent` trick) would be two copies of every signup to keep in step.
pub fn list(&self, limit: usize) -> Result<Vec<SignupRecord>, StoreError> {
let limit = if limit == 0 { Self::LIST_CEILING } else { limit.min(Self::LIST_CEILING) };
let txn = self.db.begin_read()?;
let t = txn.open_table(SIGNUPS)?;
let mut out: Vec<SignupRecord> = Vec::new();
for entry in t.iter()? {
let (_, v) = entry?;
if let Ok(rec) = serde_json::from_slice::<SignupRecord>(v.value()) {
out.push(rec);
}
}
out.sort_by(|a, b| b.recorded_at_unix_ms.cmp(&a.recorded_at_unix_ms).then_with(|| a.signup.session.cmp(&b.signup.session)));
out.truncate(limit);
Ok(out)
}
/// **Every record, in KEY order** — what a backup exports.
///
/// Key order and not [`SignupStore::list`]'s newest-first: an export must be
/// deterministic and total, so two exports of unchanged state are
/// byte-identical and a git-backed backup has nothing to commit. Two rows
/// recorded in the same millisecond sort stably here and would not there.
pub fn export_all(&self) -> Result<Vec<SignupRecord>, StoreError> {
let txn = self.db.begin_read()?;
let t = txn.open_table(SIGNUPS)?;
let mut out = Vec::new();
for entry in t.iter()? {
let (_, v) = entry?;
if let Ok(rec) = serde_json::from_slice::<SignupRecord>(v.value()) {
out.push(rec);
}
}
Ok(out)
}
/// **Read a backup back, into an EMPTY store.**
///
/// It refuses a store that already holds rows, the way `Ledger::restore`
/// does and for a sharper reason: this table IS the idempotency key. A
/// restore interleaved with live history would leave one `(vendor, session)`
/// holding whichever of the two rows was written last, and the loser's
/// customer — already created, already charged — would be created a second
/// time by the vendor's next retry.
pub fn restore(&self, records: &[SignupRecord]) -> Result<usize, StoreError> {
{
let txn = self.db.begin_read()?;
if txn.open_table(SIGNUPS)?.len()? > 0 {
return Err(StoreError::corrupted("restore into a signup store that already holds rows"));
}
}
let txn = self.db.begin_write()?;
{
let mut t = txn.open_table(SIGNUPS)?;
for rec in records {
let bytes = StoreError::encode("a signup record", rec)?;
t.insert((rec.signup.vendor.as_str(), rec.signup.session.as_str()), bytes.as_slice())?;
}
}
txn.commit()?;
Ok(records.len())
}
/// **Another machine's signups INTO this live store** (G10, the ledger
/// merge): one write transaction, refused whole if ONE `(vendor, session)`
/// is already here or comes twice — this table is the idempotency key, and
/// two rows for one key would leave the loser's customer created twice.
pub fn merge(&self, records: &[SignupRecord]) -> Result<usize, StoreError> {
let txn = self.db.begin_write()?;
{
let mut t = txn.open_table(SIGNUPS)?;
let mut seen = std::collections::BTreeSet::new();
for rec in records {
let key = (rec.signup.vendor.as_str(), rec.signup.session.as_str());
if !seen.insert(key) || t.get(key)?.is_some() {
return Err(StoreError::corrupted(format!("merge of a signup this store already holds: {}:{}", key.0, key.1)));
}
}
for rec in records {
let bytes = StoreError::encode("a signup record", rec)?;
t.insert((rec.signup.vendor.as_str(), rec.signup.session.as_str()), bytes.as_slice())?;
}
}
txn.commit()?;
Ok(records.len())
}
}
/// **Everything about the signup that this deployment can check on its own** —
/// `None` when it all agrees, the refusal reason otherwise.
///
/// It runs BEFORE the sink, and that ordering is the point: a refusal here
/// costs nothing and reaches the console with the customer's own words in it,
/// while a sink that has already created a principal cannot un-create one.
///
/// The three checks are the three things a stranger asserted:
///
/// 1. **the username** — a hosted page will happily take `Bob!` and there is
/// no product on earth that can be told to make that a namespace;
/// 2. **the plan** — a page that sells something `[signups.<product>.plans]` does not name
/// is stale or forged, and either way the deployment does not know what to
/// grant;
/// 3. **the amount, NET of tax** — the one check that catches a page whose price
/// drifted from the config's, which is how a 10 GiB plan quietly becomes
/// free. It is the NET that is compared: a checkout that collects VAT hands
/// up a total larger than the plan price by exactly the tax, and comparing
/// the gross would refuse every one of those as a drifted price.
///
/// Each answer carries its [`Refusal`] beside its words, because the first of
/// the three is about the NAME — the one a customer can fix by typing another —
/// and the other two are about the ORDER and are final.
/// ★ **The plan a paid sign-up named**: this deployment's `[signups.<product>.plans]`
/// entry, or — round 5.3.1, room by the rule — the terms a PRICED id stands for
/// (`crate::plans::priced_signup_plan`: the declared size and period, the price
/// the checkout charged).
pub fn plan_named(state: &crate::state::AppState, id: &str) -> Option<SignupPlan> {
state.signup_plans.get(id).or_else(|| crate::plans::priced_signup_plan(state, id))
}
/// [`plan_named`] for the checks: the table, with the priced plan `id` names
/// beside it when it is one.
pub fn plans_with(state: &crate::state::AppState, id: &str) -> std::collections::BTreeMap<String, SignupPlan> {
let mut plans = state.signup_plans.snapshot();
if !plans.contains_key(id) {
if let Some(p) = crate::plans::priced_signup_plan(state, id) {
plans.insert(id.to_owned(), p);
}
}
plans
}
pub fn refusal(plans: &std::collections::BTreeMap<String, SignupPlan>, s: &PaidSignup) -> Option<(Refusal, String)> {
if let Some(why) = monetize_pay::signup::username_refusal(&s.username) {
return Some((Refusal::Name, why));
}
let Some(plan) = plans.get(&s.plan) else {
let known: Vec<&str> = plans.keys().map(String::as_str).collect();
return Some((Refusal::Final, format!("plan {:?} is not sold here; [signups.<product>.plans] names {:?}", s.plan, known)));
};
let want = plan.price();
let net_minor = s.amount.minor.saturating_sub(s.tax.minor);
if want.currency != s.amount.currency || want.minor != net_minor {
// Every figure in major units with its currency (owner, live
// 2026-10-12: "costs 11000 SEK … took 12500 SEK" was öre read as kronor).
let money = |minor| monetize_pay::Money { minor, currency: s.amount.currency }.in_words();
return Some((Refusal::Final, format!(
"paid {} ({} of it tax, {} net), but plan {:?} costs {} here",
money(s.amount.minor),
money(s.tax.minor),
money(net_minor),
s.plan,
want.in_words()
)));
}
None
}
/// **A price refusal stored before 2026-10-12, in today's words.** Those rows
/// say `plan "p" costs 11000 SEK here and the vendor took 12500 SEK (2500 of it
/// tax), a net of 10000 SEK` — öre printed as kronor. The row is kept as it was
/// written; the console is handed it re-said in major units. `None` for any
/// other reason, which is carried as written.
pub fn legacy_price_refusal(reason: &str) -> Option<String> {
let rest = reason.strip_prefix("plan ")?;
let (plan, rest) = rest.split_once(" costs ")?;
let (want, rest) = rest.split_once(" here and the vendor took ")?;
let (took, rest) = rest.split_once(" (")?;
let (tax, rest) = rest.split_once(" of it tax), a net of ")?;
let words = |pair: &str| -> Option<String> {
let (n, code) = pair.trim().split_once(' ')?;
Some(monetize_pay::major_words(n.parse().ok()?, code))
};
let code = took.trim().split_once(' ')?.1;
let tax = monetize_pay::major_words(tax.trim().parse().ok()?, code);
Some(format!("paid {} ({tax} of it tax, {} net), but plan {plan} costs {} here", words(took)?, words(rest)?, words(want)?))
}
/// **What the CHECKS' refusal leaves owed.** They run before the sink, so
/// nothing was created whatever they said — the only question is whether the
/// customer can finish it themselves, which is the same question [`Refusal`]
/// already answers. One writer, so the two cannot drift apart.
fn owed_for_checks(refusal: Refusal) -> Owed {
if refusal.renameable() {
Owed::RenameOrRefund
} else {
Owed::Refund
}
}
/// **The open build's sink: it says what it WOULD do, and answers Created.**
///
/// It is not a stub and not a `todo!()`. The open half of monetize is a real
/// deployment for people sharing their servers for free, and the whole seam —
/// the webhook, the store, the idempotency,
/// the refusals, the poll from the return page — has to be exercisable end to
/// end by somebody who has no private crate at all. So it logs the line an
/// operator would need and reports the namespace it would have made.
///
/// It answers **Created** rather than `Refused("not implemented")` for the same
/// reason [`crate::registry::Linked::open`] links a `server` cloud rather than
/// none: the open path must be a working path, or nobody can tell a broken
/// deployment from an unconfigured one. What it does NOT do is create anything,
/// and it says so in every line it writes.
/// **The open server's sink when none is loaded** (owner 2026-09-26): it
/// creates nothing and SAYS it creates nothing — `Signup.Start` is refused
/// before any checkout, and a paid webhook that reached it anyway is refused
/// by name (stored, for a refund by hand), never answered `Created`.
pub struct NoSink;
impl SignupSink for NoSink {
fn paid(&self, s: &PaidSignup) -> Result<SignupOutcome, SignupError> {
Err(SignupError::Refused(format!(
"no signup sink is loaded on this server, so no account was created for {} ({}:{}); the money is at the vendor and needs a refund by hand",
s.username, s.vendor, s.session
)))
}
fn name_verdict(&self, _username: &str) -> Result<NameVerdict, SignupError> {
Err(SignupError::Unavailable("no signup sink is loaded on this server".into()))
}
fn creates_accounts(&self) -> bool {
false
}
}
pub struct LoudSink;
impl SignupSink for LoudSink {
fn paid(&self, s: &PaidSignup) -> Result<SignupOutcome, SignupError> {
let line = format!("would create {} for {} ({} at {})", s.username, s.plan, s.amount.major(), s.vendor);
skidbladnir::functional_status("monetize-server/signup", &s.vendor, true, &line);
eprintln!("monetize-server: signup {}:{} — {line}; NO principal was created (the open build's LoudSink)", s.vendor, s.session);
Ok(SignupOutcome::Created { namespace: s.username.clone(), username: s.username.clone() })
}
/// **The shape rule and nothing else, because this sink holds nothing.**
///
/// It creates no principal, so no name it was ever asked about is taken —
/// answering anything but `Free` for a well-formed name would be inventing
/// an occupant. The deployment that DOES hold names is the one with a real
/// sink, and its reserved list and its product's own answer live there,
/// behind this same method, so nothing outside a sink ever needs a second
/// copy of either.
fn name_verdict(&self, username: &str) -> Result<NameVerdict, SignupError> {
Ok(match monetize_pay::signup::username_refusal(username) {
Some(why) => NameVerdict::Taken(why),
None => NameVerdict::Free,
})
}
}
/// The sink slot: shared, because the gRPC `Status` poll retries through the
/// same one the webhook used.
pub type Sink = Arc<dyn SignupSink>;
/// **Every product's `[signups.<product>.plans]` and the meter each product's
/// caps land on, as the server holds them LIVE** — shared (a clone reads the
/// same terms), replaced whole by the guest watcher
/// ([`crate::guests::apply_signup`]) when the config moves. A plan id names its
/// product (`holger/base-se`), so one map holds them all and
/// [`Plans::snapshot_of`] is one product's.
#[derive(Clone, Default)]
pub struct Plans(Arc<std::sync::RwLock<PlanTerms>>);
type PlanMap = std::collections::BTreeMap<String, SignupPlan>;
type MeterMap = std::collections::BTreeMap<String, String>;
#[derive(Clone, Default, PartialEq)]
struct PlanTerms {
plans: PlanMap,
cap_meters: MeterMap,
}
impl std::fmt::Debug for Plans {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Plans").field("plans", &self.snapshot()).field("cap_meters", &self.read().cap_meters).finish()
}
}
impl From<PlanMap> for Plans {
fn from(plans: PlanMap) -> Plans {
Plans::of(plans, MeterMap::new())
}
}
impl<const N: usize> From<[(String, SignupPlan); N]> for Plans {
fn from(plans: [(String, SignupPlan); N]) -> Plans {
Plans::of(plans.into_iter().collect(), MeterMap::new())
}
}
/// The product a plan id names: `holger/base-se` → `holger`.
pub fn plan_product(plan_id: &str) -> &str {
plan_id.split('/').next().unwrap_or_default()
}
impl Plans {
/// Every product's plans, with the meter each product's caps land on
/// (`product → meter`; a product not named is [`SIGNUP_CAP_METER`]).
pub fn of(plans: PlanMap, cap_meters: MeterMap) -> Plans {
Plans(Arc::new(std::sync::RwLock::new(PlanTerms { plans, cap_meters })))
}
fn read(&self) -> std::sync::RwLockReadGuard<'_, PlanTerms> {
self.0.read().unwrap_or_else(|p| p.into_inner())
}
/// The plan `id` names, or none: a plan this deployment does not list is never sold.
pub fn get(&self, id: &str) -> Option<SignupPlan> {
self.read().plans.get(id).copied()
}
pub fn is_empty(&self) -> bool {
self.read().plans.is_empty()
}
/// Every product's plans now, as a map (they are few and small).
pub fn snapshot(&self) -> PlanMap {
self.read().plans.clone()
}
/// **The meter `product`'s signup `cap_bytes` land on** —
/// `[signups.<product>] cap_meter` (holger: `disk_bytes`), else
/// [`SIGNUP_CAP_METER`] (gunnar's `pack_bytes`).
pub fn cap_meter_of(&self, product: &str) -> String {
self.read().cap_meters.get(product).filter(|m| !m.trim().is_empty()).cloned().unwrap_or_else(|| SIGNUP_CAP_METER.to_owned())
}
/// `product`'s plans only (a plan id names its product: `holger/…`).
pub fn snapshot_of(&self, product: &str) -> PlanMap {
self.read().plans.iter().filter(|(id, _)| plan_product(id) == product).map(|(id, p)| (id.clone(), *p)).collect()
}
/// Replace the terms whole; `true` when they changed.
pub fn set(&self, plans: PlanMap, cap_meters: MeterMap) -> bool {
let next = PlanTerms { plans, cap_meters };
let mut cur = self.0.write().unwrap_or_else(|p| p.into_inner());
if *cur == next {
return false;
}
*cur = next;
true
}
}
/// **Each product's signup sink, each replaceable while the server runs** (one
/// monetize, several products, 2026-10-10). A call goes to the sink of the
/// product it is FOR (`PaidSignup::product`, the notice's product, the name
/// check's product) as it stands at that moment; [`SignupReload`] swaps one
/// product's sink when its `[signups.<product>]` names a new guest. A swap
/// never interrupts a call in flight, and never touches another product's.
pub struct HotSink {
cur: std::sync::RwLock<std::collections::BTreeMap<String, Sink>>,
/// **Where the host's relay is read from** — the secrets dir the installer
/// feeds `secret.smtp_url` / `secret.smtp_from` (and `secret.smtp_ca`) into.
/// `None`: the environment names only. See [`HotSink::notify`].
mail: std::sync::RwLock<Option<std::path::PathBuf>>,
}
impl HotSink {
/// One sink per product (`[signups.<product>]`), each replaceable live.
pub fn per_product(sinks: std::collections::BTreeMap<String, Sink>) -> Arc<HotSink> {
Arc::new(HotSink { cur: std::sync::RwLock::new(sinks), mail: std::sync::RwLock::new(None) })
}
/// Read the host's relay from `secrets_dir` (the served process's).
pub fn mail_from(&self, secrets_dir: &std::path::Path) {
*self.mail.write().unwrap_or_else(|p| p.into_inner()) = Some(secrets_dir.to_owned());
}
/// **The host's relay, as it stands NOW** — re-read at every notice, so a
/// relay fed (or rotated) while the server runs is the one used. `Ok(None)`:
/// this host has none.
pub fn host_mailer(&self) -> Result<Option<monetize_mail::Mailer>, String> {
self.host_mailer_of(None)
}
/// [`Self::host_mailer`] for `product`'s mail (A16): its own sender where it is fed.
pub fn host_mailer_of(&self, product: Option<&str>) -> Result<Option<monetize_mail::Mailer>, String> {
let dir = self.mail.read().unwrap_or_else(|p| p.into_inner()).clone();
host_mailer_for(dir.as_deref(), product)
}
/// `product`'s sink now, or the refusal that names the block it would come from.
fn of(&self, product: &str) -> Result<Sink, SignupError> {
self.cur.read().unwrap_or_else(|p| p.into_inner()).get(product).cloned().ok_or_else(|| {
SignupError::Refused(format!("no signup sink serves product {product:?}: this server has no [signups.{product}] that names one"))
})
}
/// The products a sink stands for.
pub fn products(&self) -> Vec<String> {
self.cur.read().unwrap_or_else(|p| p.into_inner()).keys().cloned().collect()
}
/// Replace `product`'s sink alone.
pub fn replace(&self, product: &str, next: Sink) {
self.cur.write().unwrap_or_else(|p| p.into_inner()).insert(product.to_owned(), next);
}
}
/// **The host's relay as fed in `dir` NOW** (`secret.smtp_url` / `secret.smtp_from` /
/// `secret.smtp_ca`), else the environment's names. `Ok(None)`: none. Read again by every
/// caller (a relay fed or rotated while the box runs is the one used): the customer notices
/// ([`HotSink::host_mailer`]) and the ONE box's operator alerts (monetize-admin `alerts`).
pub fn host_mailer_in(dir: Option<&std::path::Path>) -> Result<Option<monetize_mail::Mailer>, String> {
host_mailer_for(dir, None)
}
/// Pure: **the relay and sender for `product`'s mail** (A16): its own
/// (`smtp_url.<product>` + `smtp_from.<product>`, both or neither) where fed, else the host's.
pub fn relay_names(fed: &dyn Fn(&str) -> Option<String>, product: Option<&str>) -> (Option<String>, Option<String>) {
if let Some(p) = product.map(str::trim).filter(|p| !p.is_empty()) {
if let (Some(url), Some(from)) = (fed(&format!("smtp_url.{p}")), fed(&format!("smtp_from.{p}"))) {
return (Some(url), Some(from));
}
}
(fed("smtp_url"), fed("smtp_from"))
}
/// [`host_mailer_in`] for `product`'s mail: its own relay and sender where fed.
pub fn host_mailer_for(dir: Option<&std::path::Path>, product: Option<&str>) -> Result<Option<monetize_mail::Mailer>, String> {
let fed_bytes = |name: &str| dir.and_then(|d| monetize_product_wasm::secrets::read(d, name).ok()).map(|s| s.bytes().to_vec());
let env = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
let text = |b: Vec<u8>| String::from_utf8(b).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
let fed = |name: &str| fed_bytes(name).and_then(text);
let (url, from) = relay_names(&fed, product);
let url = url.or_else(|| env("MONETIZE_SMTP_URL"));
let from = from.or_else(|| env("MONETIZE_SMTP_FROM"));
let fed = fed_bytes;
let ca = fed("smtp_ca").or_else(|| env("MONETIZE_SMTP_CA").and_then(|p| std::fs::read(p).ok()));
match (url, from) {
(None, None) => Ok(None),
(Some(url), Some(from)) => monetize_mail::Mailer::with_root(&url, &from, ca.as_deref()).map(Some),
(Some(_), None) => Err("the host's relay is named (smtp_url) and its sender is not (smtp_from); a relay with no sender sends nothing".into()),
(None, Some(_)) => Err("the host's sender is named (smtp_from) and its relay is not (smtp_url)".into()),
}
}
impl SignupSink for HotSink {
fn paid(&self, s: &PaidSignup) -> Result<SignupOutcome, SignupError> {
self.of(&s.product)?.paid(s)
}
/// A name is free or taken FOR a product; with none named there is no answer.
fn name_verdict(&self, username: &str) -> Result<NameVerdict, SignupError> {
Err(SignupError::Refused(format!("the name {username:?} is checked for a product, and none was named")))
}
fn name_verdict_for(&self, product: &str, username: &str) -> Result<NameVerdict, SignupError> {
self.of(product)?.name_verdict(username)
}
/// The product's sink, told the estate the account would be made on.
fn name_verdict_on(&self, product: &str, username: &str, estate: Option<&str>) -> Result<NameVerdict, SignupError> {
self.of(product)?.name_verdict_on(product, username, estate)
}
fn paid_on(&self, s: &PaidSignup, estate: Option<&str>) -> Result<SignupOutcome, SignupError> {
self.of(&s.product)?.paid_on(s, estate)
}
fn resend_welcome(&self, s: &PaidSignup, force: bool) -> Result<String, SignupError> {
self.of(&s.product)?.resend_welcome(s, force)
}
/// Some product's sink creates accounts.
fn creates_accounts(&self) -> bool {
self.cur.read().unwrap_or_else(|p| p.into_inner()).values().any(|s| s.creates_accounts())
}
fn creates_accounts_for(&self, product: &str) -> bool {
self.of(product).is_ok_and(|s| s.creates_accounts())
}
/// **The product's sink's own mail first; the HOST's relay when it sends
/// none.** A linked sink with a mailer (monetize-impl's) answers itself. A
/// signup GUEST, `NoSink` and a product with no sink send nothing, and a
/// notice to a customer the host must deliver (g-nonpay, g-renewal) goes
/// through the host's relay, the same `monetize_mail` the welcome mail uses.
fn notify(&self, notice: &monetize_pay::CustomerNotice) -> Result<String, SignupError> {
match self.of(¬ice.product).and_then(|s| s.notify(notice)) {
Err(SignupError::Refused(sink)) => match self.host_mailer_of(Some(¬ice.product)) {
Ok(Some(m)) => {
let to = notice.to.trim();
if to.is_empty() {
return Err(SignupError::Refused(format!("{}/{} has no e-mail address; the {} notice was not sent", notice.product, notice.tenant, notice.kind)));
}
m.notice(to, ¬ice.kind, ¬ice.subject, ¬ice.body)
.map(|reply| format!("{} mail sent to {} through the host's relay {}; the relay accepted it: {reply}", notice.kind, monetize_mail::masked(to), m.relay().describe()))
.map_err(SignupError::Unavailable)
}
Ok(None) => Err(SignupError::Refused(format!("{sink}, and this host names no relay (secret.smtp_url + secret.smtp_from, or MONETIZE_SMTP_URL + MONETIZE_SMTP_FROM)"))),
Err(e) => Err(SignupError::Refused(format!("{sink}, and the host's relay is misconfigured: {e}"))),
},
other => other,
}
}
}
/// What builds a sink from a `[signups.<product>]` block that names a guest
/// (the server's: the plans, the policy and the signing key are its). The
/// block arrives with `product` set from its name.
pub type BuildSink = Arc<dyn Fn(&serde_json::Value) -> Result<Sink, String> + Send + Sync>;
/// **Each `[signups.<product>] guest = …` taken in live** (owner 2026-09-28,
/// holger's base plan; one per product 2026-10-10): when a block — or the
/// module file it names — changes, that product's guest is built through every
/// gate a start passes and swapped into the [`HotSink`]; a block that does not
/// build is REFUSED by name and the sink that stood keeps serving. A block that
/// names no guest changes nothing here (a linked sink is the start's); a guest
/// block that VANISHES leaves its product with no sink — nothing sold, no
/// account made — never with the last guest's.
pub struct SignupReload {
hot: Arc<HotSink>,
build: BuildSink,
last: Mutex<std::collections::BTreeMap<String, String>>,
}
impl SignupReload {
pub fn new(hot: Arc<HotSink>, build: BuildSink) -> SignupReload {
SignupReload { hot, build, last: Mutex::new(Default::default()) }
}
/// What each product's guest was built from at start, so the first look
/// does not build it twice.
pub fn started_from(self, blocks: &std::collections::BTreeMap<String, serde_json::Value>) -> SignupReload {
*self.last.lock().unwrap_or_else(|p| p.into_inner()) =
blocks.iter().filter(|(_, b)| monetize_product_wasm::signup::is_guest_block(b)).map(|(p, b)| (p.clone(), Self::key(b))).collect();
self
}
fn key(settings: &serde_json::Value) -> String {
let of = |p: &str| std::fs::metadata(p).ok().map(|m| (m.len(), m.modified().ok()));
let module = settings.get("guest").and_then(|g| g.as_str()).unwrap_or_default();
format!("{settings}\u{0}{:?}\u{0}{:?}", of(module), of(&format!("{module}.p7s")))
}
/// One look at every `[signups.<product>]`; answers the lines to log.
pub fn apply(&self, blocks: &std::collections::BTreeMap<String, serde_json::Value>) -> Vec<String> {
let mut lines = Vec::new();
let mut last = self.last.lock().unwrap_or_else(|p| p.into_inner());
let gone: Vec<String> = last.keys().filter(|p| !blocks.get(*p).is_some_and(monetize_product_wasm::signup::is_guest_block)).cloned().collect();
for product in gone {
last.remove(&product);
self.hot.replace(&product, Arc::new(NoSink));
lines.push(format!("signup guest REMOVED live [signups.{product}] names no guest any more — product {product} sells no signup"));
}
for (product, settings) in blocks.iter().filter(|(_, b)| monetize_product_wasm::signup::is_guest_block(b)) {
let key = Self::key(settings);
if last.get(product) == Some(&key) {
continue;
}
last.insert(product.clone(), key);
match (self.build)(settings) {
Ok(sink) => {
self.hot.replace(product, sink);
lines.push(format!("signup guest LOADED live [signups.{product}] — product {product}'s signup sink is replaced, no restart"));
}
Err(e) => lines.push(format!("signup guest REFUSED [signup-guest-refused-live] [signups.{product}] {e} — the sink that stood keeps serving")),
}
}
lines
}
}
/// **The estate `signup`'s account is made on**: `kept` (the estate its `Signup.Start` came
/// from, [`SignupRecord::estate`]), else the declared site whose own account its money went
/// through ([`crate::registry::Registry::estate_of_vendor`]); `None` is the product's home.
pub fn sited(state: &crate::state::AppState, signup: &PaidSignup, kept: Option<String>) -> Option<String> {
kept.or_else(|| state.registry.estate_of_vendor(&signup.product, &signup.vendor))
}
/// **Record a verified signup and settle it. The whole engine, in one place.**
///
/// One writer (one implementation): the webhook calls it and so does the `Status` poll's
/// retry, so a signup that was created by a retry is created by the same code
/// that would have created it the first time — including the log line and the
/// checks.
///
/// It never fails: every path ends in a [`SignupState`] the caller stores and
/// answers 200 with. A store that cannot be written is the one exception and it
/// leaves the signup PENDING in memory, which is honest — nothing was recorded,
/// so the vendor's next retry will find nothing and try again.
pub fn settle(state: &crate::state::AppState, signup: &PaidSignup) -> SignupState {
let now = state.now() as i64;
match state.signups.record_pending(signup, now) {
// Already decided: **the second webhook does nothing at all**. Not the
// checks, not the sink, not a log line claiming a state change that did
// not happen — just the answer the first one arrived at.
Ok(Recorded::Already(rec)) if !rec.state.is_pending() => return rec.state,
// Already there and still pending: this is a retry (a vendor
// re-delivery, or a Status poll). Fall through and ask again.
Ok(Recorded::Already(_)) => {}
Ok(Recorded::Fresh) => {}
Err(e) => {
skidbladnir::functional_status("monetize-server/signup", &signup.vendor, false, &format!("signup store refused the record: {e}"));
eprintln!("monetize-server: signup {}:{} could not be recorded: {e}", signup.vendor, signup.session);
return SignupState::Pending;
}
}
// **One sink call per session at a time.** A background re-settle and the
// customer's poll can now reach this line together; the loser answers what
// the row says — PENDING — and calls nothing. See `SignupStore::in_flight`.
let Some(_flight) = state.signups.fly(&signup.vendor, &signup.session) else {
return SignupState::Pending;
};
// Read again UNDER the flight: the holder before us may have decided this
// session between `record_pending` above and the flight being free, and a
// second sink call for a decided signup is exactly what the flight is for.
let kept = match state.signups.get(&signup.vendor, &signup.session) {
Ok(Some(rec)) if !rec.state.is_pending() => return rec.state,
Ok(Some(rec)) => rec.estate,
_ => None,
};
// The estate the account is made on: where the signup began (kept with its record), else
// the site whose own account took the money; neither is the product's home.
let estate = sited(state, signup, kept);
let plans = plans_with(state, &signup.plan);
let next = match refusal(&plans, signup) {
Some((refusal, reason)) => SignupState::Refused { reason, at_unix_ms: now, refusal, words: None, owed: owed_for_checks(refusal) },
None => match state.signup_sink.paid_on(signup, estate.as_deref()) {
Ok(SignupOutcome::Created { namespace, username }) => SignupState::Created { namespace, username, at_unix_ms: now },
// The product's own words are the operator's; the customer reads a
// sentence that tells them what to do.
Err(SignupError::Refused(reason)) => SignupState::Refused {
reason,
at_unix_ms: now,
refusal: Refusal::Final,
words: Some(words::COULD_NOT_CREATE.into()),
// **The sink had already begun**: it creates the principal and
// then pushes the entitlement, so a refusal here leaves a name
// spent and money taken. See [`Owed`].
owed: Owed::RefundAndTheNameMayBeSpent,
},
// **The one refusal a customer can get out of.** Recorded as such so
// `Signup.Rename` can be offered without any page reading prose.
Err(SignupError::NameTaken(reason)) => SignupState::Refused { reason, at_unix_ms: now, refusal: Refusal::Name, words: None, owed: Owed::RenameOrRefund },
// **Ours, not theirs.** Stay PENDING and let the next poll ask
// again — unless it has been pending so long that "again" has
// stopped meaning anything (see [`PENDING_CEILING_MS`]).
Err(not_now @ (SignupError::Unavailable(_) | SignupError::Maintenance(_))) => {
// RA4-20: the product's maintenance by NAME, never by a prefix of its words.
let (why, maintenance) = match not_now {
SignupError::Maintenance(why) => (why, true),
SignupError::Unavailable(why) | SignupError::Refused(why) | SignupError::NameTaken(why) => (why, false),
};
let recorded = state.signups.get(&signup.vendor, &signup.session).ok().flatten().map(|r| r.recorded_at_unix_ms).unwrap_or(now);
if now.saturating_sub(recorded) >= PENDING_CEILING_MS {
// Not renameable: nothing was ever decided about the name.
// The sink stopped answering, and a customer typing a second
// name would be sent at the same silence.
SignupState::Refused {
reason: format!("{NEVER_ANSWERED}: {why}"),
at_unix_ms: now,
refusal: Refusal::Final,
words: Some(words::COULD_NOT_CREATE.into()),
// The sink RAN — for a day — and never said what it did.
// Whether it got as far as the principal is exactly what
// nobody here can know, so an operator is sent to look.
owed: Owed::RefundAndTheNameMayBeSpent,
}
} else {
// Said once per change of reason, not on every tick.
let said = state.signups.waiting_on(&signup.vendor, &signup.session);
if SignupStore::says_pending(said.as_deref(), &why) {
skidbladnir::functional_status("monetize-server/signup", &signup.vendor, true, &format!("{}:{} stays pending: {why}", signup.vendor, signup.session));
eprintln!("monetize-server: signup {}:{} stays pending: {why}", signup.vendor, signup.session);
}
state.signups.note_waiting(&signup.vendor, &signup.session, Some(NotNow { why, maintenance }));
return SignupState::Pending;
}
}
},
};
state.signups.note_waiting(&signup.vendor, &signup.session, None);
if let Err(e) = state.signups.set(&signup.vendor, &signup.session, next.clone()) {
// The sink already ran. Saying so is all that is left, and it must be
// loud: this is the shape in which a created account loses its record.
skidbladnir::functional_status("monetize-server/signup", &signup.vendor, false, &format!("{}:{} decided but not stored: {e}", signup.vendor, signup.session));
eprintln!("monetize-server: signup {}:{} was decided {next:?} and the store refused it: {e}", signup.vendor, signup.session);
return next;
}
let ok = matches!(next, SignupState::Created { .. });
if ok {
// ★ The account's sign-in link, by mail (A11 fix 7): once, here, where the signup is
// decided; only for a payment this box minted with a welcome page.
if let SignupState::Created { username, .. } = &next {
match state.signups.welcome_of(&signup.vendor, &signup.session) {
Ok(Some(url)) => {
let site = estate.as_deref().and_then(|e| state.registry.site_of(e));
crate::services::signup::mail_sign_in_link(state, &signup.product, username, &signup.email, &url.replace("{session}", &signup.session), site.as_deref());
}
Ok(None) => {}
Err(e) => eprintln!("monetize-server: signup {}:{} has no welcome page to mail ({e})", signup.vendor, signup.session),
}
}
}
let line = match &next {
SignupState::Created { namespace, username, .. } => format!("{}:{} CREATED {username} in {namespace}", signup.vendor, signup.session),
SignupState::Refused { reason, .. } => format!("{}:{} REFUSED {reason}", signup.vendor, signup.session),
SignupState::Pending => unreachable!("pending returns before the store write"),
};
skidbladnir::functional_status("monetize-server/signup", &signup.vendor, ok, &line);
next
}
/// **The poll's half of the retry.** `Signup.Status` is read-only about the
/// STORE and still pushes a stuck signup forward, because a customer watching
/// the page should not wait for a poller tick. It is no longer the only thing
/// that does: [`settle_pending`] runs on every poller tick, for the customer
/// who closed the tab. The two meet in [`settle`], one sink call per session.
///
/// Idempotent in the sense the return page needs: a poll of a decided signup
/// reads one row and calls nothing.
pub fn status_with_retry(state: &crate::state::AppState, vendor: &str, session: &str) -> Result<Option<SignupRecord>, StoreError> {
let Some(rec) = state.signups.get(vendor, session)? else { return Ok(None) };
if !rec.state.is_pending() {
return Ok(Some(rec));
}
let next = settle(state, &rec.signup);
Ok(Some(SignupRecord { state: next, ..rec }))
}
/// **The background half of the retry: every PENDING signup, settled again.**
///
/// Until 2026-09-14 a pending signup moved only when the customer's return page
/// polled `Signup.Status`. A customer who paid and closed the tab during a
/// gunnar outage — or a clock-skew refusal, which gunnar clears by re-syncing —
/// left a paid signup stuck until somebody opened that exact URL. The poller
/// calls this on each tick, beside `resume_reference` for purchases, so the
/// interval is `poll_minutes` (5 on the front): slow enough that a gunnar which
/// is down costs one timed-out round per tick rather than a loop of them, fast
/// enough that a customer whose account could be made waits minutes and not a
/// day. The customer on the page still gets the 2 s path.
///
/// Through [`settle`], so the checks, the flight, the ceiling and the log lines
/// are the webhook's own. Returns how many signups it DECIDED this pass.
pub fn settle_pending(state: &crate::state::AppState) -> usize {
let pending = match state.signups.pending() {
Ok(v) => v,
Err(e) => {
eprintln!("monetize-server: signup store pending scan: {e}");
return 0;
}
};
pending.iter().filter(|rec| !settle(state, &rec.signup).is_pending()).count()
}
/// ★ **How long a paid account may settle before the buyer is told it is taking longer**
/// (PAID-WORDS): two minutes. A pulled product's main makes the account on its next call,
/// seconds; past this something is slow, and the buyer reads so, with the payment safe.
pub const SETTLE_BOUND_MS: i64 = 2 * 60 * 1000;
/// **A product's "not now" for a paid signup**: its words (the operator's) and whether it said,
/// by name, that it is in maintenance ([`SignupError::Maintenance`], RA4-20).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NotNow {
/// The product's words, for the operator; never the buyer's.
pub why: String,
/// The product said [`SignupError::Maintenance`].
pub maintenance: bool,
}
/// Pure: **the sentence a PENDING signup shows**: `maintenance` whether the product's last "not
/// now" was its maintenance, by name ([`SignupError::Maintenance`]); `waited_ms` since the
/// payment was recorded; `mailed` whether its sign-in link is mailed once the account is made.
pub fn pending_words(maintenance: bool, waited_ms: i64, mailed: bool) -> &'static str {
if maintenance {
words::MAINTENANCE
} else if waited_ms >= SETTLE_BOUND_MS {
if mailed {
words::TAKING_LONGER_MAILED
} else {
words::TAKING_LONGER
}
} else {
words::BEING_CREATED
}
}
/// ★ RA4-19: **the sentence a PENDING record sends AHEAD for the shop's silence**, and in how many
/// ms it is due: what the return page shows once that long has passed with no answer — the
/// settle bound's "taking longer" ([`SETTLE_BOUND_MS`], from the payment), or the product's
/// maintenance. `("", 0)` for any other state: the page keeps no bound and no words of its own.
pub fn words_ahead(store: &SignupStore, rec: &SignupRecord, now_ms: i64) -> (String, i64) {
let SignupState::Pending = &rec.state else { return (String::new(), 0) };
let (vendor, session) = (&rec.signup.vendor, &rec.signup.session);
let mailed = matches!(store.welcome_of(vendor, session), Ok(Some(_)));
let maintenance = store.not_now(vendor, session).is_some_and(|n| n.maintenance);
let waited = now_ms.saturating_sub(rec.recorded_at_unix_ms);
(pending_words(maintenance, waited.max(SETTLE_BOUND_MS), mailed).to_owned(), SETTLE_BOUND_MS.saturating_sub(waited).max(0))
}
/// **The sentence `Signup.Status` shows the customer for a record** at `now_ms`, and the
/// `reason` it may carry — see [`words`] for why these are not the operator's.
pub fn customer_words(store: &SignupStore, rec: &SignupRecord, now_ms: i64) -> (String, String) {
match &rec.state {
SignupState::Pending => {
let (vendor, session) = (&rec.signup.vendor, &rec.signup.session);
let mailed = matches!(store.welcome_of(vendor, session), Ok(Some(_)));
let w = pending_words(store.not_now(vendor, session).is_some_and(|n| n.maintenance), now_ms.saturating_sub(rec.recorded_at_unix_ms), mailed);
(w.to_owned(), String::new())
}
SignupState::Created { .. } => (words::READY.to_owned(), String::new()),
SignupState::Refused { words: Some(w), .. } => (w.clone(), w.clone()),
SignupState::Refused { reason, words: None, .. } => (reason.clone(), reason.clone()),
}
}
/// Why a rename did not happen. Every arm is a different sentence to a customer
/// who has already paid, which is why none of them is a `String`.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum RenameRefusal {
/// Nobody has heard of this `(vendor, session)`.
Unknown,
/// The record is not one a different name can finish: it is still pending,
/// or it was refused over the ORDER — a plan nobody sells, an amount that is
/// not the plan's price. The string says which.
NotRenameable(String),
/// The NEW name will not be accepted either. The customer types another.
Name(String),
/// The sink could not be asked. Ours, not theirs; the record is untouched
/// and the same request works when it comes back.
Unavailable(String),
}
/// **Finish a signup that was refused over its name, with a different name.**
///
/// The money is already at the vendor and the alternative is a refund by hand,
/// so this exists for exactly one shape of row: `Refused` with
/// [`Refusal::Name`]. Everything else is refused here rather than re-run —
/// re-running a FINAL refusal would charge the same customer's already-charged
/// card against an order that is still wrong.
///
/// It goes through the same [`refusal`] checks and the same sink as [`settle`],
/// in the same order, for the reason `settle` gives about having one writer: a
/// customer created by a rename must be created by the code that would have
/// created them the first time, including the log line.
///
/// **Idempotent per session.** A rename of a signup that is already CREATED
/// answers what it became and runs nothing — a return page that posts twice
/// (a double tap, a retry over a dropped connection) must not push a second
/// name at a sink that has already made an account.
pub fn rename(state: &crate::state::AppState, vendor: &str, session: &str, username: &str) -> Result<SignupRecord, RenameRefusal> {
let now = state.now() as i64;
// The same one-call-per-session rule as `settle`, taken BEFORE the row is
// read: a double-tapped rename is two sink calls for one payment otherwise,
// and the second can find the first one's principal and call it taken.
let Some(_flight) = state.signups.fly(vendor, session) else {
return Err(RenameRefusal::Unavailable(format!("{vendor}:{session} is already being settled by another call")));
};
let rec = match state.signups.get(vendor, session) {
Ok(Some(rec)) => rec,
Ok(None) => return Err(RenameRefusal::Unknown),
Err(e) => return Err(RenameRefusal::Unavailable(format!("signup store: {e}"))),
};
// **A refunded payment finishes no signup** (g-refund-verb): an operator
// who refunded a RenameOrRefund row gave the money back, and an account
// created against it afterwards would be an account nobody paid for. A
// refund merely REQUESTED counts too — the vendor may already have paid it.
match state.signups.refund_of(vendor, session) {
Ok(None) => {}
Ok(Some(_)) => {
return Err(RenameRefusal::NotRenameable(
"This payment has been refunded, so it cannot finish a signup any more. A new signup is a new payment.".into(),
))
}
Err(e) => return Err(RenameRefusal::Unavailable(format!("signup store: {e}"))),
}
match &rec.state {
// Already done. Answer what it became; touch nothing.
SignupState::Created { .. } => return Ok(rec),
SignupState::Pending => {
return Err(RenameRefusal::NotRenameable(
"this signup has not been decided yet; nothing has been refused and there is nothing to rename".into(),
))
}
// The CUSTOMER's words for the refusal, because the page shows this
// message: a product's refusal must not reach them as the product said it.
SignupState::Refused { refusal, .. } if !refusal.renameable() => {
return Err(RenameRefusal::NotRenameable(customer_words(&state.signups, &rec, state.now() as i64).1))
}
SignupState::Refused { .. } => {}
}
// The shape rule first, because it names the offending character, and then
// the sink — which is the only thing that knows what this deployment has
// already spoken for.
if let Some(why) = monetize_pay::signup::username_refusal(username) {
return Err(RenameRefusal::Name(why));
}
let estate = sited(state, &rec.signup, rec.estate.clone());
match state.signup_sink.name_verdict_on(&rec.signup.product, username, estate.as_deref()) {
Ok(NameVerdict::Free) => {}
Ok(NameVerdict::Taken(why)) => return Err(RenameRefusal::Name(why)),
Err(SignupError::Unavailable(why) | SignupError::Maintenance(why)) => return Err(RenameRefusal::Unavailable(why)),
// A sink that answers a REFUSAL to "is this name free" is answering a
// question nobody asked; take it as the refusal it is rather than
// pretending the name was free.
Err(other) => return Err(RenameRefusal::Name(other.to_string())),
}
let renamed = PaidSignup { username: username.to_owned(), ..rec.signup.clone() };
let next = match refusal(&plans_with(state, &renamed.plan), &renamed) {
Some((refusal, reason)) => SignupState::Refused { reason, at_unix_ms: now, refusal, words: None, owed: owed_for_checks(refusal) },
None => match state.signup_sink.paid_on(&renamed, estate.as_deref()) {
Ok(SignupOutcome::Created { namespace, username }) => SignupState::Created { namespace, username, at_unix_ms: now },
Err(SignupError::Refused(reason)) => SignupState::Refused {
reason,
at_unix_ms: now,
refusal: Refusal::Final,
words: Some(words::COULD_NOT_CREATE.into()),
// **The sink had already begun**: it creates the principal and
// then pushes the entitlement, so a refusal here leaves a name
// spent and money taken. See [`Owed`].
owed: Owed::RefundAndTheNameMayBeSpent,
},
Err(SignupError::NameTaken(reason)) => SignupState::Refused { reason, at_unix_ms: now, refusal: Refusal::Name, words: None, owed: Owed::RenameOrRefund },
// The record still says what it said; the customer tries again.
Err(SignupError::Unavailable(why) | SignupError::Maintenance(why)) => return Err(RenameRefusal::Unavailable(why)),
},
};
// **The row carries the new name whatever the outcome was**, because the
// name is what the customer asked for and the console shows it beside the
// refusal. A row left holding the old one would show an operator a name
// nobody now wants.
if let Err(e) = state.signups.rewrite(vendor, session, &renamed, next.clone()) {
skidbladnir::functional_status("monetize-server/signup", vendor, false, &format!("{vendor}:{session} renamed but not stored: {e}"));
eprintln!("monetize-server: signup {vendor}:{session} was renamed to {username} and the store refused it: {e}");
return Err(RenameRefusal::Unavailable(format!("signup store: {e}")));
}
let ok = matches!(next, SignupState::Created { .. });
let line = match &next {
SignupState::Created { namespace, username, .. } => {
format!("{vendor}:{session} RENAMED {:?} -> {username} CREATED in {namespace}", rec.signup.username)
}
SignupState::Refused { reason, .. } => format!("{vendor}:{session} RENAMED {:?} -> {username} REFUSED {reason}", rec.signup.username),
SignupState::Pending => unreachable!("a rename never leaves a signup pending"),
};
skidbladnir::functional_status("monetize-server/signup", vendor, ok, &line);
Ok(SignupRecord { signup: renamed, state: next, recorded_at_unix_ms: rec.recorded_at_unix_ms, estate: rec.estate.clone(), card: rec.card.clone() })
}
/// **Every CREATED paid signup, as the sale a tax return has to count.**
///
/// Until 2026-09-15 the tax report folded the ledger only, and a signup writes
/// no ledger row (it has no reference and no tenant until the sink answers —
/// see this module's header). So every paid signup was money taken and never
/// declared: the momsdeklaration read 0.00 SEK in every box after a real
/// 125.00 SEK signup.
///
/// The store IS the record of that money, keyed `(vendor, session)`, so the fold
/// reads it rather than inventing a ledger row: one sale per session, whatever
/// number of webhooks, polls or reconciler passes handed it up — a replay cannot
/// add a second. The reference is `signup:<vendor>:<session>` (traceable to the
/// vendor's dashboard), the date is when the money moved, the net is the
/// vendor's total minus the tax it collected, and the rate is the one that
/// charge actually carried. A REFUSED signup is not a sale: its money is owed
/// back by hand, which the refund makes true — and since 2026-09-18 that hand is
/// counted rather than assumed. See [`SignupStore::owed_back`] and [`Owed`].
///
/// **A CREATED signup refunded with `force`** ([`refund`], g-refund-verb, or
/// by the vendor) stays declared as charged, in the period it was paid in — a
/// return that may already be filed — and what went back is a correction
/// ([`monetize::tax::TaxSale::credit`]) dated when the refund was DONE, in
/// that period (stinky row 17, 2026-10-10): the share of the gross, its tax
/// the same fraction. A refused signup was never declared, so its refund
/// changes nothing.
pub fn tax_sales(store: &SignupStore, books: &monetize::Books) -> Result<Vec<monetize::tax::TaxSale>, StoreError> {
let refunded: HashMap<(String, String), (u64, i64)> = store
.export_refunds()?
.into_iter()
.filter_map(|(v, s, r)| match r {
SignupRefund::Done { amount, at_unix_ms, .. } => Some(((v, s), (amount.minor, at_unix_ms))),
_ => None,
})
.collect();
Ok(store
.export_all()?
.into_iter()
.filter(|rec| matches!(rec.state, SignupState::Created { .. }))
// An account at the shop sold nothing: no tax line.
.filter(|rec| rec.signup.vendor != ACCOUNT_VENDOR)
.flat_map(|rec| {
let s = rec.signup;
let net = monetize_pay::Money { minor: s.amount.minor.saturating_sub(s.tax.minor), currency: s.amount.currency };
let basis_points = if net.minor == 0 { 0 } else { ((u128::from(s.tax.minor) * 10_000 + u128::from(net.minor) / 2) / u128::from(net.minor)) as u32 };
let charged = monetize::tax::TaxSale {
reference: format!("signup:{}:{}", s.vendor, s.session),
at_unix_ms: s.paid_at_unix_ms.max(0) as u64,
net,
basis_points,
tax: s.tax,
// **The buyer's billing country** (stinky row 15, 2026-10-10):
// where the VAT is owed. The currency's market is only the
// fallback for a row stored before the vendor's country was
// kept, or a vendor that does not say — EUR is FI's currency
// and every other euro country's too.
customer_country: s
.customer_country
.as_deref()
.map(str::trim)
.filter(|c| c.len() == 2 && c.is_ascii())
.map(str::to_ascii_uppercase)
.unwrap_or_else(|| s.amount.currency.market().country().to_owned()),
customer_tax_id: None,
// The vendor's fee on a signup is not read back yet; `None` is
// "not reported", never zero.
fee: None,
fee_country: Some(books.country_of(&s.vendor)),
cost: None,
cost_country: None,
credit: false,
};
let back = refunded.get(&(s.vendor.clone(), s.session.clone())).and_then(|(back, at)| charged.correction((*at).max(0) as u64, 0, *back));
std::iter::once(charged).chain(back)
})
.collect())
}
/// Why [`refund`] or [`settle_by_hand`] did not do it. Each arm is its own
/// gRPC code (`services::signup`), because an operator does something
/// different about each.
#[derive(Debug, PartialEq, Eq)]
pub enum RefundRefusal {
/// The store holds no such session.
Unknown,
/// Not a row this verb may act on — pending, created without `force`,
/// already refunded or settled, its vendor not configured — or the vendor
/// said no. The sentence says which, and what to do.
NotRefundable(String),
/// The request itself is wrong: more than was paid, no reason given.
Invalid(String),
/// The vendor or the store did not answer; the same call again is safe.
Unavailable(String),
}
/// **Give a signup's money back, through the vendor that took it** —
/// `SignupAdmin.Refund` (g-refund-verb, 2026-09-28).
///
/// Until this existed a customer charged for a signup that was then refused
/// (name taken, grant failed) could only be refunded by hand in the vendor's
/// dashboard, and the owed-back alarm could never go green.
///
/// # What it acts on, and what `force` means
///
/// A REFUSED signup — every row [`SignupStore::owed_back`] lists. A PENDING
/// one never: it may still become an account, and refunding it would make an
/// account nobody paid for. A CREATED one only with `force` — the customer's
/// right of withdrawal, a goodwill refund: the money goes back and the
/// ACCOUNT STAYS (taking it away is `Entitlement.Set`, by hand, or nonpay's purge);
/// [`tax_sales`] stops declaring what was refunded.
///
/// # Order, and why
///
/// 1. Every refusal first — the row, the amount, the vendor — so a refusal
/// writes nothing and calls nobody.
/// 2. [`SignupRefund::Requested`] committed. From here the store says "we may
/// have asked", which is true.
/// 3. The vendor asked, with [`refund_key`]'s key.
/// 4. [`SignupRefund::Done`] committed.
///
/// A `Requested` row found at step 2 is a refund whose answer was never
/// written down (a lost reply, a crash): it is asked AGAIN with its RECORDED
/// amount, which is the same key, and the vendor answers the first refund. A
/// `Done` one is answered and nobody is called. One call per session at a
/// time: it takes the same flight as the sink ([`SignupStore::fly`]).
///
/// A vendor that says NO (a decline, no record of the payment) leaves the row
/// `Requested` — nothing here can know it did not half-happen — and the
/// sentence sends the operator to the dashboard and [`settle_by_hand`].
pub fn refund(
state: &crate::state::AppState,
vendor: &str,
session: &str,
amount_minor: u64,
reason: &str,
force: bool,
) -> Result<SignupRefund, RefundRefusal> {
let reason = reason.trim();
if reason.is_empty() {
return Err(RefundRefusal::Invalid("a refund names its reason (the operator's words, recorded with it)".into()));
}
let Some(_flight) = state.signups.fly(vendor, session) else {
return Err(RefundRefusal::Unavailable(format!("{vendor}:{session} is being settled by another call; ask again")));
};
let store = |e: StoreError| RefundRefusal::Unavailable(format!("signup store: {e}"));
let rec = state.signups.get(vendor, session).map_err(store)?.ok_or(RefundRefusal::Unknown)?;
let paid = rec.signup.amount;
let (amount, requested_at, forced) = match state.signups.refund_of(vendor, session).map_err(store)? {
Some(done @ SignupRefund::Done { .. }) => {
let SignupRefund::Done { amount, .. } = &done else { unreachable!() };
if amount_minor != 0 && amount_minor != amount.minor {
return Err(RefundRefusal::NotRefundable(format!(
"{vendor}:{session} was already refunded {}; one refund per payment through this verb (ask for 0 or {} to read it back)",
amount.major(),
amount.minor
)));
}
return Ok(done);
}
Some(SignupRefund::SettledByHand { note, .. }) => {
return Err(RefundRefusal::NotRefundable(format!("{vendor}:{session} was settled by hand ({note}); nothing is owed through this verb")))
}
// **The retry.** The recorded amount, whatever was asked now: another
// amount would be another key, and a second refund.
Some(SignupRefund::Requested { amount, at_unix_ms, forced, .. }) => {
if amount_minor != 0 && amount_minor != amount.minor {
return Err(RefundRefusal::NotRefundable(format!(
"a refund of {} is already REQUESTED for {vendor}:{session} and its answer was never recorded; finish it first \
(ask for 0 or {}), or check the vendor's dashboard and Settle",
amount.major(),
amount.minor
)));
}
(amount, at_unix_ms, forced)
}
None => {
match &rec.state {
SignupState::Pending => {
return Err(RefundRefusal::NotRefundable(format!(
"{vendor}:{session} is PENDING: it may still become an account, so it is not owed back; nothing was refunded"
)))
}
SignupState::Created { namespace, .. } if !force => {
return Err(RefundRefusal::NotRefundable(format!(
"{vendor}:{session} is CREATED (namespace {namespace:?}): the customer has the account they paid for. \
To refund it anyway pass force — the account stays; remove it with Entitlement by hand"
)))
}
SignupState::Created { .. } | SignupState::Refused { .. } => {}
}
let amount = if amount_minor == 0 { paid } else { monetize_pay::Money { minor: amount_minor, currency: paid.currency } };
if amount.minor > paid.minor {
return Err(RefundRefusal::Invalid(format!("{} is more than the {} {vendor}:{session} paid", amount.major(), paid.major())));
}
if amount.minor == 0 {
return Err(RefundRefusal::NotRefundable(format!("{vendor}:{session} paid nothing; there is nothing to refund")));
}
(amount, state.now() as i64, matches!(rec.state, SignupState::Created { .. }))
}
};
// The vendor BEFORE the record: a vendor this server does not have is a
// refusal that must leave nothing behind.
let Some(pay) = state.registry.find_vendor(vendor) else {
return Err(RefundRefusal::NotRefundable(format!(
"vendor {vendor:?} is not configured on this server (linked: {:?}); refund {vendor}:{session} at that vendor by hand, then Settle",
state.registry.vendor_names()
)));
};
state
.signups
.claim_refund(vendor, session, &SignupRefund::Requested { amount, reason: reason.to_owned(), at_unix_ms: requested_at, forced })
.map_err(store)?;
let key = refund_key(session, amount.minor);
let handle = monetize_pay::ChargeHandle { vendor: vendor.to_owned(), id: session.to_owned() };
let answer = pay.refund(&handle, amount);
let line = |what: &str| format!("{vendor}:{session} REFUND {} ({key}) {what}; reason: {reason}", amount.major());
match answer {
Ok(_) => {
let done = SignupRefund::Done {
refund_id: key.clone(),
amount,
reason: reason.to_owned(),
requested_at_unix_ms: requested_at,
at_unix_ms: state.now() as i64,
forced,
};
if let Err(e) = state.signups.put_refund(vendor, session, &done) {
// The vendor refunded; the record says Requested. The next call
// re-asks under the same key and completes it.
let l = line(&format!("DONE at the vendor and NOT recorded ({e}); the same call again records it"));
skidbladnir::functional_status("monetize-server/signup", vendor, false, &l);
eprintln!("monetize-server: signup {l}");
return Err(RefundRefusal::Unavailable(format!("the vendor refunded and the store refused the record: {e}; ask again")));
}
let l = line("DONE");
skidbladnir::functional_status("monetize-server/signup", vendor, true, &l);
eprintln!("monetize-server: signup {l}");
Ok(done)
}
Err(e) => {
let l = line(&format!("NOT CONFIRMED, stays REQUESTED: {e}"));
skidbladnir::functional_status("monetize-server/signup", vendor, false, &l);
eprintln!("monetize-server: signup {l}");
Err(match e {
monetize_pay::PayError::Unavailable(why) => RefundRefusal::Unavailable(format!(
"the vendor did not answer ({why}); the refund of {} stays REQUESTED and the same call again finishes it without refunding twice",
amount.major()
)),
other => RefundRefusal::NotRefundable(format!(
"the vendor did not refund {vendor}:{session} ({other}); it stays REQUESTED. Check the vendor's dashboard: refund it there if \
it is not, then Settle with a note"
)),
})
}
}
}
/// **Close out a refused signup refunded BY HAND** at the vendor's dashboard
/// — `SignupAdmin.Settle`. It moves no money; it records the operator's
/// `note` and takes the row off the owed-back queue. Only a REFUSED row, and
/// one with no refund recorded or one still `Requested` (the vendor never
/// confirmed it, and the operator has looked); never over a `Done` one.
pub fn settle_by_hand(state: &crate::state::AppState, vendor: &str, session: &str, note: &str) -> Result<SignupRefund, RefundRefusal> {
let note = note.trim();
if note.is_empty() {
return Err(RefundRefusal::Invalid("a settle says what was done (the vendor's refund id, who did it)".into()));
}
let Some(_flight) = state.signups.fly(vendor, session) else {
return Err(RefundRefusal::Unavailable(format!("{vendor}:{session} is being settled by another call; ask again")));
};
let store = |e: StoreError| RefundRefusal::Unavailable(format!("signup store: {e}"));
let rec = state.signups.get(vendor, session).map_err(store)?.ok_or(RefundRefusal::Unknown)?;
if rec.state.owed().is_none() {
return Err(RefundRefusal::NotRefundable(format!("{vendor}:{session} is not owed back (it is not REFUSED); there is nothing to settle")));
}
match state.signups.refund_of(vendor, session).map_err(store)? {
None | Some(SignupRefund::Requested { .. }) => {}
Some(other) => return Err(RefundRefusal::NotRefundable(format!("{vendor}:{session} is already settled: {other:?}"))),
}
let settled = SignupRefund::SettledByHand { note: note.to_owned(), at_unix_ms: state.now() as i64 };
state.signups.put_refund(vendor, session, &settled).map_err(store)?;
let l = format!("{vendor}:{session} SETTLED BY HAND by the operator: {note}");
skidbladnir::functional_status("monetize-server/signup", vendor, true, &l);
eprintln!("monetize-server: signup {l}");
Ok(settled)
}
/// Why [`resend_welcome`] sent nothing.
#[derive(Debug, PartialEq, Eq)]
pub enum ResendRefusal {
/// The store holds no such session.
Unknown,
/// Not CREATED, or the sink cannot send a welcome (no relay, no address).
NotSendable(String),
/// The store or the relay did not answer; asking again is fine.
Unavailable(String),
}
/// **The operator's door: send a CREATED signup's welcome mail again.**
///
/// Measured 2026-09-15: a paid signup was created and its welcome mail was
/// refused by the relay (a transport that never negotiated STARTTLS). The
/// account existed; the mail was the one thing missing, and nothing could send
/// it again short of a second payment. This is that door.
///
/// Only a CREATED record, and with the username the product SETTLED on (a
/// renamed signup's welcome names the account it has, not the one it asked
/// for). Idempotence and the per-attempt audit record are the sink's — it is
/// the one that knows whether its mail went out — and this adds the operator's
/// line to the log and to `functional_status`.
pub fn resend_welcome(state: &crate::state::AppState, vendor: &str, session: &str, force: bool) -> Result<String, ResendRefusal> {
let rec = match state.signups.get(vendor, session) {
Ok(Some(rec)) => rec,
Ok(None) => return Err(ResendRefusal::Unknown),
Err(e) => return Err(ResendRefusal::Unavailable(format!("signup store: {e}"))),
};
let SignupState::Created { username, .. } = &rec.state else {
return Err(ResendRefusal::NotSendable(format!("{vendor}:{session} is not CREATED ({:?}); there is no account to welcome anybody to", rec.state)));
};
let as_created = PaidSignup { username: username.clone(), ..rec.signup.clone() };
let out = state.signup_sink.resend_welcome(&as_created, force);
let (ok, line) = match &out {
Ok(what) => (true, format!("{vendor}:{session} WELCOME RESENT by the operator (force={force}): {what}")),
Err(e) => (false, format!("{vendor}:{session} WELCOME NOT RESENT by the operator (force={force}): {e}")),
};
skidbladnir::functional_status("monetize-server/signup", vendor, ok, &line);
eprintln!("monetize-server: signup {line}");
out.map_err(|e| match e {
SignupError::Unavailable(why) | SignupError::Maintenance(why) => ResendRefusal::Unavailable(why),
SignupError::Refused(why) | SignupError::NameTaken(why) => ResendRefusal::NotSendable(why),
})
}
#[cfg(test)]
pub(crate) mod tests;
/// **A paid sign-up's line on the vendor's page** (A16, owner 2026-10-23: never a plan id):
/// `Holger — 5.0 GiB storage, 1 month — 100.00 SEK`.
pub fn signup_checkout_words(product: &str, plan: &crate::config::SignupPlan) -> String {
let mut name = product.trim().to_owned();
if let Some(first) = name.get_mut(0..1) {
first.make_ascii_uppercase();
}
let period = match plan.period_days {
0 => String::new(),
d if d % 30 == 0 && d / 30 == 1 => ", 1 month".to_owned(),
d if d % 30 == 0 && d <= 360 => format!(", {} months", d / 30),
365 => ", 12 months".to_owned(),
d => format!(", {d} days"),
};
format!("{name} — {} storage{period} — {}", monetize_product::bytes(plan.cap_bytes), plan.price().major())
}
/// **The meter a signup's `cap_bytes` lands on** — the private sink's
/// `DEFAULT_CAP_METER` (`monetize-signup-impl`), gunnar's pack quota.
pub const SIGNUP_CAP_METER: &str = "pack_bytes";
/// The newest CREATED signup per `(product, tenant)`, the tenant being the
/// namespace the sink made. One row per vendor session in the store, so a
/// replayed webhook, a poll or a reconciler pass cannot add a second.
fn created_signups(store: &SignupStore, product: &str) -> Result<Vec<(SignupRecord, String, i64)>, StoreError> {
let mut newest: std::collections::BTreeMap<String, (SignupRecord, String, i64)> = std::collections::BTreeMap::new();
for rec in store.export_all()? {
if !product.is_empty() && rec.signup.product != product {
continue;
}
let SignupState::Created { namespace, at_unix_ms, .. } = &rec.state else { continue };
let (tenant, at) = (namespace.clone(), *at_unix_ms);
let key = format!("{}\u{0}{tenant}", rec.signup.product);
match newest.get(&key) {
Some((old, _, _)) if old.signup.paid_at_unix_ms >= rec.signup.paid_at_unix_ms => {}
_ => {
newest.insert(key, (rec, tenant, at));
}
}
}
Ok(newest.into_values().collect())
}
/// **The entitlement a CREATED paid signup gave its tenant**, as monetize's own
/// signed fact — the shape the sink pushed to the product (tenant, the plan's
/// `pack_bytes` cap, paid-until = paid_at + period, `payment:<vendor>:<ref>`).
///
/// Until 2026-09-15 a signup wrote no entitlement row (it writes no ledger row
/// at all — see this module's header), so `Entitlement.List` did not know the
/// cap the appliance was enforcing and the console listed a paying tenant as
/// "no cap". Folded from the store at read time rather than written at settle,
/// so a signup created before this fix reads correctly too. A plan the config
/// no longer names contributes nothing: a cap is never invented.
pub fn signup_facts(state: &crate::state::AppState, product: &str) -> Result<Vec<monetize_product::EntitlementFact>, StoreError> {
let now = state.now();
facts_at(state, product, Some(now))
}
/// **The fact the SINK PUSHED, exactly as it pushed it** — the same fold, with
/// the state pinned to `Paid` instead of re-derived at `now`.
///
/// `monetize_signup_impl::GunnarSignup::fact_for` says why that pin is the
/// truth and not a shortcut: *"at signup `paid_until` is always in the future,
/// and `derive_state` answers Paid for every policy there is"*. So this is
/// what the appliance was told, and it is what [`crate::poller`] seeds the
/// ledger with when it adopts a signup-granted tenant — the ladder can only
/// MOVE a tenant off a state it knows they were last put in, and re-deriving
/// it at `now` would hand the ladder the answer it is supposed to compute and
/// leave the appliance holding a fact nobody ever replaced.
pub fn granted_facts(state: &crate::state::AppState, product: &str) -> Result<Vec<monetize_product::EntitlementFact>, StoreError> {
facts_at(state, product, None)
}
/// The shared fold. `now = None` pins the state to `Paid` — see [`granted_facts`].
fn facts_at(state: &crate::state::AppState, product: &str, now: Option<u64>) -> Result<Vec<monetize_product::EntitlementFact>, StoreError> {
Ok(created_signups(&state.signups, product)?
.into_iter()
.filter_map(|(rec, tenant, _)| {
let plan = plan_named(state, &rec.signup.plan)?;
let meter = state.signup_plans.cap_meter_of(product);
let s = &rec.signup;
let tenant = monetize_product::TenantId(tenant);
let paid_at = s.paid_at_unix_ms.max(0) as u64;
if is_account_plan(&plan) {
// An account at the shop: Free, no end, no room.
return Some(account_fact(state.signer_for(&s.product), &s.product, tenant, paid_at, meter, &s.vendor));
}
let paid_until = paid_at + u64::from(plan.period_days) * 24 * 60 * 60 * 1000;
let caps = std::collections::BTreeMap::from([(meter, plan.cap_bytes)]);
let reference = monetize::transaction::reference_of(&s.product, &tenant, paid_at, &caps);
Some(state.signer_for(&s.product).sign_fact(monetize_product::EntitlementFact {
tenant,
plan: reference.clone(),
state: match now {
Some(now) => monetize::derive_state(&state.terms.read().unwrap_or_else(|e| e.into_inner()).policy, Some(paid_until), now),
None => monetize_product::State::Paid,
},
paid_until_unix_ms: Some(paid_until),
caps,
source: format!("payment:{}:{reference}", s.vendor),
signature: Vec::new(),
issued_unix_ms: None,
issued_signature: Vec::new(),
}))
})
.collect())
}
/// **The ledger's facts, with every signup-granted tenant the ledger does not
/// hold added.** The ledger wins for a tenant it has a fact for: that is a
/// later purchase or an operator's hand, and either replaced the signup's.
pub fn with_signup_facts(
state: &crate::state::AppState,
product: &str,
mut facts: Vec<monetize_product::EntitlementFact>,
) -> Result<Vec<monetize_product::EntitlementFact>, StoreError> {
for f in signup_facts(state, product)? {
if !facts.iter().any(|held| held.tenant == f.tenant) {
facts.push(f);
}
}
Ok(facts)
}
/// **Every CREATED paid signup as a "Recently bought" line** — one per vendor
/// session, `signup:<vendor>:<session>` (the reference [`tax_sales`] declares),
/// price = the vendor's total minus the tax it collected, the plan's cap as both
/// the caps and the delta, `granted`, drawn. A REFUSED signup is not a sale.
/// `product` empty = every product.
pub fn signup_sales(state: &crate::state::AppState, product: &str) -> Result<Vec<monetize_proto::Sale>, StoreError> {
Ok(created_signups(&state.signups, product)?
.into_iter()
// An account at the shop sold nothing: no money, no line.
.filter(|(rec, _, _)| rec.signup.vendor != ACCOUNT_VENDOR)
.map(|(rec, tenant, at)| {
let s = rec.signup;
let caps: std::collections::BTreeMap<String, u64> = plan_named(state, &s.plan)
.map(|p| std::collections::BTreeMap::from([(state.signup_plans.cap_meter_of(&s.product), p.cap_bytes)]))
.unwrap_or_default();
let net = monetize_pay::Money { minor: s.amount.minor.saturating_sub(s.tax.minor), currency: s.amount.currency };
monetize_proto::Sale {
reference: format!("signup:{}:{}", s.vendor, s.session),
product: s.product.clone(),
tenant,
price: Some(crate::convert::money_to_pb(net)),
// Nothing was bought at a cloud for a signup: the room was on hand.
cost: Some(crate::convert::money_to_pb(monetize_pay::Money { minor: 0, currency: s.amount.currency })),
// The vendor's fee on a signup is not read back yet: absent, never zero.
fee: None,
// The tax the VENDOR collected on it (Stripe's amount_tax), per
// order; the rate is what that figure is of the net.
tax: Some(crate::convert::money_to_pb(s.tax)),
tax_from_vendor: true,
vat_basis_points: if net.minor > 0 { ((s.tax.minor as u128 * 10_000 + net.minor as u128 / 2) / net.minor as u128) as u32 } else { 0 },
delta: caps.clone(),
caps,
started_unix_ms: s.paid_at_unix_ms.max(0) as u64,
at_unix_ms: at.max(0) as u64,
steps: 0,
drawn: true,
outcome: "granted".into(),
reason: String::new(),
needs_you: false,
// The session's own mode marker (Stripe's `cs_live_`/`cs_test_`).
livemode: monetize_pay::livemode_of(&s.vendor, &s.session),
// The buyer's country as the vendor returned it (Stripe Tax).
customer_country: s.customer_country.clone().unwrap_or_default(),
}
})
.collect())
}
/// **The entitlement a paid signup grants**, SIGNED — what a signup guest
/// delivers to the product (`monetize_product_wasm::signup`). The same shape
/// [`granted_facts`] folds for a created signup: the plan's cap on
/// [`SIGNUP_CAP_METER`], paid until `paid_at + period_days`, the state derived
/// by the deployment's policy at `now`. `Err` when the plan is not one this
/// deployment sells.
pub fn signup_fact(
plans: &std::collections::BTreeMap<String, crate::config::SignupPlan>,
policy: &monetize::Policy,
signer: &monetize::Signer,
s: &PaidSignup,
now_ms: u64,
cap_meter: &str,
) -> Result<monetize_product::EntitlementFact, String> {
let plan = plans.get(&s.plan).ok_or_else(|| format!("plan {:?} is not in this deployment's [signups.<product>.plans]", s.plan))?;
let tenant = monetize_product::TenantId(s.username.clone());
let paid_at = s.paid_at_unix_ms.max(0) as u64;
if is_account_plan(plan) {
let _ = (policy, now_ms);
return Ok(account_fact(signer, &s.product, tenant, paid_at, cap_meter.to_owned(), &s.vendor));
}
let paid_until = paid_at + u64::from(plan.period_days) * 24 * 60 * 60 * 1000;
let caps = std::collections::BTreeMap::from([(cap_meter.to_owned(), plan.cap_bytes)]);
let reference = monetize::transaction::reference_of(&s.product, &tenant, paid_at, &caps);
Ok(signer.sign_fact(monetize_product::EntitlementFact {
tenant,
plan: reference.clone(),
state: monetize::derive_state(policy, Some(paid_until), now_ms),
paid_until_unix_ms: Some(paid_until),
caps,
source: format!("payment:{}:{reference}", s.vendor),
signature: Vec::new(),
issued_unix_ms: None,
issued_signature: Vec::new(),
}))
}
/// **The fact an account at the shop holds** ([`ACCOUNT_VENDOR`]): Free, with
/// no end — nothing was bought, so nothing lapses and nothing is deleted for
/// lack of payment — and [`NO_ROOM_CAP`] on the cap meter: no room here.
pub(crate) fn account_fact(signer: &monetize::Signer, product: &str, tenant: monetize_product::TenantId, made_at: u64, meter: String, vendor: &str) -> monetize_product::EntitlementFact {
let caps = std::collections::BTreeMap::from([(meter, NO_ROOM_CAP)]);
let reference = monetize::transaction::reference_of(product, &tenant, made_at, &caps);
signer.sign_fact(monetize_product::EntitlementFact {
tenant,
plan: reference.clone(),
state: monetize_product::State::Free,
paid_until_unix_ms: None,
caps,
source: format!("account:{vendor}:{reference}"),
signature: Vec::new(),
issued_unix_ms: None,
issued_signature: Vec::new(),
})
}
#[cfg(test)]
mod a16_checkout_words {
/// ★ A16 (owner 2026-10-23: a buyer never sees monetize's words): a paid sign-up's line on the
/// vendor's page names the product, its room and its period, and the price — never a plan id.
#[test]
fn a_sign_ups_checkout_line_names_the_product_and_the_plan() {
let plan = crate::config::SignupPlan { amount_minor: 10_000, currency: monetize_pay::Currency::Sek, cap_bytes: 5 << 30, period_days: 30 };
assert_eq!(super::signup_checkout_words("holger", &plan), "Holger — 5.0 GiB storage, 1 month — 100.00 SEK");
let six = crate::config::SignupPlan { amount_minor: 59_400, cap_bytes: 10 << 30, period_days: 180, ..plan };
assert_eq!(super::signup_checkout_words("gunnar", &six), "Gunnar — 10.0 GiB storage, 6 months — 594.00 SEK");
}
}
#[cfg(test)]
mod a16_product_mail {
/// ★ A16 (owner 2026-10-23: a buyer never sees monetize): **a product's own relay and sender
/// carry its mail** where they are fed (`secret.smtp_url.<product>` + `secret.smtp_from.<product>`,
/// pmo `[products.<p>.mail]`); else the host's shared one.
#[test]
fn a_products_mail_goes_from_its_own_sender() {
let fed = |name: &str| match name {
"smtp_url" => Some("smtp://relay.mock.invalid".to_owned()),
"smtp_from" => Some("shop@mock.invalid".to_owned()),
"smtp_url.holger" => Some("smtp://holger-relay.mock.invalid".to_owned()),
"smtp_from.holger" => Some("Holger <holger@mock.invalid>".to_owned()),
_ => None,
};
assert_eq!(super::relay_names(&fed, Some("holger")), (Some("smtp://holger-relay.mock.invalid".to_owned()), Some("Holger <holger@mock.invalid>".to_owned())));
assert_eq!(super::relay_names(&fed, Some("gunnar")), (Some("smtp://relay.mock.invalid".to_owned()), Some("shop@mock.invalid".to_owned())));
assert_eq!(super::relay_names(&fed, None).1, Some("shop@mock.invalid".to_owned()));
}
}