Skip to main content

max / makenotwork

14.5 KB · 366 lines History Blame Raw
1 //! Background sales: the purchase history behind every item's "Sales" figure.
2 //!
3 //! [`super::buyer`] seeds one login-capable buyer so `/library` can be
4 //! photographed. This phase seeds the rest of the marketplace: a pool of buyers
5 //! who bought things and are never logged into, so an item page says how many
6 //! people bought it instead of saying zero.
7 //!
8 //! # Why this is transactions rather than a number
9 //!
10 //! `items.sales_count` is denormalized, so the cheap version of this phase is an
11 //! `UPDATE items SET sales_count = <a plausible number>`. That would be a lie
12 //! the platform catches itself telling: `scheduler::integrity::check_sales_count_drift`
13 //! compares the column against `COUNT(*)` over completed transactions and pages
14 //! WAM on every mismatch, so a fabricated count would alert forever, and the demo
15 //! would be showing sales that produced no revenue on the creator's own dashboard.
16 //!
17 //! So the sales are real rows, and [`reconcile_sales_counts`] derives the column
18 //! from them at the end of the phase rather than incrementing as it goes. That is
19 //! also what closes an existing drift: the demo buyer's nine purchases are
20 //! inserted directly (they do not go through the purchase path that calls
21 //! `increment_sales_count`), so before this phase existed every item they bought
22 //! reported zero sales while holding a completed transaction.
23 //!
24 //! # What is deliberately not here
25 //!
26 //! No license keys. [`super::buyer::issue_license_key`] derives the key from the
27 //! item id so a reseed reproduces the key in the approved screenshot, which means
28 //! one key per item, which means it cannot also cover a second buyer of that item.
29 //! Keys are only ever shown in the demo buyer's library, so background purchases
30 //! skip them rather than making the visible key change on every reseed.
31
32 use chrono::{DateTime, Duration, Utc};
33 use uuid::Uuid;
34
35 use super::projects::SeededProject;
36 use super::{EXAMPLE_EMAIL_DOMAIN, SeedError};
37 use crate::db::{self};
38
39 /// How many background buyers exist.
40 ///
41 /// The ceiling on any one item's `other_sales`: a buyer cannot buy the same item
42 /// twice (`idx_transactions_buyer_item_completed`), so an item wanting N
43 /// background sales needs N distinct accounts. `other_sales_fit_the_buyer_pool`
44 /// holds the roster to it.
45 pub const BUYER_POOL: usize = 14;
46
47 /// Base of the background buyers' fixed ids, so a reseed reproduces the same
48 /// accounts rather than a fresh set (same reason as [`super::buyer::BUYER_ACCOUNT_ID`],
49 /// which sits at `…b001` and is deliberately outside this range).
50 const BUYER_ID_BASE: u128 = 0x0000_0000_0000_0000_0000_0000_0000_b101;
51
52 /// Stored in `password_hash`, which is `NOT NULL`.
53 ///
54 /// Not a hash of anything: these accounts exist to own transactions and are
55 /// never logged into. `auth::verify_password` treats an unparseable hash as a
56 /// non-match, so the accounts are unreachable by password rather than reachable
57 /// with a guessable one.
58 const UNUSABLE_PASSWORD_HASH: &str = "!seed-background-buyer-no-login";
59
60 /// Username prefix every background buyer carries.
61 ///
62 /// These accounts are neither creators nor the demo buyer, so anything counting
63 /// the roster has to be able to tell them apart from it. Exported so the seed
64 /// tests filter on the same string the seed writes.
65 pub const BUYER_HANDLE_PREFIX: &str = "demo_buyer_";
66
67 /// Seed the background buyers and their purchases, then bring every seeded item's
68 /// `sales_count` into agreement with its transactions.
69 ///
70 /// Called from [`super::run`] after [`super::buyer`], so the reconcile at the end
71 /// counts the demo buyer's purchases too. Unlike the buyer phase this one needs
72 /// no credential, so it always runs.
73 pub async fn seed_sales(pool: &sqlx::PgPool, projects: &[SeededProject]) -> Result<(), SeedError> {
74 let buyers = seed_buyer_pool(pool).await?;
75
76 let mut sold = 0;
77 // Rotates the slice of the pool used per item, so the same handful of
78 // accounts are not the buyers of everything in roster order.
79 let mut offset = 0;
80 for project in projects {
81 let items = db::items::get_items_by_project(pool, project.project.id).await?;
82 for spec in project.spec.items {
83 let Some(item) = items.iter().find(|i| i.title == spec.title) else {
84 tracing::warn!(
85 title = spec.title,
86 "example seed: item missing at the sales phase; it will report zero sales"
87 );
88 continue;
89 };
90 for n in 0..spec.other_sales as usize {
91 let buyer = buyers[(offset + n) % BUYER_POOL];
92 let purchased_at = purchase_date(spec.released_days_ago, n);
93 let amount_cents = amount_for(item, n);
94 record_purchase(pool, project, item, buyer, amount_cents, purchased_at).await?;
95 sold += 1;
96 }
97 offset += spec.other_sales as usize + 1;
98 }
99 }
100
101 let reconciled = reconcile_sales_counts(pool).await?;
102 tracing::info!(
103 purchases = sold,
104 items = reconciled,
105 "example seed: background sales seeded"
106 );
107 Ok(())
108 }
109
110 /// Create the pool, returning the ids in order.
111 async fn seed_buyer_pool(pool: &sqlx::PgPool) -> Result<Vec<Uuid>, SeedError> {
112 let mut ids = Vec::with_capacity(BUYER_POOL);
113 for n in 0..BUYER_POOL {
114 let id = Uuid::from_u128(BUYER_ID_BASE + n as u128);
115 let handle = format!("{BUYER_HANDLE_PREFIX}{n:02}");
116 let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", handle.replace('_', "-"));
117 sqlx::query(
118 r"
119 INSERT INTO users (
120 id, username, email, password_hash, display_name,
121 can_create_projects, email_verified
122 )
123 VALUES ($1, $2, $3, $4, $5, FALSE, TRUE)
124 ON CONFLICT (id) DO UPDATE SET
125 username = EXCLUDED.username,
126 email = EXCLUDED.email
127 ",
128 )
129 .bind(id)
130 .bind(&handle)
131 .bind(&email)
132 .bind(UNUSABLE_PASSWORD_HASH)
133 .bind(format!("Demo Buyer {n:02}"))
134 .execute(pool)
135 .await?;
136 ids.push(id);
137 }
138 Ok(ids)
139 }
140
141 /// When the nth background buyer bought an item released `released_days_ago`.
142 ///
143 /// Spread across the item's life rather than clustered at either end, and always
144 /// strictly inside it: a sale predating the release is the kind of detail that
145 /// makes a demo look assembled rather than lived in, and the revenue dashboards
146 /// bucket by date.
147 fn purchase_date(released_days_ago: i64, n: usize) -> DateTime<Utc> {
148 // Leave the release day itself clear, and keep every sale at least a day old
149 // so nothing lands in the future between seeding and capture.
150 let window = (released_days_ago - 2).max(1);
151 // 7-day stride, wrapped: a spread that does not need a random source (the
152 // seed has to reproduce, and `mnw-testnot-seed.sh` may run twice before a
153 // capture).
154 let offset = (n as i64 * 7) % window;
155 Utc::now() - Duration::days(released_days_ago - 1 - offset)
156 }
157
158 /// What the nth background buyer paid.
159 ///
160 /// Fixed price for a priced item, nothing for a free one, and the floor plus a
161 /// varying tip for pay-what-you-want. `get_user_purchases` derives its Free badge
162 /// from `amount_cents = 0`, and the creator revenue figures add these up, so this
163 /// reads the item rather than inventing a number.
164 fn amount_for(item: &db::DbItem, n: usize) -> i32 {
165 if item.pwyw_enabled {
166 // Most buyers pay the floor; some add a little. Deterministic, and not
167 // a straight line.
168 let tip = [0, 0, 250, 0, 100, 500, 0, 150][n % 8];
169 return item.pwyw_min_cents.unwrap_or(0) + tip;
170 }
171 item.price_cents
172 }
173
174 /// Record one completed transaction, which is what the `purchases` view reads.
175 ///
176 /// `platform_fee_cents` is zero and that is not a placeholder: MNW's platform
177 /// fee is 0%, so a demo receipt showing anything else would misrepresent the
178 /// product. The Stripe ids are fabricated and marked `demo_`; nothing on testnot
179 /// talks to live Stripe, and the prefix makes a stray row obvious.
180 ///
181 /// The currency comes off the seller rather than being hardcoded. `transactions`
182 /// constrains it to a lowercase supported code, and the real payment path
183 /// settles in the seller's currency, so reading it keeps a demo receipt true to
184 /// what a live one would say if a seeded creator is ever given a non-USD
185 /// settlement currency.
186 pub(super) async fn record_purchase(
187 pool: &sqlx::PgPool,
188 project: &SeededProject,
189 item: &db::DbItem,
190 buyer_id: Uuid,
191 amount_cents: i32,
192 purchased_at: DateTime<Utc>,
193 ) -> Result<Uuid, SeedError> {
194 let (seller_username, currency): (String, String) = sqlx::query_as(
195 "SELECT username, lower(settlement_currency::text) FROM users WHERE id = $1",
196 )
197 .bind(project.user_id)
198 .fetch_one(pool)
199 .await?;
200
201 let transaction_id: Uuid = sqlx::query_scalar(
202 r"
203 INSERT INTO transactions (
204 buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
205 currency, status, stripe_payment_intent_id,
206 created_at, completed_at, item_title, seller_username
207 )
208 VALUES ($1, $2, $3, $4, 0, $5, 'completed', $6, $7, $7, $8, $9)
209 RETURNING id
210 ",
211 )
212 .bind(buyer_id)
213 .bind(project.user_id)
214 .bind(item.id)
215 .bind(amount_cents)
216 .bind(&currency)
217 .bind(format!("pi_demo_{}_{}", item.id, buyer_id.simple()))
218 .bind(purchased_at)
219 .bind(&item.title)
220 .bind(&seller_username)
221 .fetch_one(pool)
222 .await?;
223
224 Ok(transaction_id)
225 }
226
227 /// Set every seeded item's `sales_count` to its completed-transaction count.
228 ///
229 /// Derived rather than incremented, so the column agrees with the rows by
230 /// construction and `check_sales_count_drift` stays quiet. Scoped to items owned
231 /// by example accounts: the seed's guards mean nothing else should be in the
232 /// database, and scoping it anyway keeps this from being a whole-table write if
233 /// that ever stops being true.
234 async fn reconcile_sales_counts(pool: &sqlx::PgPool) -> Result<u64, SeedError> {
235 let res = sqlx::query(
236 r"
237 UPDATE items i SET sales_count = (
238 SELECT COUNT(*) FROM transactions t
239 WHERE t.item_id = i.id AND t.status = 'completed'
240 )
241 FROM projects p, users u
242 WHERE i.project_id = p.id
243 AND p.user_id = u.id
244 AND lower(u.email) LIKE $1
245 ",
246 )
247 .bind(format!("%@{EXAMPLE_EMAIL_DOMAIN}"))
248 .execute(pool)
249 .await?;
250 Ok(res.rows_affected())
251 }
252
253 #[cfg(test)]
254 mod tests {
255 use super::*;
256 use crate::seed::creators::ROSTER;
257
258 #[test]
259 fn other_sales_fit_the_buyer_pool() {
260 // One buyer cannot buy the same item twice, so an item asking for more
261 // background sales than there are background buyers would fail the
262 // seed on a unique-index violation, mid-run, on the box.
263 for creator in ROSTER {
264 for item in creator.project.items {
265 assert!(
266 item.other_sales as usize <= BUYER_POOL,
267 "{}: other_sales {} exceeds the pool of {BUYER_POOL}",
268 item.title,
269 item.other_sales
270 );
271 }
272 }
273 }
274
275 #[test]
276 fn every_background_sale_postdates_its_release() {
277 for creator in ROSTER {
278 for item in creator.project.items {
279 for n in 0..item.other_sales as usize {
280 let released = Utc::now() - Duration::days(item.released_days_ago);
281 let bought = purchase_date(item.released_days_ago, n);
282 assert!(
283 bought > released,
284 "{}: background sale {n} predates its release",
285 item.title
286 );
287 assert!(
288 bought < Utc::now(),
289 "{}: background sale {n} is in the future",
290 item.title
291 );
292 }
293 }
294 }
295 }
296
297 #[test]
298 fn demo_buyer_purchases_postdate_release() {
299 // The two phases pick their dates independently: the roster says when an
300 // item came out, `buyer::PURCHASES` says how long ago the demo buyer
301 // bought it. A purchase older than the item it bought is invisible in
302 // the library frame and wrong in every revenue view, so tie them here.
303 for creator in ROSTER {
304 for item in creator.project.items {
305 let Some(purchase) = crate::seed::buyer::PURCHASES
306 .iter()
307 .find(|p| p.title == item.title)
308 else {
309 continue;
310 };
311 assert!(
312 purchase.days_ago < item.released_days_ago,
313 "{}: bought {} days ago but released only {} days ago",
314 item.title,
315 purchase.days_ago,
316 item.released_days_ago
317 );
318 }
319 }
320 }
321
322 #[test]
323 fn total_sales_stay_within_the_pool_plus_the_demo_buyer() {
324 // `other_sales` counts background buyers only. An item the demo buyer
325 // also bought displays one more than that, and every one of those
326 // buyers must be a distinct account.
327 for creator in ROSTER {
328 for item in creator.project.items {
329 let demo = crate::seed::buyer::PURCHASES
330 .iter()
331 .any(|p| p.title == item.title) as usize;
332 assert!(
333 item.other_sales as usize + demo <= BUYER_POOL + 1,
334 "{}: more buyers than accounts exist",
335 item.title
336 );
337 }
338 }
339 }
340
341 #[test]
342 fn release_dates_are_spread_rather_than_stamped() {
343 // The tell this phase exists to remove: a catalog whose every item was
344 // released the day the seed ran. A handful of shared dates is fine, a
345 // catalog collapsed onto a few is not.
346 let days: Vec<i64> = ROSTER
347 .iter()
348 .flat_map(|c| c.project.items.iter().map(|i| i.released_days_ago))
349 .collect();
350 let mut distinct = days.clone();
351 distinct.sort_unstable();
352 distinct.dedup();
353 assert!(
354 distinct.len() >= days.len() * 3 / 4,
355 "release dates are bunched: {} distinct across {} items",
356 distinct.len(),
357 days.len()
358 );
359 let oldest = days.iter().max().copied().unwrap_or(0);
360 assert!(
361 oldest >= 180,
362 "the catalog should have a history: oldest release is {oldest} days old"
363 );
364 }
365 }
366