Add DB layer support for sandbox, termination, content removal, and promo tracking - SubscriptionStatus: add Trialing, Incomplete, IncompleteExpired variants - DbItem: add removed_by_admin, removal_reason, removed_at fields - DbUser: add is_sandbox, sandbox_expires_at, terminated_at fields - DbTransaction: add promo_code_id field - DbSubscription test fixture: add paused_at field - items: admin_remove_item, admin_restore_item, publish guards for removed items - users: create_sandbox_user, terminate_user, get_expired_sandbox/terminated_ids - projects: get_project_ids_for_user (lightweight, for cleanup) - promo_codes: release_use_count for stale reservation cleanup - subscriptions: paused_at filter on active subscription checks - transactions: promo_code_id in create, claim_free_with_promo_code reorder (claim first, increment code second, rollback on already-owned), cleanup_stale_pending for scheduler - pricing test fixture: add new item fields
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-26 19:41 UTC
Commit:
e7052f4c23b6cbd3415432861c66351c9f485d71Parent:
12 files changed,
+297 insertions,
-25 deletions
custom_license_text: None, ai_tier: db::AiTier::Handmade, ai_disclosure: None, removed_by_admin: false, removal_reason: None, removed_at: None, } }pub enum SubscriptionStatus { #[serde(rename = "active")] Active, #[serde(rename = "trialing")] Trialing, #[serde(rename = "incomplete")] Incomplete, #[serde(rename = "incomplete_expired")] IncompleteExpired, #[serde(rename = "past_due")] PastDue, #[serde(rename = "canceled")]impl_str_enum!(SubscriptionStatus { Active => "active", Trialing => "trialing", Incomplete => "incomplete", IncompleteExpired => "incomplete_expired", PastDue => "past_due", Canceled => "canceled", Unpaid => "unpaid", fn subscription_status_round_trip() { assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due"); assert_eq!("canceled".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Canceled); assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing"); assert_eq!("trialing".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Trialing); assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete"); assert_eq!("incomplete".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Incomplete); assert_eq!(SubscriptionStatus::IncompleteExpired.to_string(), "incomplete_expired"); assert_eq!("incomplete_expired".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::IncompleteExpired); } #[test] assert_eq!(json, "\"past_due\""); let back: SubscriptionStatus = serde_json::from_str(&json).unwrap(); assert_eq!(back, s); let t = SubscriptionStatus::Trialing; let json = serde_json::to_string(&t).unwrap(); assert_eq!(json, "\"trialing\""); let back: SubscriptionStatus = serde_json::from_str(&json).unwrap(); assert_eq!(back, t); } // ── ItemType::wizard_group ── description = COALESCE($4, description), price_cents = COALESCE($5, price_cents), item_type = COALESCE($6, item_type), is_public = COALESCE($7, is_public), is_public = CASE WHEN removed_by_admin AND $7 = true THEN false ELSE COALESCE($7, is_public) END, pwyw_enabled = COALESCE($8, pwyw_enabled), pwyw_min_cents = COALESCE($9, pwyw_min_cents), publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END, r#" UPDATE items SET is_public = true, publish_at = NULL, updated_at = NOW() WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false AND removed_by_admin = false RETURNING * "#, ) SET is_public = true, publish_at = NULL, updated_at = NOW() WHERE id = ANY($1) AND project_id = $2 AND project_id IN (SELECT id FROM projects WHERE user_id = $3) AND removed_by_admin = false "#, ) .bind(item_ids) Ok(result.rows_affected())}/// Admin: remove an item (hide from public, record reason). The item stays in the DB/// and the creator can see it in their dashboard with the removal reason.#[tracing::instrument(skip_all)]pub async fn admin_remove_item( pool: &PgPool, item_id: ItemId, reason: &str,) -> Result<DbItem> { let item = sqlx::query_as::<_, DbItem>( r#" UPDATE items SET removed_by_admin = true, removal_reason = $2, removed_at = NOW(), is_public = false WHERE id = $1 RETURNING * "#, ) .bind(item_id) .bind(reason) .fetch_one(pool) .await?; Ok(item)}/// Admin: restore a previously removed item. Clears the removal fields/// but does NOT re-publish (creator must publish manually).#[tracing::instrument(skip_all)]pub async fn admin_restore_item( pool: &PgPool, item_id: ItemId,) -> Result<DbItem> { let item = sqlx::query_as::<_, DbItem>( r#" UPDATE items SET removed_by_admin = false, removal_reason = NULL, removed_at = NULL WHERE id = $1 RETURNING * "#, ) .bind(item_id) .fetch_one(pool) .await?; Ok(item)} Ok(project)}/// Return just the IDs of all projects owned by a user (lightweight, for cleanup).#[tracing::instrument(skip_all)]pub async fn get_project_ids_for_user(pool: &PgPool, user_id: UserId) -> Result<Vec<ProjectId>> { let ids = sqlx::query_scalar::<_, ProjectId>( "SELECT id FROM projects WHERE user_id = $1", ) .bind(user_id) .fetch_all(pool) .await?; Ok(ids)}/// List all projects owned by a user, newest first.////// Capped at 500 as a safety limit. Ok(result.rows_affected() > 0)}/// Release a reserved use_count slot (decrement, clamped to 0)./// Called when a stale pending transaction with a reserved promo code is cleaned up.#[tracing::instrument(skip_all)]pub async fn release_use_count(pool: &PgPool, id: PromoCodeId) -> Result<()> { sqlx::query( "UPDATE promo_codes SET use_count = GREATEST(0, use_count - 1) WHERE id = $1", ) .bind(id) .execute(pool) .await?; Ok(())}/// Delete a promo code permanently.#[tracing::instrument(skip_all)]pub async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> { project_id: ProjectId,) -> Result<bool> { let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active'", "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active' AND paused_at IS NULL", ) .bind(user_id) .bind(project_id) item_id: super::ItemId,) -> Result<bool> { let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND item_id = $2 AND status = 'active'", "SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND item_id = $2 AND status = 'active' AND paused_at IS NULL", ) .bind(user_id) .bind(item_id) pub share_contact: bool, /// Set for project-level purchases; `None` for item purchases. pub project_id: Option<ProjectId>, /// Promo code used for this checkout (for releasing reservations on cleanup). pub promo_code_id: Option<PromoCodeId>,}/// Common parameters for claiming a free item (direct, discount code, or download code).) -> Result<DbTransaction> { let tx = sqlx::query_as::<_, DbTransaction>( r#" INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, item_title, seller_username, share_contact, project_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, item_title, seller_username, share_contact, project_id, promo_code_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * "#, ) .bind(params.seller_username) .bind(params.share_contact) .bind(params.project_id) .bind(params.promo_code_id) .fetch_one(pool) .await?; Ok(result.rows_affected() > 0)}/// Atomically increment a promo code's use count and claim a free item./// Atomically claim a free item and increment the promo code's use count.////// Wraps both operations in a single transaction so the use_count doesn't/// drift if the claim fails. Returns `(code_accepted, item_claimed)`:/// Claims the item FIRST (INSERT transaction), then increments use_count./// If the user already owns the item (rows_affected == 0), rolls back without/// consuming the code. If the code limit is reached, rolls back the claim too./// Returns `(code_accepted, item_claimed)`:/// - `code_accepted = false` → promo code hit its usage limit (nothing changed)/// - `item_claimed = false` → user already owns the item (code was still consumed)/// - `item_claimed = false` → user already owns the item (code was NOT consumed)#[tracing::instrument(skip_all)]pub async fn claim_free_with_promo_code( pool: &PgPool,) -> Result<(bool, bool)> { let mut tx = pool.begin().await?; let result = sqlx::query( "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)", ) .bind(promo_code_id) .execute(&mut *tx) .await?; if result.rows_affected() == 0 { tx.rollback().await?; return Ok((false, false)); } // Step 1: Attempt to claim the item first let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id); let result = sqlx::query( r#" .await?; let claimed = result.rows_affected() > 0; if claimed { crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?; // Step 2: If the user already owns the item, rollback without consuming the code if !claimed { tx.rollback().await?; return Ok((true, false)); } // Step 3: Increment the promo code use count let code_result = sqlx::query( "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)", ) .bind(promo_code_id) .execute(&mut *tx) .await?; // Step 4: If the code limit was reached, rollback the claim too if code_result.rows_affected() == 0 { tx.rollback().await?; return Ok((false, false)); } crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?; tx.commit().await?; Ok((true, claimed)) Ok((true, true))}// ── Project purchases ── Ok(rows)}/// Delete stale pending transactions (older than the given threshold) and return/// the promo_code_ids that need their use_count decremented.////// Stripe checkout sessions expire after 24 hours, so pending transactions older/// than that will never complete. This releases the pending purchase uniqueness/// slot and any reserved promo code use_count.#[tracing::instrument(skip_all)]pub async fn cleanup_stale_pending( pool: &PgPool, older_than: chrono::Duration,) -> Result<Vec<Option<super::PromoCodeId>>> { let cutoff = chrono::Utc::now() - older_than; let rows: Vec<(Option<super::PromoCodeId>,)> = sqlx::query_as( r#" DELETE FROM transactions WHERE status = 'pending' AND created_at < $1 RETURNING promo_code_id "#, ) .bind(cutoff) .fetch_all(pool) .await?; Ok(rows.into_iter().map(|(id,)| id).collect())} Ok(())}/// Admin: permanently terminate an account (enforcement ladder step 4)./// The user has 30 days to export data. After that, the scheduler deletes the account./// The account must already be suspended.#[tracing::instrument(skip_all)]pub async fn terminate_user(pool: &PgPool, id: UserId) -> Result<()> { sqlx::query( "UPDATE users SET terminated_at = NOW(), updated_at = NOW() WHERE id = $1", ) .bind(id) .execute(pool) .await?; Ok(())}/// Get user IDs of terminated accounts whose 30-day export window has expired.#[tracing::instrument(skip_all)]pub async fn get_expired_terminated_ids(pool: &PgPool) -> Result<Vec<UserId>> { let ids: Vec<UserId> = sqlx::query_scalar( r#" SELECT id FROM users WHERE terminated_at IS NOT NULL AND terminated_at < NOW() - INTERVAL '30 days' "#, ) .fetch_all(pool) .await?; Ok(ids)}/// Permanently delete a user by ID.////// Explicitly removes fingerprint and streaming session records before Ok(())}/// Create an ephemeral sandbox user. Returns the created row.////// The user gets `can_create_projects = true`, `email_verified = true`,/// a SmallFiles creator tier, and a tight storage cap. The row is/// automatically cleaned up by the scheduler after `sandbox_expires_at`.#[tracing::instrument(skip_all)]pub async fn create_sandbox_user( pool: &PgPool, username: &Username, email: &str, password_hash: &str, expiry_secs: i64, max_file_bytes: i64,) -> Result<DbUser> { let user = sqlx::query_as::<_, DbUser>( r#" INSERT INTO users ( username, email, password_hash, is_sandbox, sandbox_expires_at, can_create_projects, email_verified, creator_tier, max_file_override_bytes ) VALUES ( $1, $2, $3, TRUE, NOW() + make_interval(secs => $4::float8), TRUE, TRUE, 'SmallFiles', $5 ) RETURNING * "#, ) .bind(username) .bind(email) .bind(password_hash) .bind(expiry_secs as f64) .bind(max_file_bytes) .fetch_one(pool) .await?; Ok(user)}/// Return IDs of sandbox users whose expiry has passed.#[tracing::instrument(skip_all)]pub async fn get_expired_sandbox_ids(pool: &PgPool) -> Result<Vec<UserId>> { let ids = sqlx::query_scalar::<_, UserId>( "SELECT id FROM users WHERE is_sandbox = TRUE AND sandbox_expires_at < NOW()", ) .fetch_all(pool) .await?; Ok(ids)}/// Count active (non-expired) sandbox accounts created from a given IP./// Used to enforce the per-IP concurrent sandbox cap.#[tracing::instrument(skip_all)]pub async fn count_active_sandboxes_by_ip(pool: &PgPool, ip: &str) -> Result<i64> { let count: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM users u JOIN user_sessions us ON us.user_id = u.id WHERE u.is_sandbox = TRUE AND u.sandbox_expires_at > NOW() AND us.ip_address = $1 "#, ) .bind(ip) .fetch_one(pool) .await?; Ok(count)}/// Update user's Stripe Connect account information after OAuth#[tracing::instrument(skip_all)]pub async fn update_user_stripe_account( pub video_width: Option<i32>, /// Video height in pixels. pub video_height: Option<i32>, /// Whether this item was removed by an admin (enforcement ladder step 2). pub removed_by_admin: bool, /// Admin-provided reason for removal (shown to the creator). pub removal_reason: Option<String>, /// When the admin removed this item. pub removed_at: Option<DateTime<Utc>>,}/// Content-type-specific data extracted from a `DbItem`. video_duration_seconds: None, video_width: None, video_height: None, removed_by_admin: false, removal_reason: None, removed_at: None, } } created_at: Utc::now(), updated_at: Utc::now(), item_id: None, paused_at: None, } } pub project_id: Option<ProjectId>, /// Parent bundle transaction that granted this child item. Nullable. pub parent_transaction_id: Option<TransactionId>, /// Promo code used for this purchase (for releasing reservations on stale cleanup). pub promo_code_id: Option<PromoCodeId>,}impl DbTransaction { share_contact: false, project_id: None, parent_transaction_id: None, promo_code_id: None, } } pub notify_tip: bool, /// When the user self-deactivated their account (None = active). pub deactivated_at: Option<DateTime<Utc>>, /// Whether this is an ephemeral sandbox account. pub is_sandbox: bool, /// When the sandbox session expires (cleanup deletes the user after this). pub sandbox_expires_at: Option<DateTime<Utc>>, /// When the admin permanently terminated this account (None = not terminated). /// User has 30 days from this timestamp to export data before deletion. pub terminated_at: Option<DateTime<Utc>>,}impl DbUser { grandfathered_until: None, tips_enabled: false, notify_tip: true, deactivated_at: None, is_sandbox: false, sandbox_expires_at: None, terminated_at: None, } }