Skip to main content

max / makenotwork

Release server v0.11.13: demo catalog gets a release history and real sales The item frame of the landing carousel showed "Released Aug 07, 2026" (the day the seed ran) and "Sales 0", under a caption about creators keeping every sale. Both were true readings of the demo, and both were tells. Release dates: items.created_at was whatever NOW() was during the seed, so the whole catalog came out on one day. ItemSpec now carries released_days_ago and items.rs backdates the row, spread from 22 to 243 days across the roster. Sales: items.sales_count is denormalized, and writing a plausible number into it would have been caught by the platform itself, since check_sales_count_drift compares it against completed transactions and pages WAM on every mismatch. So the sales are transactions. A new sales phase seeds a pool of 14 background buyers who own purchases and never log in, then derives sales_count from the rows rather than incrementing as it goes. That reconcile also closes a drift that was already live: the demo buyer's nine purchases are inserted directly rather than through the purchase path that calls increment_sales_count, so every item they bought reported zero sales while holding a completed transaction. record_purchase moves to the sales phase; buyer.rs takes it from there.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 20:37 UTC
Signed with PGP, not checked
Commit: 144dc97e4f3c40dcd277bde9f128d17fc9694d05
Parent: d35632a
9 files changed, +591 insertions, -86 deletions
M server/Cargo.lock +25 -17
@@ -5192,7 +5192,7 @@
5192 5192
5193 5193 [[package]]
5194 5194 name = "makenotwork"
5195 - version = "0.11.12"
5195 + version = "0.11.13"
5196 5196 dependencies = [
5197 5197 "ammonia",
5198 5198 "anyhow",
@@ -10641,6 +10641,30 @@
10641 10641 "pkg-config",
10642 10642 ]
10643 10643
10644 + [[patch.unused]]
10645 + name = "quasi-axum"
10646 + version = "0.1.0"
10647 +
10648 + [[patch.unused]]
10649 + name = "quasi-http"
10650 + version = "0.1.0"
10651 +
10652 + [[patch.unused]]
10653 + name = "quasi-router"
10654 + version = "0.1.0"
10655 +
10656 + [[patch.unused]]
10657 + name = "quasi-store"
10658 + version = "0.1.0"
10659 +
10660 + [[patch.unused]]
10661 + name = "quasi-tauri"
10662 + version = "0.1.0"
10663 +
10664 + [[patch.unused]]
10665 + name = "quasi-webview"
10666 + version = "0.1.0"
10667 +
10644 10668 [[patch.unused]]
10645 10669 name = "synckit-client"
10646 10670 version = "0.8.0"
@@ -10656,19 +10680,3 @@
10656 10680 [[patch.unused]]
10657 10681 name = "painhours"
10658 10682 version = "0.1.0"
10659 -
10660 - [[patch.unused]]
10661 - name = "quasi-http"
10662 - version = "0.1.0"
10663 -
10664 - [[patch.unused]]
10665 - name = "quasi-router"
10666 - version = "0.1.0"
10667 -
10668 - [[patch.unused]]
10669 - name = "quasi-tauri"
10670 - version = "0.1.0"
10671 -
10672 - [[patch.unused]]
10673 - name = "quasi-webview"
10674 - version = "0.1.0"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.11.12"
3 + version = "0.11.13"
4 4 edition = "2024"
5 5 license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6 6 # Server binary: never published to a registry. Marks the crate private so
@@ -6,7 +6,7 @@
6 6 "license": {
7 7 "name": "PolyForm Noncommercial 1.0.0"
8 8 },
9 - "version": "0.11.12"
9 + "version": "0.11.13"
10 10 },
11 11 "paths": {
12 12 "/api/git/{owner}/{repo}/notes": {
@@ -55,22 +55,22 @@
55 55 const DISPLAY_NAME: &str = "Demo Collector";
56 56
57 57 /// One purchase in the demo buyer's history.
58 - struct PurchaseSpec {
58 + pub(super) struct PurchaseSpec {
59 59 /// Item title, matched against the seeded catalog. Titles are unique within
60 60 /// a project and, across this roster, unique overall.
61 - title: &'static str,
61 + pub(super) title: &'static str,
62 62 /// Days before the seed run to date the purchase. Spread on purpose: a
63 63 /// library where every row says the same timestamp reads as a fixture, and
64 64 /// the list is ordered by date, so the spread is what gives it a shape.
65 - days_ago: i64,
65 + pub(super) days_ago: i64,
66 66 /// Cents paid above the pay-what-you-want minimum. Ignored for fixed-price
67 67 /// and free items. A buyer who always pays exactly the floor is a buyer
68 68 /// nobody recognises.
69 - tip_cents: i32,
69 + pub(super) tip_cents: i32,
70 70 /// Whether the buyer has already downloaded the current version. `false`
71 71 /// leaves the "new version" badge lit, which is worth showing on one or two
72 72 /// rows and noise on all of them.
73 - downloaded: bool,
73 + pub(super) downloaded: bool,
74 74 }
75 75
76 76 /// Nine of the eleven seeded items, spanning every purchasable type.
@@ -78,7 +78,7 @@
78 78 /// Not all eleven: a library holding the entire catalog reads as seeded data
79 79 /// rather than as somebody's shelf. "Weekly-Review Template" and "Typesetting
80 80 /// the Commons" are deliberately left unbought.
81 - const PURCHASES: &[PurchaseSpec] = &[
81 + pub(super) const PURCHASES: &[PurchaseSpec] = &[
82 82 PurchaseSpec {
83 83 title: "Restoration No. 1 (Full Mix)",
84 84 days_ago: 2,
@@ -194,8 +194,15 @@
194 194 };
195 195 let purchased_at = Utc::now() - Duration::days(spec.days_ago);
196 196 let amount_cents = amount_for(item, spec);
197 - let transaction_id =
198 - record_purchase(pool, project, item, amount_cents, purchased_at).await?;
197 + let transaction_id = super::sales::record_purchase(
198 + pool,
199 + project,
200 + item,
201 + BUYER_ACCOUNT_ID,
202 + amount_cents,
203 + purchased_at,
204 + )
205 + .await?;
199 206 if project.spec.features.contains(&"license_keys") {
200 207 issue_license_key(pool, item.id, transaction_id, spec.days_ago).await?;
201 208 }
@@ -301,58 +308,6 @@
301 308 Ok(())
302 309 }
303 310
304 - /// Record one completed transaction, which is what the `purchases` view reads.
305 - ///
306 - /// `platform_fee_cents` is zero and that is not a placeholder: MNW's platform
307 - /// fee is 0%, so a demo receipt showing anything else would misrepresent the
308 - /// product. The Stripe ids are fabricated and marked `demo_`; nothing on testnot
309 - /// talks to live Stripe, and the prefix makes a stray row obvious.
310 - ///
311 - /// The currency comes off the seller rather than being hardcoded. `transactions`
312 - /// constrains it to a lowercase supported code, and the real payment path
313 - /// settles in the seller's currency, so reading it keeps a demo receipt true to
314 - /// what a live one would say if a seeded creator is ever given a non-USD
315 - /// settlement currency.
316 - async fn record_purchase(
317 - pool: &sqlx::PgPool,
318 - project: &SeededProject,
319 - item: &db::DbItem,
320 - amount_cents: i32,
321 - purchased_at: DateTime<Utc>,
322 - ) -> Result<Uuid, SeedError> {
323 - let (seller_username, currency): (String, String) = sqlx::query_as(
324 - "SELECT username, lower(settlement_currency::text) FROM users WHERE id = $1",
325 - )
326 - .bind(project.user_id)
327 - .fetch_one(pool)
328 - .await?;
329 -
330 - let transaction_id: Uuid = sqlx::query_scalar(
331 - r"
332 - INSERT INTO transactions (
333 - buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
334 - currency, status, stripe_payment_intent_id,
335 - created_at, completed_at, item_title, seller_username
336 - )
337 - VALUES ($1, $2, $3, $4, 0, $5, 'completed', $6, $7, $7, $8, $9)
338 - RETURNING id
339 - ",
340 - )
341 - .bind(BUYER_ACCOUNT_ID)
342 - .bind(project.user_id)
343 - .bind(item.id)
344 - .bind(amount_cents)
345 - .bind(&currency)
346 - .bind(format!("pi_demo_{}", item.id))
347 - .bind(purchased_at)
348 - .bind(&item.title)
349 - .bind(&seller_username)
350 - .fetch_one(pool)
351 - .await?;
352 -
353 - Ok(transaction_id)
354 - }
355 -
356 311 /// Issue a license key for a purchase from a project that sells them.
357 312 ///
358 313 /// License keys are one of the things the library page shows and one of the
@@ -70,6 +70,27 @@
70 70 /// for Image items, whose cover *is* the work. An id that is declared but
71 71 /// uncurated falls back to the generated placeholder.
72 72 pub media: Option<&'static str>,
73 + /// Days before the seed run to date the item's release (`items.created_at`,
74 + /// rendered as "Released" on the item page and used by discover's newest
75 + /// sort).
76 + ///
77 + /// Without this every item carries the seed run's own timestamp, so the
78 + /// whole catalog reads "Released <today>" — the single clearest tell that
79 + /// nobody built this over time. Spread across the roster so each project has
80 + /// a release history rather than a launch day.
81 + ///
82 + /// Must predate any purchase of the item; `demo_buyer_purchases_postdate_release`
83 + /// in [`super::sales`] holds that.
84 + pub released_days_ago: i64,
85 + /// How many background buyers bought this item ([`super::sales`]).
86 + ///
87 + /// *Background*: the demo buyer's own purchases are counted on top of this,
88 + /// so an item they bought displays `other_sales + 1`. Zero is allowed and is
89 + /// worth keeping on a new release.
90 + ///
91 + /// Capped by the background-buyer pool, since one buyer cannot buy the same
92 + /// item twice (`idx_transactions_buyer_item_completed`).
93 + pub other_sales: u8,
73 94 /// Manifest id of the item's cover art. `None` keeps the grey placeholder.
74 95 pub cover: Option<&'static str>,
75 96 }
@@ -112,15 +133,15 @@
112 133 }
113 134
114 135 /// One fabricated creator plus the project they own.
115 - struct CreatorSpec {
136 + pub(super) struct CreatorSpec {
116 137 /// Login handle and local-part of `{handle}@example.test`.
117 - handle: &'static str,
138 + pub(super) handle: &'static str,
118 139 /// Display name shown on the profile.
119 - display_name: &'static str,
140 + pub(super) display_name: &'static str,
120 141 /// Short bio, matter-of-fact, no pomp, no real-app names.
121 - bio: &'static str,
142 + pub(super) bio: &'static str,
122 143 /// The project this creator owns.
123 - project: ProjectSpec,
144 + pub(super) project: ProjectSpec,
124 145 }
125 146
126 147 /// A creator after insertion, paired with the project spec still to be seeded.
@@ -181,7 +202,7 @@
181 202 /// The five content creators. Their projects span all four `PricingKind`s and,
182 203 /// across their items, every `ItemType`. See the sprint doc
183 204 /// `_private/docs/mnw/testnot-example-seed.md`.
184 - const ROSTER: &[CreatorSpec] = &[
205 + pub(super) const ROSTER: &[CreatorSpec] = &[
185 206 CreatorSpec {
186 207 handle: "openreels",
187 208 display_name: "Open Reels",
@@ -221,6 +242,8 @@
221 242 tags: &["audio", "audio.format.music"],
222 243 body: None,
223 244 media: Some("restoration-1-audio"),
245 + released_days_ago: 148,
246 + other_sales: 7,
224 247 cover: Some("restoration-1-cover"),
225 248 },
226 249 ItemSpec {
@@ -232,6 +255,8 @@
232 255 tags: &["audio.format.samples", "audio.technique.sampling"],
233 256 body: None,
234 257 media: Some("stem-pack-strings-audio"),
258 + released_days_ago: 121,
259 + other_sales: 4,
235 260 cover: Some("stem-pack-strings-cover"),
236 261 },
237 262 ItemSpec {
@@ -243,6 +268,8 @@
243 268 tags: &["video", "video.genre.music-video"],
244 269 body: None,
245 270 media: Some("session-take-video"),
271 + released_days_ago: 96,
272 + other_sales: 2,
246 273 cover: Some("session-take-cover"),
247 274 },
248 275 ItemSpec {
@@ -254,6 +281,8 @@
254 281 tags: &["audio", "audio.format.music"],
255 282 body: None,
256 283 media: Some("restoration-2-audio"),
284 + released_days_ago: 54,
285 + other_sales: 3,
257 286 cover: Some("restoration-2-cover"),
258 287 },
259 288 ItemSpec {
@@ -265,6 +294,8 @@
265 294 tags: &["audio.format.samples", "audio.technique.sampling"],
266 295 body: None,
267 296 media: None,
297 + released_days_ago: 27,
298 + other_sales: 1,
268 299 cover: Some("stem-pack-brass-cover"),
269 300 },
270 301 ],
@@ -310,6 +341,8 @@
310 341 tags: &["software.format.plugin", "software.format.vst3"],
311 342 body: None,
312 343 media: None,
344 + released_days_ago: 209,
345 + other_sales: 9,
313 346 cover: Some("deskriver-focus-cover"),
314 347 },
315 348 ItemSpec {
@@ -321,6 +354,8 @@
321 354 tags: &["software", "software.platform.macos"],
322 355 body: None,
323 356 media: None,
357 + released_days_ago: 176,
358 + other_sales: 5,
324 359 cover: Some("deskriver-presets-cover"),
325 360 },
326 361 ItemSpec {
@@ -332,6 +367,8 @@
332 367 tags: &["writing.topic.productivity", "software"],
333 368 body: None,
334 369 media: None,
370 + released_days_ago: 132,
371 + other_sales: 6,
335 372 cover: Some("deskriver-template-cover"),
336 373 },
337 374 ItemSpec {
@@ -347,6 +384,8 @@
347 384 ],
348 385 body: None,
349 386 media: None,
387 + released_days_ago: 88,
388 + other_sales: 11,
350 389 cover: Some("deskriver-utility-cover"),
351 390 },
352 391 ItemSpec {
@@ -358,6 +397,8 @@
358 397 tags: &["software.format.plugin", "software.platform.macos"],
359 398 body: None,
360 399 media: None,
400 + released_days_ago: 41,
401 + other_sales: 6,
361 402 cover: Some("deskriver-notes-cover"),
362 403 },
363 404 ],
@@ -399,6 +440,8 @@
399 440 tags: &["visual.medium.photography", "visual"],
400 441 body: None,
401 442 media: None,
443 + released_days_ago: 243,
444 + other_sales: 12,
402 445 cover: Some("field-study-01-cover"),
403 446 },
404 447 ItemSpec {
@@ -410,6 +453,8 @@
410 453 tags: &["visual.medium.photography", "visual"],
411 454 body: None,
412 455 media: None,
456 + released_days_ago: 198,
457 + other_sales: 9,
413 458 cover: Some("field-study-02-cover"),
414 459 },
415 460 ItemSpec {
@@ -421,6 +466,8 @@
421 466 tags: &["visual.medium.photography", "visual"],
422 467 body: None,
423 468 media: None,
469 + released_days_ago: 155,
470 + other_sales: 7,
424 471 cover: Some("field-study-03-cover"),
425 472 },
426 473 ItemSpec {
@@ -432,6 +479,8 @@
432 479 tags: &["visual.medium.photography", "visual"],
433 480 body: None,
434 481 media: None,
482 + released_days_ago: 73,
483 + other_sales: 5,
435 484 cover: Some("still-life-01-cover"),
436 485 },
437 486 ItemSpec {
@@ -443,6 +492,8 @@
443 492 tags: &["visual.medium.photography", "visual"],
444 493 body: None,
445 494 media: None,
495 + released_days_ago: 31,
496 + other_sales: 2,
446 497 cover: Some("still-life-02-cover"),
447 498 },
448 499 ],
@@ -501,6 +552,8 @@
501 552 tags: &["writing.format.essay", "writing.topic.creativity"],
502 553 body: Some(SLOW_READING_BODY),
503 554 media: None,
555 + released_days_ago: 167,
556 + other_sales: 8,
504 557 cover: Some("on-slow-reading-cover"),
505 558 },
506 559 ItemSpec {
@@ -512,6 +565,8 @@
512 565 tags: &["education.format.course", "education.topic.writing"],
513 566 body: None,
514 567 media: None,
568 + released_days_ago: 139,
569 + other_sales: 4,
515 570 cover: Some("typesetting-commons-cover"),
516 571 },
517 572 ItemSpec {
@@ -523,6 +578,8 @@
523 578 tags: &["writing.format.essay", "writing"],
524 579 body: Some(READER_ONE_BODY),
525 580 media: None,
581 + released_days_ago: 104,
582 + other_sales: 6,
526 583 cover: Some("reader-one-cover"),
527 584 },
528 585 ItemSpec {
@@ -534,6 +591,8 @@
534 591 tags: &["writing.format.essay", "writing"],
535 592 body: Some(READER_TWO_BODY),
536 593 media: None,
594 + released_days_ago: 68,
595 + other_sales: 3,
537 596 cover: Some("reader-two-cover"),
538 597 },
539 598 ItemSpec {
@@ -545,6 +604,8 @@
545 604 tags: &["writing.format.essay", "writing.topic.creativity"],
546 605 body: Some(ON_EDITIONS_BODY),
547 606 media: None,
607 + released_days_ago: 22,
608 + other_sales: 1,
548 609 cover: Some("on-editions-cover"),
549 610 },
550 611 ],
@@ -587,6 +648,8 @@
587 648 tags: &["audio", "visual"],
588 649 body: None,
589 650 media: None,
651 + released_days_ago: 187,
652 + other_sales: 10,
590 653 cover: Some("community-bundle-cover"),
591 654 },
592 655 ItemSpec {
@@ -598,6 +661,8 @@
598 661 tags: &["audio", "visual"],
599 662 body: None,
600 663 media: None,
664 + released_days_ago: 129,
665 + other_sales: 6,
601 666 cover: Some("community-bundle-2-cover"),
602 667 },
603 668 ItemSpec {
@@ -609,6 +674,8 @@
609 674 tags: &["audio.format.samples", "audio"],
610 675 body: None,
611 676 media: None,
677 + released_days_ago: 112,
678 + other_sales: 8,
612 679 cover: Some("field-recordings-cover"),
613 680 },
614 681 ItemSpec {
@@ -620,6 +687,8 @@
620 687 tags: &["audio.format.samples", "audio.technique.sampling"],
621 688 body: None,
622 689 media: None,
690 + released_days_ago: 79,
691 + other_sales: 5,
623 692 cover: Some("pd-loops-cover"),
624 693 },
625 694 ItemSpec {
@@ -631,6 +700,8 @@
631 700 tags: &["writing", "writing.format.essay"],
632 701 body: None,
633 702 media: None,
703 + released_days_ago: 35,
704 + other_sales: 2,
634 705 cover: Some("commons-reader-cover"),
635 706 },
636 707 ],
@@ -115,6 +115,8 @@
115 115 db::items::update_item_text(pool, item.id, seeded.user_id, body).await?;
116 116 }
117 117
118 + backdate_release(pool, item.id, spec.released_days_ago).await?;
119 +
118 120 // Keep the item hidden until Phase 3 attaches media. `items.scan_status`
119 121 // defaults to 'clean' (migration 146), so an unfinished, file-less item would
120 122 // otherwise show in discover with nothing to download.
@@ -139,3 +141,23 @@
139 141 );
140 142 Ok(())
141 143 }
144 +
145 + /// Date the item's release, which `create_item` sets to now.
146 + ///
147 + /// `items.created_at` is what the item page renders as "Released" and what
148 + /// discover sorts by, so leaving it at the seed run's timestamp gives every item
149 + /// in the catalog the same release date. There is no authoring path for this
150 + /// (a real creator's item really was created when they created it), hence the
151 + /// direct write rather than a `db::items` call.
152 + async fn backdate_release(
153 + pool: &sqlx::PgPool,
154 + item_id: db::ItemId,
155 + days_ago: i64,
156 + ) -> Result<(), SeedError> {
157 + sqlx::query("UPDATE items SET created_at = NOW() - ($2 || ' days')::interval WHERE id = $1")
158 + .bind(item_id)
159 + .bind(days_ago.to_string())
160 + .execute(pool)
161 + .await?;
162 + Ok(())
163 + }
@@ -38,6 +38,7 @@
38 38 pub mod manifest;
39 39 pub mod media;
40 40 pub mod projects;
41 + pub mod sales;
41 42 pub mod social;
42 43
43 44 use std::sync::Arc;
@@ -229,6 +230,12 @@
229 230 ),
230 231 }
231 232
233 + // Sales phase: the purchase history behind every item's "Sales" figure, and
234 + // the reconcile that makes `items.sales_count` agree with it. After the
235 + // buyer phase so it counts those purchases too, and unconditional because it
236 + // needs no credential.
237 + sales::seed_sales(pool, &projects).await?;
238 +
232 239 Ok(())
233 240 }
234 241
@@ -43,11 +43,18 @@
43 43 }
44 44 }
45 45
46 + /// Seeded creators and harness accounts, which is to say every example account
47 + /// except the background-buyer pool. Those are seeded by `seed::sales` purely to
48 + /// own transactions and are counted by
49 + /// [`every_item_reports_the_sales_it_actually_has`] instead; folding them in here
50 + /// would make the roster size a function of how many sales the demo shows.
46 51 pub(super) async fn count_example_creators(pool: &sqlx::PgPool) -> i64 {
47 52 sqlx::query_scalar(
48 53 "SELECT COUNT(*) FROM users \
49 - WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE",
54 + WHERE lower(split_part(email, '@', 2)) = 'example.test' AND is_sandbox = FALSE \
55 + AND username NOT LIKE $1",
50 56 )
57 + .bind(format!("{}%", seed::sales::BUYER_HANDLE_PREFIX))
51 58 .fetch_one(pool)
52 59 .await
53 60 .expect("count example creators")
@@ -266,6 +273,76 @@
266 273 );
267 274 }
268 275
276 + #[tokio::test]
277 + async fn every_item_reports_the_sales_it_actually_has() {
278 + let db = TestDb::new().await;
279 +
280 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
281 + .await
282 + .expect("seed should succeed");
283 +
284 + // The exact query `scheduler::integrity::check_sales_count_drift` runs. It
285 + // pages WAM on every row it returns, so a demo whose sales figures were
286 + // written rather than earned would alert forever.
287 + let drifted: Vec<(String, i32, i64)> = sqlx::query_as(
288 + r"
289 + SELECT i.title, i.sales_count, COUNT(t.id)
290 + FROM items i
291 + LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'
292 + GROUP BY i.id, i.title, i.sales_count
293 + HAVING i.sales_count != COUNT(t.id)
294 + ",
295 + )
296 + .fetch_all(&db.pool)
297 + .await
298 + .expect("drift query");
299 +
300 + assert!(
301 + drifted.is_empty(),
302 + "sales_count disagrees with the transactions behind it: {drifted:?}"
303 + );
304 +
305 + // And the figure is not uniformly zero, which is the state this phase
306 + // exists to leave behind.
307 + let sold: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM items WHERE sales_count > 0")
308 + .fetch_one(&db.pool)
309 + .await
310 + .expect("count of items with sales");
311 + assert!(sold > 0, "no item reports a single sale");
312 +
313 + // The pool those transactions belong to. Counted here because
314 + // `count_example_creators` deliberately excludes it.
315 + let buyers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username LIKE $1")
316 + .bind(format!("{}%", seed::sales::BUYER_HANDLE_PREFIX))
317 + .fetch_one(&db.pool)
318 + .await
319 + .expect("count background buyers");
320 + assert_eq!(buyers, seed::sales::BUYER_POOL as i64);
321 + }
322 +
323 + #[tokio::test]
324 + async fn no_item_is_released_on_the_day_the_seed_ran() {
325 + let db = TestDb::new().await;
326 +
327 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
328 + .await
329 + .expect("seed should succeed");
330 +
331 + // `items.created_at` is what the item page renders as "Released". A catalog
332 + // that all came out today is the clearest tell that it was generated.
333 + let stamped: Vec<String> = sqlx::query_scalar(
334 + "SELECT title FROM items WHERE created_at > NOW() - INTERVAL '1 day' ORDER BY title",
335 + )
336 + .fetch_all(&db.pool)
337 + .await
338 + .expect("release dates");
339 +
340 + assert!(
341 + stamped.is_empty(),
342 + "these items carry the seed run's own date as their release: {stamped:?}"
343 + );
344 + }
345 +
269 346 #[tokio::test]
270 347 async fn seed_is_idempotent() {
271 348 let db = TestDb::new().await;
@@ -1,0 +1,365 @@
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 + }