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");
}
}