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

nordisk/monetize

crates/monetize-pay/src/signup.rs

//! **A paid signup: the customer did not exist before the money moved.**
//!
//! Every other path in this crate starts with a tenant that is already there —
//! `ChargeRequest::reference` is `<product>/<namespace>/<period>`, and the
//! namespace is a thing the operator or the product already knows. A visitor on
//! gunnar.rs who clicks *Buy 100:- 10GB one month* is not that. There is no
//! tenant, no ledger reference held open, nothing for
//! [`crate::CallbackEvent`] to name: the vendor's hosted page collected an
//! e-mail and a wanted username, and the FIRST thing this deployment ever
//! learns about that person arrives in the webhook.
//!
//! So the signup is its own outcome of `parse_callback`
//! ([`crate::PayError::Signup`]) and its own seam ([`SignupSink`]), rather than
//! a second meaning bolted onto the transaction. The two are genuinely
//! different events:
//!
//! ```text
//!   purchase   reference held open ──▶ callback ──▶ resume the attempt   (a tenant exists)
//!   signup     nothing held        ──▶ callback ──▶ SignupSink::paid     (a tenant is BORN)
//! ```
//!
//! # What is open here and what is not
//!
//! This crate holds the SHAPE — what a paid signup is, what a sink may answer,
//! and what a username is allowed to look like. It holds no vendor: the code
//! that verifies a Stripe Checkout session and turns it into a [`PaidSignup`]
//! is `monetize-pay-impl`'s, behind the same `parse_callback` every vendor
//! implements. It holds no creator either: the open build's sink logs and says
//! Created (`monetize_server::signup::LoudSink`), and the one that actually
//! mints a gunnar principal and its entitlement is private.

use crate::{Market, Money};

/// **What the vendor's checkout page collected, once the money is real.**
///
/// A provider builds one of these inside `parse_callback`, AFTER the signature
/// verified, and hands it back as [`crate::PayError::Signup`]. Everything in it
/// is the vendor's own words about the vendor's own session — this crate
/// invents nothing and validates nothing here, because a field that arrived
/// wrong must be refused BY NAME further up (`SignupError::Refused`), where the
/// refusal can be stored and shown, not silently normalised at the edge.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct PaidSignup {
    /// The vendor that verified it — the `<vendor>` of `POST /callback/<vendor>`.
    pub vendor: String,
    /// **The vendor's handle for the checkout session** (`cs_…` at Stripe), and
    /// with [`PaidSignup::vendor`] the idempotency key of the whole seam. It is
    /// the vendor's, not ours, on purpose: it is the one identifier that exists
    /// on BOTH sides of the redirect, so the return page can poll for an
    /// outcome (`Signup.Status`) using a value the vendor put in its own
    /// success URL, before this deployment has heard anything at all.
    pub session: String,
    /// The vendor's reference for the payment itself (a payment-intent id, an
    /// order number) — what an operator types into the vendor's dashboard to
    /// find this money. Not a ledger reference: a signup holds none.
    pub reference: String,
    /// What the customer typed. Kept because the sink has to reach them; it is
    /// never returned by `Signup.Status`, which a return page polls unauthenticated.
    pub email: String,
    /// The username the customer asked for. **Not yet validated** — see
    /// [`username_refusal`].
    pub username: String,
    /// Which product the plan belongs to (`gunnar`).
    pub product: String,
    /// The plan the checkout sold, as the deployment's config names it
    /// (`gunnar/10gb-1m`). A plan no config names is a refusal by name, not a
    /// default.
    pub plan: String,
    /// **What the vendor says it took, tax included.** This is the figure on the
    /// customer's statement, not the deployment's revenue — see
    /// [`PaidSignup::tax`] for the part of it that is the state's. What the
    /// configured plan price is checked against is `amount - tax`.
    pub amount: Money,
    /// **The tax inside [`PaidSignup::amount`], as the VENDOR calculated it.**
    ///
    /// Zero when the checkout charged none, which is what every signup carried
    /// before a tax registration existed — so a deployment that collects no tax
    /// reads exactly as it did before this field.
    ///
    /// It is carried and not derived, because the two figures answer two
    /// different questions and only the vendor knows both. The plan price is
    /// checked against the NET, so that switching a checkout to collect 25 % VAT
    /// stops reading as a page whose price drifted — which is what it looked
    /// like the first time it was tried, and the refusal is final and never
    /// retried, so it takes a customer's money and gives them nothing. The
    /// momsdeklaration then needs the tax itself for box 10 and the net for 05.
    /// Deriving either from a rate would put a rounding rule here that the
    /// vendor has already applied to the money.
    pub tax: Money,
    /// When the money moved, by the vendor's clock. `i64` and not `u64`
    /// because it crosses the wire as proto3's `int64` and back, and one
    /// signed/unsigned round trip is one place for a timestamp to become
    /// enormous.
    pub paid_at_unix_ms: i64,
    /// **The customer's billing country, as the VENDOR collected it** (ISO
    /// 3166-1 alpha-2, upper case: Stripe's `customer_details.address.country`).
    /// It is where the sale is taxed (stinky monetize-server #15): the
    /// currency says nothing about it — every EUR buyer is not Finnish.
    /// `None`: the vendor did not say, or a row written before the field; the
    /// tax line then falls back to the currency's market and says so.
    #[serde(default)]
    pub customer_country: Option<String>,
}

/// What a [`SignupSink`] did.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum SignupOutcome {
    /// The customer exists now. `namespace` is what the product calls them —
    /// which need not equal `username`, and is carried back rather than assumed
    /// so the return page can log the right principal in.
    Created { namespace: String, username: String },
}

/// Why a sink did not create the customer.
///
/// The split is the same one [`crate::PayError::Unverified`] and
/// [`crate::PayError::Ignored`] make, for the same reason: the two must be
/// ANSWERED differently. One is final and the customer must be told; the other
/// is this deployment's own fault and must be tried again.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum SignupError {
    /// **Final, and the reason is shown to a human.** A plan nobody sells, an
    /// amount that is not the plan's price, a product that would not take the
    /// entitlement. The money was taken and the account will not appear, so the
    /// string is not a log line — it is what an operator reads off the console
    /// and what a refund conversation starts from. Never retried.
    Refused(String),
    /// **Refused, and ANOTHER NAME WOULD FIX IT**: the name is taken, reserved,
    /// or not a name.
    ///
    /// Its own variant rather than a `Refused` whose string a caller matches on,
    /// and the difference between the two is the difference between a refund and
    /// a customer. [`SignupError::Refused`] ends the day — the money is at the
    /// vendor and an operator moves it back by hand. This is the same refusal
    /// with a way out: the return page offers ONE text field, the customer types
    /// something else, and `Signup.Rename` re-runs the sink against the money
    /// that is already there. The engine stores which of the two it was
    /// (`SignupStatusResponse.Refusal` on the wire) because a page deciding that
    /// by reading prose breaks the day somebody improves a sentence.
    ///
    /// Still final for THIS name, and never retried on its own: nothing about a
    /// taken name gets better by waiting.
    NameTaken(String),
    /// **Ours, not theirs.** The product was unreachable, the store was locked,
    /// the identity key was not readable. The customer paid and nothing is
    /// wrong with what they asked for, so the signup stays PENDING and is tried
    /// again — a refusal here would turn a network blip into a lost customer
    /// with a charge on their card.
    Unavailable(String),
    /// ★ **Ours, not theirs, and the PRODUCT says by name that it is in maintenance**
    /// (RA4-20; PAID-WORDS). Pending and tried again, as [`SignupError::Unavailable`]; the one
    /// "not now" a buyer who has paid reads as "down for maintenance". Any other "not now" (a
    /// timeout, a box that does not answer, an account queued for the product's main) is
    /// `Unavailable`, a settle in the words of a settle. Its own variant, never a prefix of
    /// `Unavailable`'s words: a page or a host deciding by reading prose breaks the day somebody
    /// improves a sentence.
    Maintenance(String),
}

impl std::fmt::Display for SignupError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SignupError::Refused(r) => write!(f, "signup refused: {r}"),
            SignupError::NameTaken(r) => write!(f, "signup refused, the name: {r}"),
            SignupError::Unavailable(r) => write!(f, "signup sink unavailable: {r}"),
            SignupError::Maintenance(r) => write!(f, "signup sink in maintenance: {r}"),
        }
    }
}
impl std::error::Error for SignupError {}

/// **The seam that turns money into a customer.**
///
/// Sync, like [`crate::Payment`], and for the same reason: the caller
/// owns the runtime and an impl that needs async confines it privately.
///
/// It is a separate trait from `Product` deliberately. A product plugin reports
/// usage and receives a signed entitlement for a tenant that EXISTS; creating
/// the principal, its passkey invite and its first entitlement is a different
/// act with different credentials, and giving `Product` a `create_tenant` verb
/// would put "make a person" behind every plugin that only wanted to report
/// bytes.
///
/// # It must be idempotent on `(vendor, session)`
///
/// The engine's store already answers a repeated webhook without calling here
/// again, but a vendor retry that races the first delivery can still arrive
/// twice, and the second must not create a second account. A sink that cannot
/// promise that answers [`SignupError::Unavailable`] rather than guessing.
pub trait SignupSink: Send + Sync {
    fn paid(&self, s: &PaidSignup) -> Result<SignupOutcome, SignupError>;

    /// **Would this name be accepted, right now, with no money involved?**
    ///
    /// Asked by `Signup.CheckName` for a visitor who has typed a name and has
    /// not yet seen a card form, and again by `Signup.Start` before a checkout
    /// is minted. It is the ONLY way anybody outside the sink learns whether a
    /// name is free: the list of names this deployment has spoken for and the
    /// product's own answer both live behind this trait, and duplicating either
    /// into a console would make two lists that can disagree.
    ///
    /// # It is a courtesy, not a reservation
    ///
    /// It holds nothing. A name that is free here can be taken by somebody else
    /// in the minute the customer spends typing a card, and `paid` is still the
    /// gate that decides. What it buys is that the ORDINARY case — a name
    /// somebody already has — costs the customer a second and no money at all,
    /// instead of a charge, a refusal and a refund by hand.
    ///
    /// # The contract
    ///
    /// * `Ok(NameVerdict::Free)` — nothing this sink knows of holds it.
    /// * `Ok(NameVerdict::Taken(reason))` — it will not be accepted, and the
    ///   string is the sentence the customer reads. It must never name another
    ///   principal, because this answers an anonymous stranger.
    /// * `Err(SignupError::Unavailable)` — the sink could not ASK. Not the same
    ///   as taken: a console that showed "choose another username" because the
    ///   appliance was rebooting would be lying to a customer whose name is
    ///   free. [`SignupError::Refused`] and [`SignupError::NameTaken`] are not
    ///   answers here; the verdict carries the refusal.
    ///
    /// There is no default implementation, for the reason
    /// [`crate::Vendor::page_url`] has none: a default answering `Free`
    /// would make "this sink cannot tell" and "the name is free" the same
    /// answer, and the second sends somebody to a card form.
    fn name_verdict(&self, username: &str) -> Result<NameVerdict, SignupError>;

    /// **[`SignupSink::name_verdict`] for `product`'s account** (one monetize,
    /// several products, 2026-10-10). A sink serves ONE product, so the default
    /// is its own verdict; the server's per-product router answers with the
    /// sink of `product`, and refuses a product it holds no sink for.
    fn name_verdict_for(&self, product: &str, username: &str) -> Result<NameVerdict, SignupError> {
        let _ = product;
        self.name_verdict(username)
    }

    /// **[`SignupSink::paid`] for a signup that came from `estate`** (ONE monetize, several
    /// sites of one product, owner 2026-10-11): the site the signup began on — the caller's
    /// own bearer, or the account its money went through — not the product's home. A sink
    /// that serves one estate ignores it (the default); one whose product runs on several
    /// makes the account on THAT estate. `None`: the product's own estate, as before.
    fn paid_on(&self, s: &PaidSignup, estate: Option<&str>) -> Result<SignupOutcome, SignupError> {
        let _ = estate;
        self.paid(s)
    }

    /// **[`SignupSink::name_verdict_for`] on `estate`** — a name is free or taken on the
    /// estate the account would be made on ([`SignupSink::paid_on`]). The default ignores it.
    fn name_verdict_on(&self, product: &str, username: &str, estate: Option<&str>) -> Result<NameVerdict, SignupError> {
        let _ = estate;
        self.name_verdict_for(product, username)
    }

    /// **Send the welcome mail again, for a signup that is already CREATED.**
    ///
    /// The operator's door for a mail that did not go out the first time (a
    /// relay that refused, an address fixed afterwards). The engine calls it only
    /// for a record it holds as created, with the username it was created as.
    ///
    /// * `Ok(line)` — sent, or already sent and `force` is false; the line says
    ///   which, and is the audit record's words.
    /// * `Err(Refused)` — this sink cannot send it (no relay, no address) and
    ///   saying so is the answer.
    /// * `Err(Unavailable)` — the relay did not take it; asking again is fine.
    ///
    /// The default is a refusal by name: a sink that sends no mail must not
    /// answer "sent".
    fn resend_welcome(&self, s: &PaidSignup, force: bool) -> Result<String, SignupError> {
        let _ = (s, force);
        Err(SignupError::Refused("this deployment's signup sink sends no welcome mail".into()))
    }

    /// **Does this sink CREATE the account it answers `Created` for?** A server
    /// whose sink does not refuses to START a signup (`Signup.Start`): nobody is
    /// sent to pay for an account that will never exist (owner 2026-09-26).
    /// `true` for every sink that makes one; the open server's `NoSink`
    /// answers `false`.
    fn creates_accounts(&self) -> bool {
        true
    }

    /// **Does this sink create `product`'s accounts?** The default is
    /// [`SignupSink::creates_accounts`] (a sink serves one product); the
    /// server's per-product router answers for the sink of `product` and
    /// `false` where it holds none.
    fn creates_accounts_for(&self, product: &str) -> bool {
        let _ = product;
        self.creates_accounts()
    }

    /// **Send an EXISTING customer a notice** — the account goes read-only
    /// and will be deleted on a date, a period is about to end, a card was
    /// refused (owner rulings g-nonpay and g-renewal, 2026-09-29).
    ///
    /// It rides on this seam because this is where the deployment's ONE mail
    /// path already is: the relay, its credentials and its TLS rules live in
    /// the sink that sends the welcome mail, and a second mailer beside it
    /// would be a second place a relay password is read.
    ///
    /// The text is the caller's, whole and product-neutral: the sink only
    /// delivers it. Answers as [`SignupSink::resend_welcome`] does —
    ///
    /// * `Ok(line)` — handed to the relay; the line is the audit record's words;
    /// * `Err(Refused)` — this sink cannot send it (no relay, no address);
    /// * `Err(Unavailable)` — the relay did not take it; asking again is fine.
    ///
    /// The default is a refusal by name: a sink that sends no mail must not
    /// answer "sent", and the caller records the refusal as what happened.
    fn notify(&self, notice: &CustomerNotice) -> Result<String, SignupError> {
        let _ = notice;
        Err(SignupError::Refused("this deployment's signup sink sends no mail".into()))
    }
}

/// **One mail to one existing customer**, written by the caller and only
/// delivered by the sink ([`SignupSink::notify`]).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct CustomerNotice {
    /// The product and the tenant it is about — for the log line, never for
    /// the text, which is already whole.
    pub product: String,
    pub tenant: String,
    /// The address, as the customer's signup recorded it.
    pub to: String,
    /// What kind of notice (`nonpay.day0`, `nonpay.day23`, `renewal.reminder`,
    /// `payment.failed`) — the idempotency the CALLER keeps, and a word a
    /// sink may log.
    pub kind: String,
    pub subject: String,
    pub body: String,
}

/// What a [`SignupSink`] says about a name before any money moves.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum NameVerdict {
    /// Nothing holds it — as of this instant, and nothing more than that. See
    /// [`SignupSink::name_verdict`].
    Free,
    /// It will not be accepted, and this is what the customer is told.
    Taken(String),
}

/// **What a vendor is asked to sell, for a signup that has already picked its
/// name.**
///
/// It is not a [`crate::ChargeRequest`] and cannot be: that carries a
/// `reference` (`<product>/<namespace>/<period>`) and a reference is a ledger
/// key about a tenant that exists. This is the other case — money from somebody
/// who has no tenant at all — and the only identity it has is the name the
/// customer typed and has not paid for yet.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct SignupOrder {
    /// The name, already checked against [`username_refusal`] and against the
    /// sink ([`SignupSink::name_verdict`]). The vendor's job is to carry it
    /// back in the webhook, not to judge it.
    pub username: String,
    /// **The contact address the buyer typed and confirmed** before any card
    /// form, already checked against [`email_refusal`]. The vendor pre-fills its
    /// page with it and carries it back on the webhook, so the welcome mail goes
    /// to the address the person chose — not to whatever a wallet happened to
    /// hand the vendor (measured 2026-09-15: a signup that never asked for one).
    pub email: String,
    /// The product whose account is being bought (`gunnar`), and the plan id as
    /// `[signups.<product>.plans]` names it. Both ride on the session, and both come back
    /// on the webhook: `monetize_pay_impl::stripe::signup::is_signup` is
    /// exactly "this session has both".
    pub product: String,
    pub plan: String,
    /// **The plan's price, NET of tax.** Whatever tax the vendor adds on top is
    /// the vendor's calculation and comes back inside `PaidSignup::amount` with
    /// `PaidSignup::tax` beside it, because that is what
    /// `monetize_server::signup::refusal` compares against.
    pub amount: Money,
    pub market: Market,
    /// What the customer sees on the statement and on the vendor's page.
    pub description: String,
    /// Where the vendor returns a customer who paid, and one who backed out.
    /// The literal `{session}` in either is the vendor's own session id — see
    /// [`SIGNUP_SESSION_TOKEN`].
    pub success_url: String,
    pub cancel_url: String,
}

/// **The token a vendor substitutes its own checkout session id for.**
///
/// `https://gunnar.rs/welcome?session={session}` is what the caller writes;
/// Stripe wants `{CHECKOUT_SESSION_ID}` and another vendor wants something
/// else, and the caller is a console that must not have to know which vendor is
/// taking the money. Each impl rewrites this into its own spelling — or, where
/// it mints the id itself, substitutes the id.
pub const SIGNUP_SESSION_TOKEN: &str = "{session}";

/// **Where to send somebody who has picked a name and now has to pay.**
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct SignupCheckout {
    /// The vendor's own hosted page, absolute.
    pub url: String,
    /// The session id, where the vendor names one at creation. Empty is a legal
    /// answer: the return URL carries it either way, and the caller must not
    /// read an empty one as a failure.
    pub session: String,
}

/// **A card checked at the vendor, with nothing charged** (owner 2026-10-15: a FREE
/// account on a site is made only after a card check, at most one free account per card).
///
/// The vendor's answer to [`crate::Vendor::card_check`] about a session minted by
/// [`crate::Vendor::card_check_page`] (Stripe: a Checkout Session in
/// `mode=setup` — a SetupIntent, no charge, 3-D Secure as the bank asks). Only what is
/// needed to recognise the CARD again is carried: the vendor's fingerprint of the card
/// number, never the number, never its last four digits.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct CardCheck {
    /// The vendor's session (`cs_…`), as [`SignupCheckout::session`] named it.
    pub session: String,
    pub state: CardCheckState,
    /// **The vendor's fingerprint of the card number** (Stripe:
    /// `payment_method.card.fingerprint`) — the same card gives the same value on the
    /// same vendor ACCOUNT, and a different value on another account. Empty until
    /// [`CardCheckState::Checked`].
    pub fingerprint: String,
    /// The vendor's customer the card was saved on (`cus_…`), where it made one — what
    /// a later paid plan on the account may reuse. Empty: none.
    #[serde(default)]
    pub customer: String,
    /// The saved payment method (`pm_…`). Empty: none.
    #[serde(default)]
    pub payment_method: String,
    /// What an operator types into the vendor's dashboard (Stripe: the SetupIntent, `seti_…`).
    #[serde(default)]
    pub reference: String,
}

/// ★ **Where a paid signup's checkout stands at the vendor**, read from the vendor by its
/// session (owner 2026-10-15, B: a plan row goes to the vendor ONCE and the account is made
/// once the payment is confirmed — monetize asks the vendor itself, as for a card check).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum SignupAtVendor {
    /// The page is open, or complete with the money not there yet.
    Open,
    /// Paid: the signup exactly as the vendor's webhook would have handed it up.
    Paid(Box<PaidSignup>),
    /// The page closed unpaid (expired): no account, and the name is free again.
    Closed,
}

/// Where a card check stands at the vendor.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum CardCheckState {
    /// The page is open: the card is not checked (yet).
    Open,
    /// The card was checked and saved; nothing was charged.
    Checked,
    /// The page closed with no card checked (expired, or abandoned).
    Expired,
}

/// The rule a username has to satisfy: `^[a-z0-9][a-z0-9._-]{1,31}$`.
///
/// Written out as a constant because the refusal quotes it — a customer whose
/// name was rejected is owed the rule, not the word "invalid".
pub const USERNAME_RULE: &str = "^[a-z0-9][a-z0-9._-]{1,31}$";

/// **`None` when the name is allowed; the refusal reason otherwise.**
///
/// Hand-written rather than a regex crate: the dependency rule aside, the
/// rule is four conditions and each of them names itself in the refusal, which
/// a regex match cannot do — `"nordkalott!"` is rejected for the `!`, and being
/// told which character was the problem is the difference between a customer
/// who retries and one who mails support.
///
/// It is here and not in the private sink because the refusal has to be
/// reachable without a vendor: the open build refuses the same names the paid
/// one does, so a deployment can rehearse the whole seam.
pub fn username_refusal(username: &str) -> Option<String> {
    let n = username.chars().count();
    if !(2..=32).contains(&n) {
        return Some(format!("username {username:?} is {n} characters; the rule is {USERNAME_RULE} (2 to 32)"));
    }
    let mut chars = username.chars();
    let Some(first) = chars.next() else { return Some(format!("username {username:?} is empty; the rule is {USERNAME_RULE} (2 to 32)")) };
    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
        return Some(format!("username {username:?} starts with {first:?}; it must start with a lowercase letter or a digit ({USERNAME_RULE})"));
    }
    for c in chars {
        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_' || c == '-') {
            return Some(format!("username {username:?} contains {c:?}; only a-z, 0-9, '.', '_' and '-' are allowed ({USERNAME_RULE})"));
        }
    }
    None
}

/// **`None` when a contact address is plausible; the refusal otherwise.**
///
/// Deliberately a SHAPE check and not a validation — the only proof an address
/// works is a mail that arrives. One `@`, a non-empty local part, a domain with a
/// dot that neither starts nor ends it, no whitespace or control characters, at
/// most 254 characters (RFC 5321's path limit). Each refusal names what was
/// wrong, for the reason [`username_refusal`] does.
pub fn email_refusal(email: &str) -> Option<String> {
    let n = email.chars().count();
    if n == 0 {
        return Some("an e-mail address is required: it is where the welcome mail and the receipt go".into());
    }
    if n > 254 {
        return Some(format!("the e-mail address is {n} characters; at most 254 are allowed"));
    }
    if let Some(c) = email.chars().find(|c| c.is_whitespace() || c.is_control()) {
        return Some(format!("the e-mail address {email:?} contains {c:?}"));
    }
    let Some((local, domain)) = email.split_once('@') else {
        return Some(format!("the e-mail address {email:?} has no '@'"));
    };
    if local.is_empty() {
        return Some(format!("the e-mail address {email:?} has nothing before the '@'"));
    }
    if domain.contains('@') {
        return Some(format!("the e-mail address {email:?} has more than one '@'"));
    }
    if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") {
        return Some(format!("the e-mail address {email:?} does not end in a domain like example.com"));
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_email_shape_check_names_what_it_refused_and_passes_real_addresses() {
        assert_eq!(email_refusal("elvira@example.com"), None);
        assert_eq!(email_refusal("a.b+tag@sub.example.se"), None);
        assert!(email_refusal("").unwrap().contains("required"));
        assert!(email_refusal("elvira.example.com").unwrap().contains("no '@'"));
        assert!(email_refusal("@example.com").unwrap().contains("nothing before"));
        assert!(email_refusal("a@b@example.com").unwrap().contains("more than one"));
        assert!(email_refusal("a@localhost").unwrap().contains("domain"));
        assert!(email_refusal("a@example.com.").unwrap().contains("domain"));
        assert!(email_refusal("a b@example.com").unwrap().contains("' '"));
        assert!(email_refusal(&format!("{}@example.com", "a".repeat(250))).unwrap().contains("at most 254"));
    }

    #[test]
    fn money_reads_in_major_units_never_as_bare_minor() {
        use crate::{Currency, Money};
        assert_eq!(Money { minor: 12_500, currency: Currency::Sek }.major(), "125.00 SEK");
        assert_eq!(Money { minor: 5, currency: Currency::Eur }.major(), "0.05 EUR");
        assert_eq!(Money { minor: 0, currency: Currency::Sek }.major(), "0.00 SEK");
    }

    #[test]
    fn the_username_rule_names_the_thing_it_refused() {
        assert_eq!(username_refusal("bob"), None);
        assert_eq!(username_refusal("a1"), None);
        assert_eq!(username_refusal("a.b_c-d"), None);
        assert!(username_refusal("a").unwrap().contains("1 characters"), "a one-character name is too short");
        assert!(username_refusal(&"a".repeat(33)).unwrap().contains("33 characters"));
        assert!(username_refusal("Bob").unwrap().contains("must start with"));
        assert!(username_refusal(".hidden").unwrap().contains("must start with"));
        // The offending character is named, not just the rule.
        let why = username_refusal("nordkalott!").unwrap();
        assert!(why.contains("'!'"), "{why}");
        assert!(why.contains(USERNAME_RULE), "{why}");
    }

    /// The two sink refusals must not read alike: one ends the customer's day
    /// and the other is a retry nobody outside this process should notice.
    #[test]
    fn a_refusal_and_an_outage_say_different_things() {
        assert_eq!(SignupError::Refused("plan unknown".into()).to_string(), "signup refused: plan unknown");
        assert_eq!(SignupError::Unavailable("gunnar is down".into()).to_string(), "signup sink unavailable: gunnar is down");
        assert_eq!(SignupError::Maintenance("resizing".into()).to_string(), "signup sink in maintenance: resizing");
    }
}