| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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
|