Skip to main content

max / makenotwork

payments: seal StripeAccountId + cart ItemType, add DB-layer tests Drive Payments A- -> A+ (ultra-fuzz Run 7 --deep). Codebase-wide StripeAccountId newtype: the in-memory and DB representation of a creator's connected account across every payout path (DbUser, DbProjectMemberWithUser, CartItem, the User view type), converting to &str only at the Stripe wire boundary. create_connect_account and the fan-out helper carry the newtype; validate_stripe_account_id checks the acct_ shape at input boundaries. Retires the bare String that let any string stand in for a connected-account id. Seal CartItem.item_type as the existing ItemType enum (the lone holdout; the type already backs items/discover/tags). DB-layer tests for the Payments cold-spot modules (tips/license_keys/ pending_refunds had zero direct coverage): pending-refund claim/unclaim/ complete lifecycle + crash-window sweep, tip create-guard + complete/refund idempotency, license-key finalize idempotency + activation-cap enforcement. Widen those three db modules to pub mod so the integration crate can pin their contracts directly. Gate: cargo clippy --all-targets clean, cargo test --lib (1764), cargo test --test integration (983). sqlx offline unchanged (no macro SQL touched). No version bump, no deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-25 00:07 UTC
Commit: b412e48d45a8cee0c81fe20a14136f8a4d0594d0
Parent: 832b7b7
19 files changed, +336 insertions, -26 deletions
@@ -3,7 +3,8 @@
3 3 use chrono::{DateTime, Duration, Utc};
4 4 use sqlx::PgPool;
5 5
6 - use super::{ItemId, ProjectId, UserId};
6 + use super::validated_types::StripeAccountId;
7 + use super::{ItemId, ItemType, ProjectId, UserId};
7 8 use crate::error::Result;
8 9
9 10
@@ -135,7 +136,7 @@ pub struct CartItem {
135 136 /// a `get_item_by_id` per item at checkout.
136 137 pub project_id: ProjectId,
137 138 pub title: String,
138 - pub item_type: String,
139 + pub item_type: ItemType,
139 140 pub price_cents: i32,
140 141 pub pwyw_enabled: bool,
141 142 pub pwyw_min_cents: Option<i32>,
@@ -143,7 +144,7 @@ pub struct CartItem {
143 144 pub amount_cents: Option<i32>,
144 145 pub creator_username: String,
145 146 pub seller_id: UserId,
146 - pub seller_stripe_account_id: Option<String>,
147 + pub seller_stripe_account_id: Option<StripeAccountId>,
147 148 pub seller_charges_enabled: bool,
148 149 pub project_slug: String,
149 150 pub added_at: DateTime<Utc>,
@@ -333,7 +334,7 @@ mod tests {
333 334 item_id: ItemId::nil(),
334 335 project_id: ProjectId::nil(),
335 336 title: String::new(),
336 - item_type: String::from("audio"),
337 + item_type: ItemType::Audio,
337 338 price_cents,
338 339 pwyw_enabled,
339 340 pwyw_min_cents,
@@ -23,7 +23,7 @@ pub(crate) mod custom_links;
23 23 pub(crate) mod auth;
24 24 pub mod waitlist;
25 25 pub(crate) mod blog_posts;
26 - pub(crate) mod license_keys;
26 + pub mod license_keys;
27 27 pub mod synckit; // pub so the integration test crate can exercise compaction directly
28 28 pub mod synckit_billing;
29 29 pub(crate) mod oauth;
@@ -62,10 +62,10 @@ pub mod bundles;
62 62 pub(crate) mod email_signups;
63 63 pub(crate) mod imports;
64 64 pub(crate) mod media_files;
65 - pub(crate) mod tips;
65 + pub mod tips;
66 66 pub(crate) mod project_members;
67 67 pub mod idempotency; // pub so the integration test crate can exercise it directly
68 - pub(crate) mod pending_refunds;
68 + pub mod pending_refunds;
69 69 pub mod webhook_events;
70 70 pub(crate) mod scheduler_jobs;
71 71 pub(crate) mod moderation;
@@ -5,7 +5,7 @@ use serde::Serialize;
5 5 use sqlx::FromRow;
6 6
7 7 use super::super::id_types::*;
8 - use super::super::validated_types::Cents;
8 + use super::super::validated_types::{Cents, StripeAccountId};
9 9
10 10 /// A one-time tip from a fan to a creator.
11 11 #[derive(Debug, Clone, FromRow, Serialize)]
@@ -78,6 +78,6 @@ pub struct DbProjectMemberWithUser {
78 78 pub added_at: DateTime<Utc>,
79 79 pub username: String,
80 80 pub display_name: Option<String>,
81 - pub stripe_account_id: Option<String>,
81 + pub stripe_account_id: Option<StripeAccountId>,
82 82 pub stripe_charges_enabled: bool,
83 83 }
@@ -76,7 +76,7 @@ pub struct DbUser {
76 76 pub updated_at: DateTime<Utc>,
77 77 // Stripe Connect fields (see struct-level doc for state machine)
78 78 /// Stripe Connect account ID (e.g. `acct_...`). None = not connected.
79 - pub stripe_account_id: Option<String>,
79 + pub stripe_account_id: Option<StripeAccountId>,
80 80 /// Whether Stripe onboarding has been completed. Only meaningful when `stripe_account_id` is Some.
81 81 pub stripe_onboarding_complete: bool,
82 82 /// Whether Stripe payouts are enabled. Only meaningful when `stripe_onboarding_complete` is true.
@@ -300,7 +300,7 @@ mod tests {
300 300 custom_pages_locked: false,
301 301 created_at: Utc::now(),
302 302 updated_at: Utc::now(),
303 - stripe_account_id: account_id.map(|s| s.to_string()),
303 + stripe_account_id: account_id.map(|s| StripeAccountId::from_trusted(s.to_string())),
304 304 stripe_onboarding_complete: onboarding,
305 305 stripe_payouts_enabled: payouts,
306 306 stripe_charges_enabled: false,
@@ -119,6 +119,12 @@ define_pg_string_newtype!(
119 119 KeyCode => crate::validation::validate_key_code,
120 120 Slug => crate::validation::validate_slug,
121 121 Username => crate::validation::validate_username,
122 + // StripeAccountId: Stripe Connect account ID (`acct_...`). The in-memory and
123 + // DB representation of a creator's connected account across every payout
124 + // path; convert to `&str` (via `Deref`/`as_str`) only at the Stripe wire
125 + // boundary. `new()` validates the `acct_` shape; DB reads use `from_trusted`
126 + // (Stripe is the source of truth for the stored value).
127 + StripeAccountId => crate::validation::validate_stripe_account_id,
122 128 );
123 129
124 130 // ── Email ──
@@ -16,6 +16,7 @@ use stripe_product::product::CreateProduct;
16 16 use stripe_product::price::{CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval};
17 17 use stripe_types::Currency;
18 18
19 + use crate::db::StripeAccountId;
19 20 use crate::error::{AppError, Result};
20 21 use super::StripeClient;
21 22
@@ -28,7 +29,7 @@ fn parse_subscription_id(stripe_sub_id: &str) -> Result<stripe_shared::Subscript
28 29 impl StripeClient {
29 30 /// Create a Stripe Standard connected account for a creator.
30 31 #[tracing::instrument(skip_all, name = "payments::create_connect_account")]
31 - pub async fn create_connect_account(&self, email: &str) -> Result<String> {
32 + pub async fn create_connect_account(&self, email: &str) -> Result<StripeAccountId> {
32 33 let account = CreateAccount::new()
33 34 .type_(CreateAccountType::Standard)
34 35 .email(email.to_string())
@@ -38,7 +39,8 @@ impl StripeClient {
38 39 tracing::error!(error = ?e, "failed to create Stripe connected account");
39 40 AppError::BadRequest("Failed to create Stripe account".to_string())
40 41 })?;
41 - Ok(account.id.to_string())
42 + // Stripe minted this id; trust its shape rather than re-validating.
43 + Ok(StripeAccountId::from_trusted(account.id.to_string()))
42 44 }
43 45
44 46 /// Create an Account Link for Stripe Connect onboarding.
@@ -13,6 +13,7 @@
13 13 use std::sync::Arc;
14 14
15 15 use crate::background::BackgroundTx;
16 + use crate::db::StripeAccountId;
16 17 use crate::payments::PaymentProvider;
17 18
18 19 /// Which Stripe subscription operation to apply to each fan subscription.
@@ -62,7 +63,7 @@ impl FanSubOp {
62 63 pub fn spawn_fan_sub_fanout(
63 64 bg: &BackgroundTx,
64 65 stripe: Arc<dyn PaymentProvider>,
65 - account_id: String,
66 + account_id: StripeAccountId,
66 67 sub_ids: Vec<String>,
67 68 op: FanSubOp,
68 69 ) {
@@ -73,7 +74,7 @@ pub fn spawn_fan_sub_fanout(
73 74 let total = sub_ids.len();
74 75 let mut failed = 0u32;
75 76 for sub_id in &sub_ids {
76 - if let Err(e) = op.apply(&stripe, sub_id, &account_id).await {
77 + if let Err(e) = op.apply(&stripe, sub_id, account_id.as_str()).await {
77 78 failed += 1;
78 79 tracing::warn!(stripe_sub_id = %sub_id, op = op.label(), error = ?e, "fan subscription op failed");
79 80 }
@@ -88,7 +88,7 @@ pub trait PaymentProvider: Send + Sync {
88 88 async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>;
89 89
90 90 // Connect
91 - async fn create_connect_account(&self, email: &str) -> crate::error::Result<String>;
91 + async fn create_connect_account(&self, email: &str) -> crate::error::Result<crate::db::StripeAccountId>;
92 92 async fn create_account_link(&self, account_id: &str, return_url: &str, refresh_url: &str) -> crate::error::Result<String>;
93 93 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate>;
94 94 async fn create_subscription_product_and_price(&self, connected_account_id: &str, tier_name: &str, tier_description: Option<&str>, price_cents: i64) -> crate::error::Result<(String, String)>;
@@ -177,7 +177,7 @@ impl PaymentProvider for StripeClient {
177 177 Ok(CheckoutResult { id: session.id.to_string(), url: session.url })
178 178 }
179 179
180 - async fn create_connect_account(&self, email: &str) -> crate::error::Result<String> {
180 + async fn create_connect_account(&self, email: &str) -> crate::error::Result<crate::db::StripeAccountId> {
181 181 StripeClient::create_connect_account(self, email).await
182 182 }
183 183
@@ -123,7 +123,7 @@ pub(super) async fn create_guest_checkout(
123 123 crate::payments::check_min_charge(final_price_cents as i64)?;
124 124
125 125 // Verify seller has Stripe configured
126 - let stripe_account_id = seller.stripe_account_id.as_ref()
126 + let stripe_account_id = seller.stripe_account_id.as_deref()
127 127 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
128 128 if !seller.stripe_charges_enabled {
129 129 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
@@ -87,7 +87,7 @@ pub(super) async fn create_tier(
87 87 .await?
88 88 .ok_or(AppError::NotFound)?;
89 89
90 - let stripe_account_id = creator.stripe_account_id.as_ref()
90 + let stripe_account_id = creator.stripe_account_id.as_deref()
91 91 .ok_or_else(|| AppError::BadRequest("Connect your Stripe account before creating subscription tiers".to_string()))?;
92 92
93 93 if !creator.stripe_charges_enabled {
@@ -370,7 +370,7 @@ async fn checkout_seller_cart(
370 370
371 371 // Verify Stripe is ready and the total clears the minimum BEFORE reserving
372 372 // the promo, so neither reject burns a single-use code.
373 - let stripe_account_id = seller.stripe_account_id.as_ref()
373 + let stripe_account_id = seller.stripe_account_id.as_deref()
374 374 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
375 375 if !seller.stripe_charges_enabled {
376 376 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
@@ -250,7 +250,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
250 250 // Validate Stripe-readiness BEFORE reserving the promo code use_count.
251 251 // The original order (reserve → readiness checks) burned a use of a
252 252 // single-use code every time a buyer hit a creator who lost charges_enabled.
253 - let stripe_account_id = seller.stripe_account_id.as_ref()
253 + let stripe_account_id = seller.stripe_account_id.as_deref()
254 254 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
255 255
256 256 if !seller.stripe_charges_enabled {
@@ -118,7 +118,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
118 118 // Stripe checkout
119 119 let stripe_account_id = seller
120 120 .stripe_account_id
121 - .as_ref()
121 + .as_deref()
122 122 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
123 123
124 124 if !seller.stripe_charges_enabled {
@@ -381,7 +381,7 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
381 381 }
382 382
383 383 // Verify creator has Stripe connected
384 - let stripe_account_id = creator.stripe_account_id.as_ref()
384 + let stripe_account_id = creator.stripe_account_id.as_deref()
385 385 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
386 386
387 387 if !creator.stripe_charges_enabled {
@@ -1,6 +1,7 @@
1 1 //! View types for user-facing surfaces: profile, waitlist, invites.
2 2
3 3 use super::json_escape;
4 + use crate::db::StripeAccountId;
4 5
5 6 /// User profile data
6 7 #[derive(Clone)]
@@ -13,7 +14,7 @@ pub struct User {
13 14 pub avatar_url: Option<String>,
14 15 // Stripe Connect status
15 16 pub stripe_connected: bool,
16 - pub stripe_account_id: Option<String>,
17 + pub stripe_account_id: Option<StripeAccountId>,
17 18 pub stripe_onboarding_complete: bool,
18 19 pub stripe_payouts_enabled: bool,
19 20 pub stripe_charges_enabled: bool,
@@ -4,6 +4,26 @@ use crate::constants;
4 4 use crate::error::AppError;
5 5 use super::limits;
6 6
7 + /// Validate a Stripe Connect account ID.
8 + ///
9 + /// Stripe Connect account IDs are `acct_` followed by an alphanumeric token.
10 + /// We only ever store values Stripe minted for us (via `create_connect_account`
11 + /// or the account webhook), so this is a shape check that fences off obviously
12 + /// wrong values entering the [`crate::db::validated_types::StripeAccountId`]
13 + /// newtype at an input boundary — not a claim of Stripe-side existence.
14 + pub fn validate_stripe_account_id(id: &str) -> Result<(), AppError> {
15 + if !id.starts_with("acct_")
16 + || id.len() < 6
17 + || id.len() > 255
18 + || !id["acct_".len()..].chars().all(|c| c.is_ascii_alphanumeric())
19 + {
20 + return Err(AppError::validation(
21 + "Invalid Stripe account ID".to_string(),
22 + ));
23 + }
24 + Ok(())
25 + }
26 +
7 27 /// Validate a subscription tier name
8 28 pub fn validate_tier_name(name: &str) -> Result<(), AppError> {
9 29 if name.is_empty() {
@@ -155,8 +155,8 @@ impl PaymentProvider for MockPaymentProvider {
155 155 Ok(self.next_session())
156 156 }
157 157
158 - async fn create_connect_account(&self, _email: &str) -> Result<String> {
159 - Ok("acct_test_mock".to_string())
158 + async fn create_connect_account(&self, _email: &str) -> Result<makenotwork::db::StripeAccountId> {
159 + Ok(makenotwork::db::StripeAccountId::from_trusted("acct_test_mock".to_string()))
160 160 }
161 161
162 162 async fn create_account_link(&self, _account_id: &str, _return_url: &str, _refresh_url: &str) -> Result<String> {
@@ -0,0 +1,278 @@
1 + //! DB-layer contract tests for the payment cold-spot modules (`db::tips`,
2 + //! `db::license_keys`, `db::pending_refunds`).
3 + //!
4 + //! Ultra-fuzz Run 7 flagged these three modules as the Payments cold spot:
5 + //! correct, but with their DB-layer contracts asserted only indirectly through
6 + //! HTTP/webhook flows. These tests call the `db::` functions directly so the
7 + //! claim/unclaim/complete lifecycle, the activation cap, and the
8 + //! finalize-idempotency are pinned at the layer they live in.
9 +
10 + use crate::harness::TestHarness;
11 + use makenotwork::db::{self, KeyCode};
12 +
13 + // ── db::pending_refunds — claim/unclaim/complete lifecycle (PAY-S1) ──
14 +
15 + #[tokio::test]
16 + async fn pending_refund_claim_is_single_shot_and_completes() {
17 + let h = TestHarness::new().await;
18 + let pi = "pi_dbpl_single_001";
19 +
20 + db::pending_refunds::insert_pending_refund(&h.db, pi, 999, 999).await.unwrap();
21 +
22 + // First claim matches the row; a second claim finds nothing (matched_at set).
23 + let first = db::pending_refunds::claim_pending_refund(&h.db, pi).await.unwrap();
24 + let claimed = first.expect("first claim must match the unclaimed row");
25 + assert_eq!(claimed.payment_intent_id, pi);
26 + let second = db::pending_refunds::claim_pending_refund(&h.db, pi).await.unwrap();
27 + assert!(second.is_none(), "a claimed refund must not be claimable twice");
28 +
29 + // Completing it removes it from the stale sweep.
30 + db::pending_refunds::mark_refund_completed(&h.db, claimed.id).await.unwrap();
31 + let stale = db::pending_refunds::get_stale_refunds(&h.db, chrono::Duration::zero())
32 + .await
33 + .unwrap();
34 + assert!(
35 + !stale.iter().any(|r| r.payment_intent_id == pi),
36 + "a completed refund must not surface in the stale sweep"
37 + );
38 + }
39 +
40 + #[tokio::test]
41 + async fn pending_refund_unclaim_reopens_for_retry() {
42 + let h = TestHarness::new().await;
43 + let pi = "pi_dbpl_reopen_001";
44 +
45 + db::pending_refunds::insert_pending_refund(&h.db, pi, 500, 500).await.unwrap();
46 + let claimed = db::pending_refunds::claim_pending_refund(&h.db, pi)
47 + .await
48 + .unwrap()
49 + .expect("claim");
50 +
51 + // A graceful failure unclaims; the row is then re-claimable.
52 + db::pending_refunds::unclaim_pending_refund(&h.db, claimed.id).await.unwrap();
53 + let reclaim = db::pending_refunds::claim_pending_refund(&h.db, pi).await.unwrap();
54 + assert!(reclaim.is_some(), "an unclaimed refund must be re-claimable");
55 + }
56 +
57 + #[tokio::test]
58 + async fn pending_refund_claimed_but_uncompleted_is_swept() {
59 + let h = TestHarness::new().await;
60 + let pi = "pi_dbpl_crash_001";
61 +
62 + // Claim but never complete — the crash window. Backdate created_at so the
63 + // age filter surfaces it (insert_pending_refund stamps created_at = NOW()).
64 + db::pending_refunds::insert_pending_refund(&h.db, pi, 700, 700).await.unwrap();
65 + let claimed = db::pending_refunds::claim_pending_refund(&h.db, pi)
66 + .await
67 + .unwrap()
68 + .expect("claim");
69 + sqlx::query("UPDATE pending_refunds SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1")
70 + .bind(claimed.id)
71 + .execute(&h.db)
72 + .await
73 + .unwrap();
74 +
75 + let stale = db::pending_refunds::get_stale_refunds(&h.db, chrono::Duration::hours(24))
76 + .await
77 + .unwrap();
78 + assert!(
79 + stale.iter().any(|r| r.payment_intent_id == pi),
80 + "a claimed-but-uncompleted refund must surface for human reconciliation (PAY-S1)"
81 + );
82 + }
83 +
84 + // ── db::tips — create guard + complete/refund idempotency ──
85 +
86 + /// Two bare users (tipper, recipient) for tip tests. Raw SQL keeps the fixture
87 + /// minimal — tips need only valid user FKs.
88 + async fn two_users(h: &TestHarness, tag: &str) -> (db::UserId, db::UserId) {
89 + let tipper: db::UserId = sqlx::query_scalar(
90 + "INSERT INTO users (username, email, password_hash, email_verified) \
91 + VALUES ($1, $2, 'x', true) RETURNING id",
92 + )
93 + .bind(format!("tipper_{tag}"))
94 + .bind(format!("tipper_{tag}@test.com"))
95 + .fetch_one(&h.db)
96 + .await
97 + .unwrap();
98 + let recipient: db::UserId = sqlx::query_scalar(
99 + "INSERT INTO users (username, email, password_hash, email_verified) \
100 + VALUES ($1, $2, 'x', true) RETURNING id",
101 + )
102 + .bind(format!("recip_{tag}"))
103 + .bind(format!("recip_{tag}@test.com"))
104 + .fetch_one(&h.db)
105 + .await
106 + .unwrap();
107 + (tipper, recipient)
108 + }
109 +
110 + #[tokio::test]
111 + async fn create_tip_rejects_nonpositive_amount() {
112 + let h = TestHarness::new().await;
113 + let (tipper, recipient) = two_users(&h, "guard").await;
114 +
115 + for amount in [0, -100] {
116 + let res = db::tips::create_tip(
117 + &h.db, tipper, recipient, None, amount, None, "cs_guard",
118 + )
119 + .await;
120 + assert!(res.is_err(), "tip amount {amount} must be rejected by the positivity guard");
121 + }
122 +
123 + // A positive amount goes through.
124 + let ok = db::tips::create_tip(
125 + &h.db, tipper, recipient, None, 500, Some("thanks"), "cs_guard_ok",
126 + )
127 + .await;
128 + assert!(ok.is_ok(), "a positive tip must be accepted");
129 + }
130 +
131 + #[tokio::test]
132 + async fn complete_tip_is_idempotent() {
133 + let h = TestHarness::new().await;
134 + let (tipper, recipient) = two_users(&h, "complete").await;
135 + let session = "cs_dbpl_complete_001";
136 +
137 + db::tips::create_tip(&h.db, tipper, recipient, None, 1500, None, session)
138 + .await
139 + .unwrap();
140 +
141 + // First completion transitions pending → completed and returns the row.
142 + let first = db::tips::complete_tip(&h.db, session, "pi_tip_complete_001")
143 + .await
144 + .unwrap();
145 + assert!(first.is_some(), "first completion must update the pending tip");
146 +
147 + // A redelivered webhook completing the same session is a no-op.
148 + let second = db::tips::complete_tip(&h.db, session, "pi_tip_complete_001")
149 + .await
150 + .unwrap();
151 + assert!(second.is_none(), "completing an already-completed tip must be idempotent");
152 +
153 + // The completed tip counts toward the recipient's totals.
154 + assert_eq!(db::tips::count_tips_received(&h.db, recipient).await.unwrap(), 1);
155 + assert_eq!(db::tips::total_tips_received(&h.db, recipient).await.unwrap(), 1500);
156 + }
157 +
158 + #[tokio::test]
159 + async fn refund_tip_is_idempotent_and_scoped() {
160 + let h = TestHarness::new().await;
161 + let (tipper, recipient) = two_users(&h, "refund").await;
162 + let session = "cs_dbpl_refund_001";
163 + let pi = "pi_tip_refund_001";
164 +
165 + db::tips::create_tip(&h.db, tipper, recipient, None, 800, None, session)
166 + .await
167 + .unwrap();
168 + db::tips::complete_tip(&h.db, session, pi).await.unwrap();
169 +
170 + // First refund flips completed → refunded.
171 + assert!(db::tips::refund_tip_by_payment_intent(&h.db, pi).await.unwrap());
172 + // Second refund of the same PI is a no-op (already refunded).
173 + assert!(!db::tips::refund_tip_by_payment_intent(&h.db, pi).await.unwrap());
174 + // An unknown PI never matches.
175 + assert!(!db::tips::refund_tip_by_payment_intent(&h.db, "pi_unknown").await.unwrap());
176 +
177 + // A refunded tip drops out of the received totals.
178 + assert_eq!(db::tips::count_tips_received(&h.db, recipient).await.unwrap(), 0);
179 + }
180 +
181 + // ── db::license_keys — finalize idempotency + activation cap ──
182 +
183 + /// A user/project/item plus a completed transaction. License-key tests need the
184 + /// full FK chain (license_keys.transaction_id → transactions).
185 + async fn keyed_item(h: &TestHarness, tag: &str) -> (db::UserId, db::ItemId, db::TransactionId) {
186 + let owner: db::UserId = sqlx::query_scalar(
187 + "INSERT INTO users (username, email, password_hash, email_verified) \
188 + VALUES ($1, $2, 'x', true) RETURNING id",
189 + )
190 + .bind(format!("lkowner_{tag}"))
191 + .bind(format!("lkowner_{tag}@test.com"))
192 + .fetch_one(&h.db)
193 + .await
194 + .unwrap();
195 + let project: db::ProjectId = sqlx::query_scalar(
196 + "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id",
197 + )
198 + .bind(owner)
199 + .bind(format!("lkproj_{tag}"))
200 + .fetch_one(&h.db)
201 + .await
202 + .unwrap();
203 + let item: db::ItemId = sqlx::query_scalar(
204 + "INSERT INTO items (project_id, title, item_type, price_cents, slug) \
205 + VALUES ($1, 'Plugin', 'plugin', 0, $2) RETURNING id",
206 + )
207 + .bind(project)
208 + .bind(format!("lkitem_{tag}"))
209 + .fetch_one(&h.db)
210 + .await
211 + .unwrap();
212 + let tx: db::TransactionId = sqlx::query_scalar(
213 + "INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, status) \
214 + VALUES ($1, $1, $2, 0, 'completed') RETURNING id",
215 + )
216 + .bind(owner)
217 + .bind(item)
218 + .fetch_one(&h.db)
219 + .await
220 + .unwrap();
221 + (owner, item, tx)
222 + }
223 +
224 + #[tokio::test]
225 + async fn create_license_key_is_idempotent_on_transaction_id() {
226 + let h = TestHarness::new().await;
227 + let (owner, item, tx) = keyed_item(&h, "idem").await;
228 +
229 + let code_a = KeyCode::from_trusted("aaaa-bbbb-cccc-dddd-eeee".to_string());
230 + let code_b = KeyCode::from_trusted("ffff-gggg-hhhh-iiii-jjjj".to_string());
231 +
232 + // First mint for the transaction.
233 + let first = db::license_keys::create_license_key(&h.db, item, owner, Some(tx), &code_a, Some(3))
234 + .await
235 + .unwrap();
236 +
237 + // A redelivered finalize for the SAME transaction must return the existing
238 + // key (Pay-M1), not error and not mint a second — even with a different code.
239 + let second = db::license_keys::create_license_key(&h.db, item, owner, Some(tx), &code_b, Some(3))
240 + .await
241 + .unwrap();
242 +
243 + assert_eq!(first.id, second.id, "a duplicate finalize must return the existing key");
244 + let count = db::license_keys::count_keys_by_item(&h.db, item).await.unwrap();
245 + assert_eq!(count, 1, "no second key may be minted for the same transaction");
246 + }
247 +
248 + #[tokio::test]
249 + async fn try_create_activation_enforces_max_activations() {
250 + let h = TestHarness::new().await;
251 + let (owner, item, tx) = keyed_item(&h, "cap").await;
252 +
253 + let code = KeyCode::from_trusted("kkkk-llll-mmmm-nnnn-oooo".to_string());
254 + let key = db::license_keys::create_license_key(&h.db, item, owner, Some(tx), &code, Some(2))
255 + .await
256 + .unwrap();
257 +
258 + // Two distinct machines fit under the cap of 2.
259 + assert!(db::license_keys::try_create_activation(&h.db, key.id, "machine-a", None).await.unwrap().is_some());
260 + assert!(db::license_keys::try_create_activation(&h.db, key.id, "machine-b", None).await.unwrap().is_some());
261 + // A third distinct machine is refused.
262 + assert!(
263 + db::license_keys::try_create_activation(&h.db, key.id, "machine-c", None).await.unwrap().is_none(),
264 + "activation past max_activations must be refused"
265 + );
266 + // Re-activating an existing machine is always allowed (no new slot consumed).
267 + assert!(
268 + db::license_keys::try_create_activation(&h.db, key.id, "machine-a", None).await.unwrap().is_some(),
269 + "re-activation of a known machine must always succeed"
270 + );
271 +
272 + // A revoked key activates nothing.
273 + db::license_keys::revoke_license_key(&h.db, key.id).await.unwrap();
274 + assert!(
275 + db::license_keys::try_create_activation(&h.db, key.id, "machine-d", None).await.unwrap().is_none(),
276 + "a revoked key must not activate"
277 + );
278 + }
@@ -95,3 +95,4 @@ mod lifecycle;
95 95 mod cart;
96 96 mod bundles;
97 97 mod idempotency;
98 + mod db_payments_layer;