Skip to main content

max / makenotwork

Split db/transactions into purchases + seller_contacts + revenue_stats The 1730-line transactions.rs carried three concerns on the transaction table: core purchase/refund/claim flow, buyer-contact/seller-relationship queries, and revenue analytics. Split into a transactions/ directory — purchases.rs (core checkout/claim/refund/pending), seller_contacts.rs (buyer lists, seller contacts, contact sharing), and revenue_stats.rs (per-project/per-user/platform revenue rollups) — with mod.rs re-exporting all three. The db::transactions::* path is preserved (call sites unchanged); inline `super::` type references were rewritten to `crate::db::` for the added module depth. No behavior change.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 13:02 UTC
Signed with PGP, not checked
Commit: 1ea57412591d2645ecff335f40c7f0866ea8d4c6
Parent: c431087
4 files changed, +398 insertions, -338 deletions
@@ -1,12 +1,4 @@
1 - //! Transaction queries: checkout tracking, purchase history, and free claims.
2 -
3 - use chrono::{DateTime, Utc};
4 - use sqlx::PgPool;
5 -
6 - use super::models::*;
7 - use super::validated_types::KeyCode;
8 - use super::{Cents, ClaimToken, DownloadToken, ItemId, ProjectId, PromoCodeId, TransactionId, UserId};
9 - use crate::error::Result;
1 + use super::*;
10 2
11 3 /// Parameters for creating a pending Stripe checkout transaction.
12 4 pub struct CreateTransactionParams<'a> {
@@ -64,7 +56,7 @@
64 56 RETURNING
65 57 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
66 58 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
67 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
59 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
68 60 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
69 61 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
70 62 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -122,7 +114,7 @@
122 114 RETURNING
123 115 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
124 116 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
125 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
117 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
126 118 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
127 119 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
128 120 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -183,7 +175,7 @@
183 175 RETURNING
184 176 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
185 177 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
186 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
178 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
187 179 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
188 180 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
189 181 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -211,7 +203,7 @@
211 203 SELECT
212 204 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
213 205 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
214 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
206 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
215 207 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
216 208 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
217 209 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -251,7 +243,7 @@
251 243 RETURNING
252 244 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
253 245 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
254 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
246 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
255 247 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
256 248 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
257 249 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -287,7 +279,7 @@
287 279 RETURNING
288 280 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
289 281 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
290 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
282 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
291 283 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
292 284 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
293 285 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -321,7 +313,7 @@
321 313 SELECT
322 314 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
323 315 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
324 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
316 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
325 317 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
326 318 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
327 319 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -353,7 +345,7 @@
353 345 SELECT
354 346 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
355 347 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
356 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
348 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
357 349 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
358 350 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
359 351 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -387,7 +379,7 @@
387 379 SELECT
388 380 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
389 381 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
390 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
382 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
391 383 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
392 384 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
393 385 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -422,7 +414,7 @@
422 414 SELECT
423 415 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
424 416 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
425 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
417 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
426 418 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
427 419 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
428 420 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -742,7 +734,7 @@
742 734 RETURNING
743 735 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
744 736 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
745 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
737 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
746 738 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
747 739 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
748 740 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -781,7 +773,7 @@
781 773 item_id AS "item_id!: ItemId",
782 774 title AS "title!",
783 775 creator AS "creator!",
784 - item_type AS "item_type!: super::ItemType",
776 + item_type AS "item_type!: crate::db::ItemType",
785 777 purchased_at AS "purchased_at!: chrono::DateTime<chrono::Utc>",
786 778 is_free AS "is_free!",
787 779 license_key_code AS "license_key_code?: KeyCode",
@@ -830,97 +822,6 @@
830 822 Ok(purchases)
831 823 }
832 824
833 - /// Sum completed revenue and count sales for all items in a project.
834 - ///
835 - /// Returns `(total_revenue_cents, total_sales)`. Only completed transactions
836 - /// are counted; pending, failed, and refunded are excluded.
837 - #[tracing::instrument(skip_all)]
838 - pub async fn get_revenue_by_project(pool: &PgPool, project_id: ProjectId) -> Result<(i64, i64)> {
839 - let row = sqlx::query!(
840 - r#"
841 - SELECT
842 - COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "total!",
843 - COUNT(*) AS "count!"
844 - FROM transactions t
845 - JOIN items i ON t.item_id = i.id
846 - WHERE i.project_id = $1
847 - AND t.status = 'completed'
848 - "#,
849 - project_id as ProjectId,
850 - )
851 - .fetch_one(pool)
852 - .await?;
853 -
854 - Ok((row.total, row.count))
855 - }
856 -
857 - /// Revenue per project for a given seller, returned as (project_id, title, revenue_cents).
858 - /// Single query replaces N+1 loop in dashboard analytics.
859 - #[tracing::instrument(skip_all)]
860 - pub async fn get_revenue_by_user_projects(
861 - pool: &PgPool,
862 - user_id: UserId,
863 - ) -> Result<Vec<(ProjectId, String, i64)>> {
864 - let rows = sqlx::query!(
865 - r#"
866 - SELECT p.id AS "id: ProjectId", p.title, COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "revenue!"
867 - FROM projects p
868 - LEFT JOIN items i ON i.project_id = p.id
869 - LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'
870 - WHERE p.user_id = $1
871 - GROUP BY p.id, p.title
872 - HAVING COALESCE(SUM(t.amount_cents), 0) > 0
873 - ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC
874 - "#,
875 - user_id as UserId,
876 - )
877 - .fetch_all(pool)
878 - .await?;
879 -
880 - Ok(rows.into_iter().map(|r| (r.id, r.title, r.revenue)).collect())
881 - }
882 -
883 - /// Revenue and sales per project for a seller within a time range.
884 - ///
885 - /// Used for the cross-project comparison table on the user analytics tab.
886 - #[tracing::instrument(skip_all)]
887 - pub async fn get_revenue_by_user_projects_in_range(
888 - pool: &PgPool,
889 - user_id: UserId,
890 - range: &super::analytics::TimeRange,
891 - ) -> Result<Vec<(ProjectId, String, i64, i64)>> {
892 - let time_filter = match range.interval_sql() {
893 - Some(interval) => format!(
894 - " AND t.completed_at >= NOW() - INTERVAL '{interval}'"
895 - ),
896 - None => String::new(),
897 - };
898 -
899 - let sql = format!(
900 - r#"
901 - SELECT p.id, p.title,
902 - COALESCE(SUM(t.amount_cents), 0)::BIGINT,
903 - COUNT(t.id)::BIGINT
904 - FROM projects p
905 - LEFT JOIN items i ON i.project_id = p.id
906 - LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'{time_filter}
907 - WHERE p.user_id = $1
908 - GROUP BY p.id, p.title
909 - ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC
910 - "#
911 - );
912 -
913 - // runtime-checked: dynamically-built SQL string — the `time_filter` clause is
914 - // conditionally interpolated via `format!`, so the query text isn't a literal
915 - // and can't be compile-checked by the macro.
916 - let rows: Vec<(ProjectId, String, i64, i64)> = sqlx::query_as(&sql)
917 - .bind(user_id)
918 - .fetch_all(pool)
919 - .await?;
920 -
921 - Ok(rows)
922 - }
923 -
924 825 /// Remove a free item from library (deletes the claim transaction).
925 826 /// If the claim was via a promo code, decrements the code's use_count.
926 827 #[tracing::instrument(skip_all)]
@@ -930,11 +831,11 @@
930 831 item_id: ItemId,
931 832 ) -> Result<bool> {
932 833 // Delete the free claim and return the promo_code_id if one was used
933 - let row: Option<Option<super::PromoCodeId>> = sqlx::query_scalar!(
834 + let row: Option<Option<crate::db::PromoCodeId>> = sqlx::query_scalar!(
934 835 r#"
935 836 DELETE FROM transactions
936 837 WHERE buyer_id = $1 AND item_id = $2 AND amount_cents = 0 AND status = 'completed'
937 - RETURNING promo_code_id AS "promo_code_id: super::PromoCodeId"
838 + RETURNING promo_code_id AS "promo_code_id: crate::db::PromoCodeId"
938 839 "#,
939 840 user_id as UserId,
940 841 item_id as ItemId,
@@ -945,7 +846,7 @@
945 846 let deleted = row.is_some();
946 847
947 848 if let Some(Some(pc_id)) = row {
948 - super::promo_codes::release_use_count(pool, pc_id).await.ok();
849 + crate::db::promo_codes::release_use_count(pool, pc_id).await.ok();
949 850 }
950 851
951 852 Ok(deleted)
@@ -973,7 +874,7 @@
973 874 SELECT
974 875 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
975 876 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
976 - currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
877 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
977 878 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
978 879 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
979 880 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
@@ -1054,7 +955,7 @@
1054 955 pub(crate) async fn refund_transaction_by_payment_intent<'e>(
1055 956 executor: impl sqlx::PgExecutor<'e>,
1056 957 payment_intent_id: &str,
1057 - ) -> Result<Vec<(super::TransactionId, Option<ItemId>)>> {
958 + ) -> Result<Vec<(crate::db::TransactionId, Option<ItemId>)>> {
1058 959 // item_id is nullable on project-level transactions (routes/stripe/checkout/project.rs);
1059 960 // returning non-Optional ItemId would cause sqlx decode failures and infinite Stripe retries.
1060 961 let rows = sqlx::query!(
@@ -1062,7 +963,7 @@
1062 963 UPDATE transactions
1063 964 SET status = 'refunded'
1064 965 WHERE stripe_payment_intent_id = $1 AND status IN ('completed', 'refunding')
1065 - RETURNING id AS "id: super::TransactionId", item_id AS "item_id: ItemId"
966 + RETURNING id AS "id: crate::db::TransactionId", item_id AS "item_id: ItemId"
1066 967 "#,
1067 968 payment_intent_id,
1068 969 )
@@ -1084,13 +985,13 @@
1084 985 pub(crate) async fn refund_transaction_by_id<'e>(
1085 986 executor: impl sqlx::PgExecutor<'e>,
1086 987 id: TransactionId,
1087 - ) -> Result<Option<(super::TransactionId, Option<ItemId>)>> {
988 + ) -> Result<Option<(crate::db::TransactionId, Option<ItemId>)>> {
1088 989 let row = sqlx::query!(
1089 990 r#"
1090 991 UPDATE transactions
1091 992 SET status = 'refunded'
1092 993 WHERE id = $1 AND status IN ('completed', 'refunding')
1093 - RETURNING id AS "id: super::TransactionId", item_id AS "item_id: ItemId"
994 + RETURNING id AS "id: crate::db::TransactionId", item_id AS "item_id: ItemId"
1094 995 "#,
1095 996 id as TransactionId,
1096 997 )
@@ -1158,152 +1059,6 @@
1158 1059 Ok(item_ids.into_iter().flatten().collect())
1159 1060 }
1160 1061
1161 - /// A buyer's email and display name for platform notifications (e.g. creator departure).
1162 - ///
1163 - /// Unlike [`DbContactRow`], this includes ALL buyers regardless of contact sharing
1164 - /// preference, because platform-initiated notifications (content removal warnings)
1165 - /// are distinct from creator-initiated contact.
1166 - #[derive(sqlx::FromRow)]
1167 - pub struct BuyerNotificationRow {
1168 - pub email: String,
1169 - pub display_name: Option<String>,
1170 - }
1171 -
1172 - /// Get unique buyers who purchased from a seller, for platform notifications.
1173 - ///
1174 - /// This bypasses `share_contact` and `contact_revocations` because the notification
1175 - /// is sent by the platform (not the creator) to warn buyers about content removal.
1176 - ///
1177 - /// Capped at `limit` rows to bound memory + outbound email volume on creators
1178 - /// with very large historical buyer pools. The caller is responsible for
1179 - /// noting when the cap is hit (returned slice length == limit).
1180 - #[tracing::instrument(skip_all)]
1181 - pub async fn get_all_buyers_for_seller(
1182 - pool: &PgPool,
1183 - seller_id: UserId,
1184 - limit: i64,
1185 - ) -> Result<Vec<BuyerNotificationRow>> {
1186 - let rows = sqlx::query_as!(
1187 - BuyerNotificationRow,
1188 - r#"
1189 - SELECT DISTINCT u.email, u.display_name
1190 - FROM transactions t
1191 - JOIN users u ON u.id = t.buyer_id
1192 - WHERE t.seller_id = $1
1193 - AND t.status = 'completed'
1194 - AND t.buyer_id IS NOT NULL
1195 - LIMIT $2
1196 - "#,
1197 - seller_id as UserId,
1198 - limit,
1199 - )
1200 - .fetch_all(pool)
1201 - .await?;
1202 -
1203 - Ok(rows)
1204 - }
1205 -
1206 - /// A contact row: a buyer who opted to share their email with the seller.
1207 - #[derive(sqlx::FromRow)]
1208 - pub struct DbContactRow {
1209 - pub username: String,
1210 - pub email: String,
1211 - pub total_purchases: i64,
1212 - pub total_spent_cents: i64,
1213 - pub last_purchase_at: DateTime<Utc>,
1214 - }
1215 -
1216 - /// A creator the fan has actively shared contact info with (no revocation).
1217 - #[derive(sqlx::FromRow)]
1218 - pub struct SharedCreatorRow {
1219 - pub seller_id: UserId,
1220 - pub username: String,
1221 - pub display_name: Option<String>,
1222 - }
1223 -
1224 - /// Get unique contacts for a seller: buyers who opted in to share their email.
1225 - ///
1226 - /// Aggregates across all completed transactions where `share_contact = true`,
1227 - /// returning one row per buyer with purchase stats. Excludes buyers who have
1228 - /// revoked contact sharing.
1229 - #[tracing::instrument(skip_all)]
1230 - pub async fn get_seller_contacts(
1231 - pool: &PgPool,
1232 - seller_id: UserId,
1233 - ) -> Result<Vec<DbContactRow>> {
1234 - let rows = sqlx::query_as!(
1235 - DbContactRow,
1236 - r#"
1237 - SELECT
1238 - u.username,
1239 - u.email,
1240 - COUNT(*) AS "total_purchases!",
1241 - COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "total_spent_cents!",
1242 - MAX(t.completed_at) AS "last_purchase_at!: chrono::DateTime<chrono::Utc>"
1243 - FROM transactions t
1244 - JOIN users u ON u.id = t.buyer_id
1245 - WHERE t.seller_id = $1
1246 - AND t.status = 'completed'
1247 - AND t.share_contact = true
1248 - AND NOT EXISTS (
1249 - SELECT 1 FROM contact_revocations cr
1250 - WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
1251 - )
1252 - GROUP BY t.buyer_id, u.username, u.email
1253 - ORDER BY MAX(t.completed_at) DESC
1254 - LIMIT 500
1255 - "#,
1256 - seller_id as UserId,
1257 - )
1258 - .fetch_all(pool)
1259 - .await?;
1260 -
1261 - Ok(rows)
1262 - }
1263 -
1264 - /// One page of a seller's sharing-opted-in contacts for CSV export, newest
1265 - /// first. Paginated so the contacts export streams in bounded batches like its
1266 - /// sibling exports instead of buffering one capped query into a single `String`
1267 - /// (ultra-fuzz Run 6 R6-Perf-M2). `(MAX(completed_at), buyer_id)` ordering is
1268 - /// stable so OFFSET batches don't reorder.
1269 - #[tracing::instrument(skip_all)]
1270 - pub async fn get_seller_contacts_page(
1271 - pool: &PgPool,
1272 - seller_id: UserId,
1273 - limit: i64,
1274 - offset: i64,
1275 - ) -> Result<Vec<DbContactRow>> {
1276 - let rows = sqlx::query_as::<_, DbContactRow>(
1277 - r#"
1278 - SELECT
1279 - u.username,
1280 - u.email,
1281 - COUNT(*) AS total_purchases,
1282 - COALESCE(SUM(t.amount_cents), 0)::BIGINT AS total_spent_cents,
1283 - MAX(t.completed_at) AS last_purchase_at
1284 - FROM transactions t
1285 - JOIN users u ON u.id = t.buyer_id
1286 - WHERE t.seller_id = $1
1287 - AND t.status = 'completed'
1288 - AND t.share_contact = true
1289 - AND NOT EXISTS (
1290 - SELECT 1 FROM contact_revocations cr
1291 - WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
1292 - )
1293 - GROUP BY t.buyer_id, u.username, u.email
1294 - ORDER BY MAX(t.completed_at) DESC, t.buyer_id
1295 - LIMIT $2 OFFSET $3
1296 - "#,
1297 - )
1298 - .bind(seller_id)
1299 - .bind(limit)
1300 - .bind(offset)
1301 - .fetch_all(pool)
1302 - .await?;
1303 -
1304 - Ok(rows)
1305 - }
1306 -
1307 1062 /// Get seller transactions for CSV export, with conditional buyer email.
1308 1063 ///
1309 1064 /// Respects contact revocations: if a buyer revoked sharing, their email
@@ -1331,7 +1086,7 @@
1331 1086 t.item_id AS "item_id: ItemId",
1332 1087 t.item_title,
1333 1088 t.amount_cents AS "amount_cents: Cents",
1334 - t.status AS "status: super::TransactionStatus",
1089 + t.status AS "status: crate::db::TransactionStatus",
1335 1090 CASE WHEN t.share_contact AND NOT EXISTS (
1336 1091 SELECT 1 FROM contact_revocations cr
1337 1092 WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
@@ -1379,92 +1134,6 @@
1379 1134 Ok(all)
1380 1135 }
1381 1136
1382 - /// Platform-wide revenue stats: total completed revenue, completed count, refunded count.
1383 - #[tracing::instrument(skip_all)]
1384 - pub async fn get_platform_revenue_stats(pool: &PgPool) -> Result<(i64, i64, i64)> {
1385 - let row = sqlx::query!(
1386 - r#"
1387 - SELECT
1388 - COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_cents ELSE 0 END), 0)::BIGINT AS "revenue!",
1389 - COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)::BIGINT AS "completed!",
1390 - COALESCE(SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END), 0)::BIGINT AS "refunded!"
1391 - FROM transactions
1392 - "#,
1393 - )
1394 - .fetch_one(pool)
1395 - .await?;
1396 -
1397 - Ok((row.revenue, row.completed, row.refunded))
1398 - }
1399 -
1400 - /// Record a contact revocation (fan withdraws email sharing from a creator).
1401 - ///
1402 - /// Idempotent: does nothing if already revoked.
1403 - #[tracing::instrument(skip_all)]
1404 - pub async fn revoke_contact_sharing(
1405 - pool: &PgPool,
1406 - buyer_id: UserId,
1407 - seller_id: UserId,
1408 - ) -> Result<()> {
1409 - sqlx::query!(
1410 - "INSERT INTO contact_revocations (buyer_id, seller_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
1411 - buyer_id as UserId,
1412 - seller_id as UserId,
1413 - )
1414 - .execute(pool)
1415 - .await?;
1416 -
1417 - Ok(())
1418 - }
1419 -
1420 - /// Clear a contact revocation (fan re-shares on a new purchase).
1421 - #[tracing::instrument(skip_all)]
1422 - pub async fn clear_contact_revocation(
1423 - pool: &PgPool,
1424 - buyer_id: UserId,
1425 - seller_id: UserId,
1426 - ) -> Result<()> {
1427 - sqlx::query!(
1428 - "DELETE FROM contact_revocations WHERE buyer_id = $1 AND seller_id = $2",
1429 - buyer_id as UserId,
1430 - seller_id as UserId,
1431 - )
1432 - .execute(pool)
1433 - .await?;
1434 -
1435 - Ok(())
1436 - }
1437 -
1438 - /// Get creators the fan has actively shared contact info with (excluding revoked).
1439 - #[tracing::instrument(skip_all)]
1440 - pub async fn get_shared_creators(
1441 - pool: &PgPool,
1442 - buyer_id: UserId,
1443 - ) -> Result<Vec<SharedCreatorRow>> {
1444 - let rows = sqlx::query_as!(
1445 - SharedCreatorRow,
1446 - r#"
1447 - SELECT DISTINCT t.seller_id AS "seller_id!: UserId", u.username, u.display_name
1448 - FROM transactions t
1449 - JOIN users u ON u.id = t.seller_id
1450 - WHERE t.buyer_id = $1
1451 - AND t.status = 'completed'
1452 - AND t.share_contact = true
Lines truncated
@@ -1,0 +1,17 @@
1 + //! Transaction queries: checkout tracking, purchase history, and free claims.
2 +
3 + use chrono::{DateTime, Utc};
4 + use sqlx::PgPool;
5 +
6 + use super::models::*;
7 + use super::validated_types::KeyCode;
8 + use super::{Cents, ClaimToken, DownloadToken, ItemId, ProjectId, PromoCodeId, TransactionId, UserId};
9 + use crate::error::Result;
10 +
11 + mod purchases;
12 + mod revenue_stats;
13 + mod seller_contacts;
14 +
15 + pub use purchases::*;
16 + pub use revenue_stats::*;
17 + pub use seller_contacts::*;
@@ -1,0 +1,144 @@
1 + use super::*;
2 +
3 + /// Sum completed revenue and count sales for all items in a project.
4 + ///
5 + /// Returns `(total_revenue_cents, total_sales)`. Only completed transactions
6 + /// are counted; pending, failed, and refunded are excluded.
7 + #[tracing::instrument(skip_all)]
8 + pub async fn get_revenue_by_project(pool: &PgPool, project_id: ProjectId) -> Result<(i64, i64)> {
9 + let row = sqlx::query!(
10 + r#"
11 + SELECT
12 + COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "total!",
13 + COUNT(*) AS "count!"
14 + FROM transactions t
15 + JOIN items i ON t.item_id = i.id
16 + WHERE i.project_id = $1
17 + AND t.status = 'completed'
18 + "#,
19 + project_id as ProjectId,
20 + )
21 + .fetch_one(pool)
22 + .await?;
23 +
24 + Ok((row.total, row.count))
25 + }
26 +
27 + /// Revenue per project for a given seller, returned as (project_id, title, revenue_cents).
28 + /// Single query replaces N+1 loop in dashboard analytics.
29 + #[tracing::instrument(skip_all)]
30 + pub async fn get_revenue_by_user_projects(
31 + pool: &PgPool,
32 + user_id: UserId,
33 + ) -> Result<Vec<(ProjectId, String, i64)>> {
34 + let rows = sqlx::query!(
35 + r#"
36 + SELECT p.id AS "id: ProjectId", p.title, COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "revenue!"
37 + FROM projects p
38 + LEFT JOIN items i ON i.project_id = p.id
39 + LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'
40 + WHERE p.user_id = $1
41 + GROUP BY p.id, p.title
42 + HAVING COALESCE(SUM(t.amount_cents), 0) > 0
43 + ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC
44 + "#,
45 + user_id as UserId,
46 + )
47 + .fetch_all(pool)
48 + .await?;
49 +
50 + Ok(rows.into_iter().map(|r| (r.id, r.title, r.revenue)).collect())
51 + }
52 +
53 + /// Revenue and sales per project for a seller within a time range.
54 + ///
55 + /// Used for the cross-project comparison table on the user analytics tab.
56 + #[tracing::instrument(skip_all)]
57 + pub async fn get_revenue_by_user_projects_in_range(
58 + pool: &PgPool,
59 + user_id: UserId,
60 + range: &crate::db::analytics::TimeRange,
61 + ) -> Result<Vec<(ProjectId, String, i64, i64)>> {
62 + let time_filter = match range.interval_sql() {
63 + Some(interval) => format!(
64 + " AND t.completed_at >= NOW() - INTERVAL '{interval}'"
65 + ),
66 + None => String::new(),
67 + };
68 +
69 + let sql = format!(
70 + r#"
71 + SELECT p.id, p.title,
72 + COALESCE(SUM(t.amount_cents), 0)::BIGINT,
73 + COUNT(t.id)::BIGINT
74 + FROM projects p
75 + LEFT JOIN items i ON i.project_id = p.id
76 + LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'{time_filter}
77 + WHERE p.user_id = $1
78 + GROUP BY p.id, p.title
79 + ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC
80 + "#
81 + );
82 +
83 + // runtime-checked: dynamically-built SQL string — the `time_filter` clause is
84 + // conditionally interpolated via `format!`, so the query text isn't a literal
85 + // and can't be compile-checked by the macro.
86 + let rows: Vec<(ProjectId, String, i64, i64)> = sqlx::query_as(&sql)
87 + .bind(user_id)
88 + .fetch_all(pool)
89 + .await?;
90 +
91 + Ok(rows)
92 + }
93 +
94 + /// Platform-wide revenue stats: total completed revenue, completed count, refunded count.
95 + #[tracing::instrument(skip_all)]
96 + pub async fn get_platform_revenue_stats(pool: &PgPool) -> Result<(i64, i64, i64)> {
97 + let row = sqlx::query!(
98 + r#"
99 + SELECT
100 + COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_cents ELSE 0 END), 0)::BIGINT AS "revenue!",
101 + COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)::BIGINT AS "completed!",
102 + COALESCE(SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END), 0)::BIGINT AS "refunded!"
103 + FROM transactions
104 + "#,
105 + )
106 + .fetch_one(pool)
107 + .await?;
108 +
109 + Ok((row.revenue, row.completed, row.refunded))
110 + }
111 +
112 + /// Completed and refunded sales for a specific item, for the item dashboard Sales tab.
113 + #[tracing::instrument(skip_all)]
114 + pub async fn get_sales_by_item(
115 + pool: &PgPool,
116 + item_id: ItemId,
117 + seller_id: UserId,
118 + ) -> Result<Vec<DbTransaction>> {
119 + let rows = sqlx::query_as!(
120 + DbTransaction,
121 + r#"
122 + SELECT
123 + id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
124 + item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
125 + currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
126 + created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
127 + item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
128 + parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
129 + guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId",
130 + download_token AS "download_token: DownloadToken"
131 + FROM transactions
132 + WHERE item_id = $1 AND seller_id = $2
133 + AND status IN ('completed', 'refunded')
134 + ORDER BY created_at DESC
135 + LIMIT 200
136 + "#,
137 + item_id as ItemId,
138 + seller_id as UserId,
139 + )
140 + .fetch_all(pool)
141 + .await?;
142 +
143 + Ok(rows)
144 + }
@@ -1,0 +1,215 @@
1 + use super::*;
2 +
3 + /// A buyer's email and display name for platform notifications (e.g. creator departure).
4 + ///
5 + /// Unlike [`DbContactRow`], this includes ALL buyers regardless of contact sharing
6 + /// preference, because platform-initiated notifications (content removal warnings)
7 + /// are distinct from creator-initiated contact.
8 + #[derive(sqlx::FromRow)]
9 + pub struct BuyerNotificationRow {
10 + pub email: String,
11 + pub display_name: Option<String>,
12 + }
13 +
14 + /// Get unique buyers who purchased from a seller, for platform notifications.
15 + ///
16 + /// This bypasses `share_contact` and `contact_revocations` because the notification
17 + /// is sent by the platform (not the creator) to warn buyers about content removal.
18 + ///
19 + /// Capped at `limit` rows to bound memory + outbound email volume on creators
20 + /// with very large historical buyer pools. The caller is responsible for
21 + /// noting when the cap is hit (returned slice length == limit).
22 + #[tracing::instrument(skip_all)]
23 + pub async fn get_all_buyers_for_seller(
24 + pool: &PgPool,
25 + seller_id: UserId,
26 + limit: i64,
27 + ) -> Result<Vec<BuyerNotificationRow>> {
28 + let rows = sqlx::query_as!(
29 + BuyerNotificationRow,
30 + r#"
31 + SELECT DISTINCT u.email, u.display_name
32 + FROM transactions t
33 + JOIN users u ON u.id = t.buyer_id
34 + WHERE t.seller_id = $1
35 + AND t.status = 'completed'
36 + AND t.buyer_id IS NOT NULL
37 + LIMIT $2
38 + "#,
39 + seller_id as UserId,
40 + limit,
41 + )
42 + .fetch_all(pool)
43 + .await?;
44 +
45 + Ok(rows)
46 + }
47 +
48 + /// A contact row: a buyer who opted to share their email with the seller.
49 + #[derive(sqlx::FromRow)]
50 + pub struct DbContactRow {
51 + pub username: String,
52 + pub email: String,
53 + pub total_purchases: i64,
54 + pub total_spent_cents: i64,
55 + pub last_purchase_at: DateTime<Utc>,
56 + }
57 +
58 + /// A creator the fan has actively shared contact info with (no revocation).
59 + #[derive(sqlx::FromRow)]
60 + pub struct SharedCreatorRow {
61 + pub seller_id: UserId,
62 + pub username: String,
63 + pub display_name: Option<String>,
64 + }
65 +
66 + /// Get unique contacts for a seller: buyers who opted in to share their email.
67 + ///
68 + /// Aggregates across all completed transactions where `share_contact = true`,
69 + /// returning one row per buyer with purchase stats. Excludes buyers who have
70 + /// revoked contact sharing.
71 + #[tracing::instrument(skip_all)]
72 + pub async fn get_seller_contacts(
73 + pool: &PgPool,
74 + seller_id: UserId,
75 + ) -> Result<Vec<DbContactRow>> {
76 + let rows = sqlx::query_as!(
77 + DbContactRow,
78 + r#"
79 + SELECT
80 + u.username,
81 + u.email,
82 + COUNT(*) AS "total_purchases!",
83 + COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "total_spent_cents!",
84 + MAX(t.completed_at) AS "last_purchase_at!: chrono::DateTime<chrono::Utc>"
85 + FROM transactions t
86 + JOIN users u ON u.id = t.buyer_id
87 + WHERE t.seller_id = $1
88 + AND t.status = 'completed'
89 + AND t.share_contact = true
90 + AND NOT EXISTS (
91 + SELECT 1 FROM contact_revocations cr
92 + WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
93 + )
94 + GROUP BY t.buyer_id, u.username, u.email
95 + ORDER BY MAX(t.completed_at) DESC
96 + LIMIT 500
97 + "#,
98 + seller_id as UserId,
99 + )
100 + .fetch_all(pool)
101 + .await?;
102 +
103 + Ok(rows)
104 + }
105 +
106 + /// One page of a seller's sharing-opted-in contacts for CSV export, newest
107 + /// first. Paginated so the contacts export streams in bounded batches like its
108 + /// sibling exports instead of buffering one capped query into a single `String`
109 + /// (ultra-fuzz Run 6 R6-Perf-M2). `(MAX(completed_at), buyer_id)` ordering is
110 + /// stable so OFFSET batches don't reorder.
111 + #[tracing::instrument(skip_all)]
112 + pub async fn get_seller_contacts_page(
113 + pool: &PgPool,
114 + seller_id: UserId,
115 + limit: i64,
116 + offset: i64,
117 + ) -> Result<Vec<DbContactRow>> {
118 + let rows = sqlx::query_as::<_, DbContactRow>(
119 + r#"
120 + SELECT
121 + u.username,
122 + u.email,
123 + COUNT(*) AS total_purchases,
124 + COALESCE(SUM(t.amount_cents), 0)::BIGINT AS total_spent_cents,
125 + MAX(t.completed_at) AS last_purchase_at
126 + FROM transactions t
127 + JOIN users u ON u.id = t.buyer_id
128 + WHERE t.seller_id = $1
129 + AND t.status = 'completed'
130 + AND t.share_contact = true
131 + AND NOT EXISTS (
132 + SELECT 1 FROM contact_revocations cr
133 + WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
134 + )
135 + GROUP BY t.buyer_id, u.username, u.email
136 + ORDER BY MAX(t.completed_at) DESC, t.buyer_id
137 + LIMIT $2 OFFSET $3
138 + "#,
139 + )
140 + .bind(seller_id)
141 + .bind(limit)
142 + .bind(offset)
143 + .fetch_all(pool)
144 + .await?;
145 +
146 + Ok(rows)
147 + }
148 +
149 + /// Record a contact revocation (fan withdraws email sharing from a creator).
150 + ///
151 + /// Idempotent: does nothing if already revoked.
152 + #[tracing::instrument(skip_all)]
153 + pub async fn revoke_contact_sharing(
154 + pool: &PgPool,
155 + buyer_id: UserId,
156 + seller_id: UserId,
157 + ) -> Result<()> {
158 + sqlx::query!(
159 + "INSERT INTO contact_revocations (buyer_id, seller_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
160 + buyer_id as UserId,
161 + seller_id as UserId,
162 + )
163 + .execute(pool)
164 + .await?;
165 +
166 + Ok(())
167 + }
168 +
169 + /// Clear a contact revocation (fan re-shares on a new purchase).
170 + #[tracing::instrument(skip_all)]
171 + pub async fn clear_contact_revocation(
172 + pool: &PgPool,
173 + buyer_id: UserId,
174 + seller_id: UserId,
175 + ) -> Result<()> {
176 + sqlx::query!(
177 + "DELETE FROM contact_revocations WHERE buyer_id = $1 AND seller_id = $2",
178 + buyer_id as UserId,
179 + seller_id as UserId,
180 + )
181 + .execute(pool)
182 + .await?;
183 +
184 + Ok(())
185 + }
186 +
187 + /// Get creators the fan has actively shared contact info with (excluding revoked).
188 + #[tracing::instrument(skip_all)]
189 + pub async fn get_shared_creators(
190 + pool: &PgPool,
191 + buyer_id: UserId,
192 + ) -> Result<Vec<SharedCreatorRow>> {
193 + let rows = sqlx::query_as!(
194 + SharedCreatorRow,
195 + r#"
196 + SELECT DISTINCT t.seller_id AS "seller_id!: UserId", u.username, u.display_name
197 + FROM transactions t
198 + JOIN users u ON u.id = t.seller_id
199 + WHERE t.buyer_id = $1
200 + AND t.status = 'completed'
201 + AND t.share_contact = true
202 + AND NOT EXISTS (
203 + SELECT 1 FROM contact_revocations cr
204 + WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
205 + )
206 + ORDER BY u.username
207 + LIMIT 500
208 + "#,
209 + buyer_id as UserId,
210 + )
211 + .fetch_all(pool)
212 + .await?;
213 +
214 + Ok(rows)
215 + }