Skip to main content

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-ByClaude Opus 4.6 (1M context) <noreply@anthropic.com>

Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-26 19:41 UTC

Commit:

e7052f4c23b6cbd3415432861c66351c9f485d71

Parent:

69240f6

12 files changed,

+297 insertions,

-25 deletions

OldNewLine
@@ -679,6 +679,9 @@
679
679
custom_license_text: None,
680
680
ai_tier: db::AiTier::Handmade,
681
681
ai_disclosure: None,
682
removed_by_admin: false,
683
removal_reason: None,
684
removed_at: None,
682
685
}
683
686
}
684
687
OldNewLine
@@ -183,6 +183,12 @@
183
183
pub enum SubscriptionStatus {
184
184
#[serde(rename = "active")]
185
185
Active,
186
#[serde(rename = "trialing")]
187
Trialing,
188
#[serde(rename = "incomplete")]
189
Incomplete,
190
#[serde(rename = "incomplete_expired")]
191
IncompleteExpired,
186
192
#[serde(rename = "past_due")]
187
193
PastDue,
188
194
#[serde(rename = "canceled")]
@@ -193,6 +199,9 @@
193
199
194
200
impl_str_enum!(SubscriptionStatus {
195
201
Active => "active",
202
Trialing => "trialing",
203
Incomplete => "incomplete",
204
IncompleteExpired => "incomplete_expired",
196
205
PastDue => "past_due",
197
206
Canceled => "canceled",
198
207
Unpaid => "unpaid",
@@ -928,6 +937,12 @@
928
937
fn subscription_status_round_trip() {
929
938
assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due");
930
939
assert_eq!("canceled".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Canceled);
940
assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing");
941
assert_eq!("trialing".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Trialing);
942
assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete");
943
assert_eq!("incomplete".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::Incomplete);
944
assert_eq!(SubscriptionStatus::IncompleteExpired.to_string(), "incomplete_expired");
945
assert_eq!("incomplete_expired".parse::<SubscriptionStatus>().unwrap(), SubscriptionStatus::IncompleteExpired);
931
946
}
932
947
933
948
#[test]
@@ -1251,6 +1266,12 @@
1251
1266
assert_eq!(json, "\"past_due\"");
1252
1267
let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1253
1268
assert_eq!(back, s);
1269
1270
let t = SubscriptionStatus::Trialing;
1271
let json = serde_json::to_string(&t).unwrap();
1272
assert_eq!(json, "\"trialing\"");
1273
let back: SubscriptionStatus = serde_json::from_str(&json).unwrap();
1274
assert_eq!(back, t);
1254
1275
}
1255
1276
1256
1277
// ── ItemType::wizard_group ──
OldNewLine
@@ -234,7 +234,7 @@
234
234
description = COALESCE($4, description),
235
235
price_cents = COALESCE($5, price_cents),
236
236
item_type = COALESCE($6, item_type),
237
is_public = COALESCE($7, is_public),
237
is_public = CASE WHEN removed_by_admin AND $7 = true THEN false ELSE COALESCE($7, is_public) END,
238
238
pwyw_enabled = COALESCE($8, pwyw_enabled),
239
239
pwyw_min_cents = COALESCE($9, pwyw_min_cents),
240
240
publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END,
@@ -277,7 +277,7 @@
277
277
r#"
278
278
UPDATE items
279
279
SET is_public = true, publish_at = NULL, updated_at = NOW()
280
WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false
280
WHERE publish_at IS NOT NULL AND publish_at <= NOW() AND is_public = false AND removed_by_admin = false
281
281
RETURNING *
282
282
"#,
283
283
)
@@ -669,6 +669,7 @@
669
669
SET is_public = true, publish_at = NULL, updated_at = NOW()
670
670
WHERE id = ANY($1) AND project_id = $2
671
671
AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
672
AND removed_by_admin = false
672
673
"#,
673
674
)
674
675
.bind(item_ids)
@@ -1012,3 +1013,54 @@
1012
1013
1013
1014
Ok(result.rows_affected())
1014
1015
}
1016
1017
/// Admin: remove an item (hide from public, record reason). The item stays in the DB
1018
/// and the creator can see it in their dashboard with the removal reason.
1019
#[tracing::instrument(skip_all)]
1020
pub async fn admin_remove_item(
1021
pool: &PgPool,
1022
item_id: ItemId,
1023
reason: &str,
1024
) -> Result<DbItem> {
1025
let item = sqlx::query_as::<_, DbItem>(
1026
r#"
1027
UPDATE items
1028
SET removed_by_admin = true,
1029
removal_reason = $2,
1030
removed_at = NOW(),
1031
is_public = false
1032
WHERE id = $1
1033
RETURNING *
1034
"#,
1035
)
1036
.bind(item_id)
1037
.bind(reason)
1038
.fetch_one(pool)
1039
.await?;
1040
1041
Ok(item)
1042
}
1043
1044
/// Admin: restore a previously removed item. Clears the removal fields
1045
/// but does NOT re-publish (creator must publish manually).
1046
#[tracing::instrument(skip_all)]
1047
pub async fn admin_restore_item(
1048
pool: &PgPool,
1049
item_id: ItemId,
1050
) -> Result<DbItem> {
1051
let item = sqlx::query_as::<_, DbItem>(
1052
r#"
1053
UPDATE items
1054
SET removed_by_admin = false,
1055
removal_reason = NULL,
1056
removed_at = NULL
1057
WHERE id = $1
1058
RETURNING *
1059
"#,
1060
)
1061
.bind(item_id)
1062
.fetch_one(pool)
1063
.await?;
1064
1065
Ok(item)
1066
}
OldNewLine
@@ -86,6 +86,19 @@
86
86
Ok(project)
87
87
}
88
88
89
/// Return just the IDs of all projects owned by a user (lightweight, for cleanup).
90
#[tracing::instrument(skip_all)]
91
pub async fn get_project_ids_for_user(pool: &PgPool, user_id: UserId) -> Result<Vec<ProjectId>> {
92
let ids = sqlx::query_scalar::<_, ProjectId>(
93
"SELECT id FROM projects WHERE user_id = $1",
94
)
95
.bind(user_id)
96
.fetch_all(pool)
97
.await?;
98
99
Ok(ids)
100
}
101
89
102
/// List all projects owned by a user, newest first.
90
103
///
91
104
/// Capped at 500 as a safety limit.
OldNewLine
@@ -171,6 +171,20 @@
171
171
Ok(result.rows_affected() > 0)
172
172
}
173
173
174
/// Release a reserved use_count slot (decrement, clamped to 0).
175
/// Called when a stale pending transaction with a reserved promo code is cleaned up.
176
#[tracing::instrument(skip_all)]
177
pub async fn release_use_count(pool: &PgPool, id: PromoCodeId) -> Result<()> {
178
sqlx::query(
179
"UPDATE promo_codes SET use_count = GREATEST(0, use_count - 1) WHERE id = $1",
180
)
181
.bind(id)
182
.execute(pool)
183
.await?;
184
185
Ok(())
186
}
187
174
188
/// Delete a promo code permanently.
175
189
#[tracing::instrument(skip_all)]
176
190
pub async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> {
OldNewLine
@@ -370,7 +370,7 @@
370
370
project_id: ProjectId,
371
371
) -> Result<bool> {
372
372
let count: i64 = sqlx::query_scalar(
373
"SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active'",
373
"SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND project_id = $2 AND status = 'active' AND paused_at IS NULL",
374
374
)
375
375
.bind(user_id)
376
376
.bind(project_id)
@@ -430,7 +430,7 @@
430
430
item_id: super::ItemId,
431
431
) -> Result<bool> {
432
432
let count: i64 = sqlx::query_scalar(
433
"SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND item_id = $2 AND status = 'active'",
433
"SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1 AND item_id = $2 AND status = 'active' AND paused_at IS NULL",
434
434
)
435
435
.bind(user_id)
436
436
.bind(item_id)
OldNewLine
@@ -20,6 +20,8 @@
20
20
pub share_contact: bool,
21
21
/// Set for project-level purchases; `None` for item purchases.
22
22
pub project_id: Option<ProjectId>,
23
/// Promo code used for this checkout (for releasing reservations on cleanup).
24
pub promo_code_id: Option<PromoCodeId>,
23
25
}
24
26
25
27
/// Common parameters for claiming a free item (direct, discount code, or download code).
@@ -42,8 +44,8 @@
42
44
) -> Result<DbTransaction> {
43
45
let tx = sqlx::query_as::<_, DbTransaction>(
44
46
r#"
45
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)
46
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
47
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)
48
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
47
49
RETURNING *
48
50
"#,
49
51
)
@@ -57,6 +59,7 @@
57
59
.bind(params.seller_username)
58
60
.bind(params.share_contact)
59
61
.bind(params.project_id)
62
.bind(params.promo_code_id)
60
63
.fetch_one(pool)
61
64
.await?;
62
65
@@ -194,12 +197,14 @@
194
197
Ok(result.rows_affected() > 0)
195
198
}
196
199
197
/// Atomically increment a promo code's use count and claim a free item.
200
/// Atomically claim a free item and increment the promo code's use count.
198
201
///
199
/// Wraps both operations in a single transaction so the use_count doesn't
200
/// drift if the claim fails. Returns `(code_accepted, item_claimed)`:
202
/// Claims the item FIRST (INSERT transaction), then increments use_count.
203
/// If the user already owns the item (rows_affected == 0), rolls back without
204
/// consuming the code. If the code limit is reached, rolls back the claim too.
205
/// Returns `(code_accepted, item_claimed)`:
201
206
/// - `code_accepted = false` → promo code hit its usage limit (nothing changed)
202
/// - `item_claimed = false` → user already owns the item (code was still consumed)
207
/// - `item_claimed = false` → user already owns the item (code was NOT consumed)
203
208
#[tracing::instrument(skip_all)]
204
209
pub async fn claim_free_with_promo_code(
205
210
pool: &PgPool,
@@ -208,18 +213,7 @@
208
213
) -> Result<(bool, bool)> {
209
214
let mut tx = pool.begin().await?;
210
215
211
let result = sqlx::query(
212
"UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
213
)
214
.bind(promo_code_id)
215
.execute(&mut *tx)
216
.await?;
217
218
if result.rows_affected() == 0 {
219
tx.rollback().await?;
220
return Ok((false, false));
221
}
222
216
// Step 1: Attempt to claim the item first
223
217
let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id);
224
218
let result = sqlx::query(
225
219
r#"
@@ -239,11 +233,30 @@
239
233
.await?;
240
234
241
235
let claimed = result.rows_affected() > 0;
242
if claimed {
243
crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?;
236
237
// Step 2: If the user already owns the item, rollback without consuming the code
238
if !claimed {
239
tx.rollback().await?;
240
return Ok((true, false));
244
241
}
242
243
// Step 3: Increment the promo code use count
244
let code_result = sqlx::query(
245
"UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
246
)
247
.bind(promo_code_id)
248
.execute(&mut *tx)
249
.await?;
250
251
// Step 4: If the code limit was reached, rollback the claim too
252
if code_result.rows_affected() == 0 {
253
tx.rollback().await?;
254
return Ok((false, false));
255
}
256
257
crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?;
245
258
tx.commit().await?;
246
Ok((true, claimed))
259
Ok((true, true))
247
260
}
248
261
249
262
// ── Project purchases ──
@@ -636,3 +649,30 @@
636
649
637
650
Ok(rows)
638
651
}
652
653
/// Delete stale pending transactions (older than the given threshold) and return
654
/// the promo_code_ids that need their use_count decremented.
655
///
656
/// Stripe checkout sessions expire after 24 hours, so pending transactions older
657
/// than that will never complete. This releases the pending purchase uniqueness
658
/// slot and any reserved promo code use_count.
659
#[tracing::instrument(skip_all)]
660
pub async fn cleanup_stale_pending(
661
pool: &PgPool,
662
older_than: chrono::Duration,
663
) -> Result<Vec<Option<super::PromoCodeId>>> {
664
let cutoff = chrono::Utc::now() - older_than;
665
let rows: Vec<(Option<super::PromoCodeId>,)> = sqlx::query_as(
666
r#"
667
DELETE FROM transactions
668
WHERE status = 'pending'
669
AND created_at < $1
670
RETURNING promo_code_id
671
"#,
672
)
673
.bind(cutoff)
674
.fetch_all(pool)
675
.await?;
676
677
Ok(rows.into_iter().map(|(id,)| id).collect())
678
}
OldNewLine
@@ -139,6 +139,37 @@
139
139
Ok(())
140
140
}
141
141
142
/// Admin: permanently terminate an account (enforcement ladder step 4).
143
/// The user has 30 days to export data. After that, the scheduler deletes the account.
144
/// The account must already be suspended.
145
#[tracing::instrument(skip_all)]
146
pub async fn terminate_user(pool: &PgPool, id: UserId) -> Result<()> {
147
sqlx::query(
148
"UPDATE users SET terminated_at = NOW(), updated_at = NOW() WHERE id = $1",
149
)
150
.bind(id)
151
.execute(pool)
152
.await?;
153
154
Ok(())
155
}
156
157
/// Get user IDs of terminated accounts whose 30-day export window has expired.
158
#[tracing::instrument(skip_all)]
159
pub async fn get_expired_terminated_ids(pool: &PgPool) -> Result<Vec<UserId>> {
160
let ids: Vec<UserId> = sqlx::query_scalar(
161
r#"
162
SELECT id FROM users
163
WHERE terminated_at IS NOT NULL
164
AND terminated_at < NOW() - INTERVAL '30 days'
165
"#,
166
)
167
.fetch_all(pool)
168
.await?;
169
170
Ok(ids)
171
}
172
142
173
/// Permanently delete a user by ID.
143
174
///
144
175
/// Explicitly removes fingerprint and streaming session records before
@@ -159,6 +190,80 @@
159
190
Ok(())
160
191
}
161
192
193
/// Create an ephemeral sandbox user. Returns the created row.
194
///
195
/// The user gets `can_create_projects = true`, `email_verified = true`,
196
/// a SmallFiles creator tier, and a tight storage cap. The row is
197
/// automatically cleaned up by the scheduler after `sandbox_expires_at`.
198
#[tracing::instrument(skip_all)]
199
pub async fn create_sandbox_user(
200
pool: &PgPool,
201
username: &Username,
202
email: &str,
203
password_hash: &str,
204
expiry_secs: i64,
205
max_file_bytes: i64,
206
) -> Result<DbUser> {
207
let user = sqlx::query_as::<_, DbUser>(
208
r#"
209
INSERT INTO users (
210
username, email, password_hash,
211
is_sandbox, sandbox_expires_at,
212
can_create_projects, email_verified,
213
creator_tier, max_file_override_bytes
214
)
215
VALUES (
216
$1, $2, $3,
217
TRUE, NOW() + make_interval(secs => $4::float8),
218
TRUE, TRUE,
219
'SmallFiles', $5
220
)
221
RETURNING *
222
"#,
223
)
224
.bind(username)
225
.bind(email)
226
.bind(password_hash)
227
.bind(expiry_secs as f64)
228
.bind(max_file_bytes)
229
.fetch_one(pool)
230
.await?;
231
232
Ok(user)
233
}
234
235
/// Return IDs of sandbox users whose expiry has passed.
236
#[tracing::instrument(skip_all)]
237
pub async fn get_expired_sandbox_ids(pool: &PgPool) -> Result<Vec<UserId>> {
238
let ids = sqlx::query_scalar::<_, UserId>(
239
"SELECT id FROM users WHERE is_sandbox = TRUE AND sandbox_expires_at < NOW()",
240
)
241
.fetch_all(pool)
242
.await?;
243
244
Ok(ids)
245
}
246
247
/// Count active (non-expired) sandbox accounts created from a given IP.
248
/// Used to enforce the per-IP concurrent sandbox cap.
249
#[tracing::instrument(skip_all)]
250
pub async fn count_active_sandboxes_by_ip(pool: &PgPool, ip: &str) -> Result<i64> {
251
let count: i64 = sqlx::query_scalar(
252
r#"
253
SELECT COUNT(*) FROM users u
254
JOIN user_sessions us ON us.user_id = u.id
255
WHERE u.is_sandbox = TRUE
256
AND u.sandbox_expires_at > NOW()
257
AND us.ip_address = $1
258
"#,
259
)
260
.bind(ip)
261
.fetch_one(pool)
262
.await?;
263
264
Ok(count)
265
}
266
162
267
/// Update user's Stripe Connect account information after OAuth
163
268
#[tracing::instrument(skip_all)]
164
269
pub async fn update_user_stripe_account(
OldNewLine
@@ -104,6 +104,12 @@
104
104
pub video_width: Option<i32>,
105
105
/// Video height in pixels.
106
106
pub video_height: Option<i32>,
107
/// Whether this item was removed by an admin (enforcement ladder step 2).
108
pub removed_by_admin: bool,
109
/// Admin-provided reason for removal (shown to the creator).
110
pub removal_reason: Option<String>,
111
/// When the admin removed this item.
112
pub removed_at: Option<DateTime<Utc>>,
107
113
}
108
114
109
115
/// Content-type-specific data extracted from a `DbItem`.
@@ -312,6 +318,9 @@
312
318
video_duration_seconds: None,
313
319
video_width: None,
314
320
video_height: None,
321
removed_by_admin: false,
322
removal_reason: None,
323
removed_at: None,
315
324
}
316
325
}
317
326
OldNewLine
@@ -217,6 +217,7 @@
217
217
created_at: Utc::now(),
218
218
updated_at: Utc::now(),
219
219
item_id: None,
220
paused_at: None,
220
221
}
221
222
}
222
223
OldNewLine
@@ -60,6 +60,8 @@
60
60
pub project_id: Option<ProjectId>,
61
61
/// Parent bundle transaction that granted this child item. Nullable.
62
62
pub parent_transaction_id: Option<TransactionId>,
63
/// Promo code used for this purchase (for releasing reservations on stale cleanup).
64
pub promo_code_id: Option<PromoCodeId>,
63
65
}
64
66
65
67
impl DbTransaction {
@@ -151,6 +153,7 @@
151
153
share_contact: false,
152
154
project_id: None,
153
155
parent_transaction_id: None,
156
promo_code_id: None,
154
157
}
155
158
}
156
159
OldNewLine
@@ -158,6 +158,13 @@
158
158
pub notify_tip: bool,
159
159
/// When the user self-deactivated their account (None = active).
160
160
pub deactivated_at: Option<DateTime<Utc>>,
161
/// Whether this is an ephemeral sandbox account.
162
pub is_sandbox: bool,
163
/// When the sandbox session expires (cleanup deletes the user after this).
164
pub sandbox_expires_at: Option<DateTime<Utc>>,
165
/// When the admin permanently terminated this account (None = not terminated).
166
/// User has 30 days from this timestamp to export data before deletion.
167
pub terminated_at: Option<DateTime<Utc>>,
161
168
}
162
169
163
170
impl DbUser {
@@ -267,6 +274,10 @@
267
274
grandfathered_until: None,
268
275
tips_enabled: false,
269
276
notify_tip: true,
277
deactivated_at: None,
278
is_sandbox: false,
279
sandbox_expires_at: None,
280
terminated_at: None,
270
281
}
271
282
}
272
283