//! Background sales: the purchase history behind every item's "Sales" figure. //! //! [`super::buyer`] seeds one login-capable buyer so `/library` can be //! photographed. This phase seeds the rest of the marketplace: a pool of buyers //! who bought things and are never logged into, so an item page says how many //! people bought it instead of saying zero. //! //! # Why this is transactions rather than a number //! //! `items.sales_count` is denormalized, so the cheap version of this phase is an //! `UPDATE items SET sales_count = `. That would be a lie //! the platform catches itself telling: `scheduler::integrity::check_sales_count_drift` //! compares the column against `COUNT(*)` over completed transactions and pages //! WAM on every mismatch, so a fabricated count would alert forever, and the demo //! would be showing sales that produced no revenue on the creator's own dashboard. //! //! So the sales are real rows, and [`reconcile_sales_counts`] derives the column //! from them at the end of the phase rather than incrementing as it goes. That is //! also what closes an existing drift: the demo buyer's nine purchases are //! inserted directly (they do not go through the purchase path that calls //! `increment_sales_count`), so before this phase existed every item they bought //! reported zero sales while holding a completed transaction. //! //! # What is deliberately not here //! //! No license keys. [`super::buyer::issue_license_key`] derives the key from the //! item id so a reseed reproduces the key in the approved screenshot, which means //! one key per item, which means it cannot also cover a second buyer of that item. //! Keys are only ever shown in the demo buyer's library, so background purchases //! skip them rather than making the visible key change on every reseed. use chrono::{DateTime, Duration, Utc}; use uuid::Uuid; use super::projects::SeededProject; use super::{EXAMPLE_EMAIL_DOMAIN, SeedError}; use crate::db::{self}; /// How many background buyers exist. /// /// The ceiling on any one item's `other_sales`: a buyer cannot buy the same item /// twice (`idx_transactions_buyer_item_completed`), so an item wanting N /// background sales needs N distinct accounts. `other_sales_fit_the_buyer_pool` /// holds the roster to it. pub const BUYER_POOL: usize = 14; /// Base of the background buyers' fixed ids, so a reseed reproduces the same /// accounts rather than a fresh set (same reason as [`super::buyer::BUYER_ACCOUNT_ID`], /// which sits at `…b001` and is deliberately outside this range). const BUYER_ID_BASE: u128 = 0x0000_0000_0000_0000_0000_0000_0000_b101; /// Stored in `password_hash`, which is `NOT NULL`. /// /// Not a hash of anything: these accounts exist to own transactions and are /// never logged into. `auth::verify_password` treats an unparseable hash as a /// non-match, so the accounts are unreachable by password rather than reachable /// with a guessable one. const UNUSABLE_PASSWORD_HASH: &str = "!seed-background-buyer-no-login"; /// Username prefix every background buyer carries. /// /// These accounts are neither creators nor the demo buyer, so anything counting /// the roster has to be able to tell them apart from it. Exported so the seed /// tests filter on the same string the seed writes. pub const BUYER_HANDLE_PREFIX: &str = "demo_buyer_"; /// Seed the background buyers and their purchases, then bring every seeded item's /// `sales_count` into agreement with its transactions. /// /// Called from [`super::run`] after [`super::buyer`], so the reconcile at the end /// counts the demo buyer's purchases too. Unlike the buyer phase this one needs /// no credential, so it always runs. pub async fn seed_sales(pool: &sqlx::PgPool, projects: &[SeededProject]) -> Result<(), SeedError> { let buyers = seed_buyer_pool(pool).await?; let mut sold = 0; // Rotates the slice of the pool used per item, so the same handful of // accounts are not the buyers of everything in roster order. let mut offset = 0; for project in projects { let items = db::items::get_items_by_project(pool, project.project.id).await?; for spec in project.spec.items { let Some(item) = items.iter().find(|i| i.title == spec.title) else { tracing::warn!( title = spec.title, "example seed: item missing at the sales phase; it will report zero sales" ); continue; }; for n in 0..spec.other_sales as usize { let buyer = buyers[(offset + n) % BUYER_POOL]; let purchased_at = purchase_date(spec.released_days_ago, n); let amount_cents = amount_for(item, n); record_purchase(pool, project, item, buyer, amount_cents, purchased_at).await?; sold += 1; } offset += spec.other_sales as usize + 1; } } let reconciled = reconcile_sales_counts(pool).await?; tracing::info!( purchases = sold, items = reconciled, "example seed: background sales seeded" ); Ok(()) } /// Create the pool, returning the ids in order. async fn seed_buyer_pool(pool: &sqlx::PgPool) -> Result, SeedError> { let mut ids = Vec::with_capacity(BUYER_POOL); for n in 0..BUYER_POOL { let id = Uuid::from_u128(BUYER_ID_BASE + n as u128); let handle = format!("{BUYER_HANDLE_PREFIX}{n:02}"); let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", handle.replace('_', "-")); sqlx::query( r" INSERT INTO users ( id, username, email, password_hash, display_name, can_create_projects, email_verified ) VALUES ($1, $2, $3, $4, $5, FALSE, TRUE) ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, email = EXCLUDED.email ", ) .bind(id) .bind(&handle) .bind(&email) .bind(UNUSABLE_PASSWORD_HASH) .bind(format!("Demo Buyer {n:02}")) .execute(pool) .await?; ids.push(id); } Ok(ids) } /// When the nth background buyer bought an item released `released_days_ago`. /// /// Spread across the item's life rather than clustered at either end, and always /// strictly inside it: a sale predating the release is the kind of detail that /// makes a demo look assembled rather than lived in, and the revenue dashboards /// bucket by date. fn purchase_date(released_days_ago: i64, n: usize) -> DateTime { // Leave the release day itself clear, and keep every sale at least a day old // so nothing lands in the future between seeding and capture. let window = (released_days_ago - 2).max(1); // 7-day stride, wrapped: a spread that does not need a random source (the // seed has to reproduce, and `mnw-testnot-seed.sh` may run twice before a // capture). let offset = (n as i64 * 7) % window; Utc::now() - Duration::days(released_days_ago - 1 - offset) } /// What the nth background buyer paid. /// /// Fixed price for a priced item, nothing for a free one, and the floor plus a /// varying tip for pay-what-you-want. `get_user_purchases` derives its Free badge /// from `amount_cents = 0`, and the creator revenue figures add these up, so this /// reads the item rather than inventing a number. fn amount_for(item: &db::DbItem, n: usize) -> i32 { if item.pwyw_enabled { // Most buyers pay the floor; some add a little. Deterministic, and not // a straight line. let tip = [0, 0, 250, 0, 100, 500, 0, 150][n % 8]; return item.pwyw_min_cents.unwrap_or(0) + tip; } item.price_cents } /// Record one completed transaction, which is what the `purchases` view reads. /// /// `platform_fee_cents` is zero and that is not a placeholder: MNW's platform /// fee is 0%, so a demo receipt showing anything else would misrepresent the /// product. The Stripe ids are fabricated and marked `demo_`; nothing on testnot /// talks to live Stripe, and the prefix makes a stray row obvious. /// /// The currency comes off the seller rather than being hardcoded. `transactions` /// constrains it to a lowercase supported code, and the real payment path /// settles in the seller's currency, so reading it keeps a demo receipt true to /// what a live one would say if a seeded creator is ever given a non-USD /// settlement currency. pub(super) async fn record_purchase( pool: &sqlx::PgPool, project: &SeededProject, item: &db::DbItem, buyer_id: Uuid, amount_cents: i32, purchased_at: DateTime, ) -> Result { let (seller_username, currency): (String, String) = sqlx::query_as( "SELECT username, lower(settlement_currency::text) FROM users WHERE id = $1", ) .bind(project.user_id) .fetch_one(pool) .await?; let transaction_id: Uuid = sqlx::query_scalar( r" INSERT INTO transactions ( buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, currency, status, stripe_payment_intent_id, created_at, completed_at, item_title, seller_username ) VALUES ($1, $2, $3, $4, 0, $5, 'completed', $6, $7, $7, $8, $9) RETURNING id ", ) .bind(buyer_id) .bind(project.user_id) .bind(item.id) .bind(amount_cents) .bind(¤cy) .bind(format!("pi_demo_{}_{}", item.id, buyer_id.simple())) .bind(purchased_at) .bind(&item.title) .bind(&seller_username) .fetch_one(pool) .await?; Ok(transaction_id) } /// Set every seeded item's `sales_count` to its completed-transaction count. /// /// Derived rather than incremented, so the column agrees with the rows by /// construction and `check_sales_count_drift` stays quiet. Scoped to items owned /// by example accounts: the seed's guards mean nothing else should be in the /// database, and scoping it anyway keeps this from being a whole-table write if /// that ever stops being true. async fn reconcile_sales_counts(pool: &sqlx::PgPool) -> Result { let res = sqlx::query( r" UPDATE items i SET sales_count = ( SELECT COUNT(*) FROM transactions t WHERE t.item_id = i.id AND t.status = 'completed' ) FROM projects p, users u WHERE i.project_id = p.id AND p.user_id = u.id AND lower(u.email) LIKE $1 ", ) .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}")) .execute(pool) .await?; Ok(res.rows_affected()) } #[cfg(test)] mod tests { use super::*; use crate::seed::creators::ROSTER; #[test] fn other_sales_fit_the_buyer_pool() { // One buyer cannot buy the same item twice, so an item asking for more // background sales than there are background buyers would fail the // seed on a unique-index violation, mid-run, on the box. for creator in ROSTER { for item in creator.project.items { assert!( item.other_sales as usize <= BUYER_POOL, "{}: other_sales {} exceeds the pool of {BUYER_POOL}", item.title, item.other_sales ); } } } #[test] fn every_background_sale_postdates_its_release() { for creator in ROSTER { for item in creator.project.items { for n in 0..item.other_sales as usize { let released = Utc::now() - Duration::days(item.released_days_ago); let bought = purchase_date(item.released_days_ago, n); assert!( bought > released, "{}: background sale {n} predates its release", item.title ); assert!( bought < Utc::now(), "{}: background sale {n} is in the future", item.title ); } } } } #[test] fn demo_buyer_purchases_postdate_release() { // The two phases pick their dates independently: the roster says when an // item came out, `buyer::PURCHASES` says how long ago the demo buyer // bought it. A purchase older than the item it bought is invisible in // the library frame and wrong in every revenue view, so tie them here. for creator in ROSTER { for item in creator.project.items { let Some(purchase) = crate::seed::buyer::PURCHASES .iter() .find(|p| p.title == item.title) else { continue; }; assert!( purchase.days_ago < item.released_days_ago, "{}: bought {} days ago but released only {} days ago", item.title, purchase.days_ago, item.released_days_ago ); } } } #[test] fn total_sales_stay_within_the_pool_plus_the_demo_buyer() { // `other_sales` counts background buyers only. An item the demo buyer // also bought displays one more than that, and every one of those // buyers must be a distinct account. for creator in ROSTER { for item in creator.project.items { let demo = crate::seed::buyer::PURCHASES .iter() .any(|p| p.title == item.title) as usize; assert!( item.other_sales as usize + demo <= BUYER_POOL + 1, "{}: more buyers than accounts exist", item.title ); } } } #[test] fn release_dates_are_spread_rather_than_stamped() { // The tell this phase exists to remove: a catalog whose every item was // released the day the seed ran. A handful of shared dates is fine, a // catalog collapsed onto a few is not. let days: Vec = ROSTER .iter() .flat_map(|c| c.project.items.iter().map(|i| i.released_days_ago)) .collect(); let mut distinct = days.clone(); distinct.sort_unstable(); distinct.dedup(); assert!( distinct.len() >= days.len() * 3 / 4, "release dates are bunched: {} distinct across {} items", distinct.len(), days.len() ); let oldest = days.iter().max().copied().unwrap_or(0); assert!( oldest >= 180, "the catalog should have a history: oldest release is {oldest} days old" ); } }