Skip to main content

max / makenotwork

7.6 KB · 194 lines History Blame Raw
1 //! Purchases made without an account.
2 //!
3 //! A guest purchase lands with `buyer_id` NULL, identified by its claim and
4 //! download tokens, and is attached to an account out of band once the buyer
5 //! verifies the email it was bought with. These are the only queries that
6 //! touch `claim_token`, `download_token` or `guest_email`.
7
8 use super::super::{
9 Cents, ClaimToken, DbTransaction, DownloadToken, ItemId, PgPool, ProjectId, PromoCodeId,
10 Result, TransactionId, UserId,
11 };
12
13 /// Complete a guest transaction: mark it completed, record the guest email, and
14 /// mint a `claim_token` so the buyer can later attach the purchase to an account.
15 ///
16 /// Guest purchases always land unclaimed (`buyer_id` NULL); attachment to a user
17 /// happens out of band via [`attach_guest_purchases_by_email`] at signup/email
18 /// verification.
19 #[tracing::instrument(skip_all)]
20 pub async fn complete_guest_transaction<'e>(
21 executor: impl sqlx::PgExecutor<'e>,
22 stripe_checkout_session_id: &str,
23 stripe_payment_intent_id: Option<&str>,
24 guest_email: &str,
25 ) -> Result<Option<DbTransaction>> {
26 let claim_token = ClaimToken::new();
27
28 let tx = sqlx::query_as!(
29 DbTransaction,
30 r#"
31 UPDATE transactions
32 SET status = 'completed',
33 stripe_payment_intent_id = $2,
34 completed_at = NOW(),
35 guest_email = $3,
36 claim_token = $4,
37 buyer_id = NULL
38 WHERE stripe_checkout_session_id = $1
39 AND status = 'pending'
40 RETURNING
41 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
42 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
43 currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
44 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
45 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
46 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
47 guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId",
48 download_token AS "download_token: DownloadToken",
49 presentment_amount_cents, presentment_currency
50 "#,
51 stripe_checkout_session_id,
52 stripe_payment_intent_id,
53 guest_email,
54 claim_token as ClaimToken,
55 )
56 .fetch_optional(executor)
57 .await?;
58
59 Ok(tx)
60 }
61
62 /// Attach all unclaimed guest purchases for an email to a user account.
63 /// Called during signup/email verification to auto-claim prior guest purchases.
64 #[tracing::instrument(skip_all)]
65 pub async fn attach_guest_purchases_by_email(
66 pool: &PgPool,
67 email: &str,
68 user_id: UserId,
69 ) -> Result<u64> {
70 let result = sqlx::query!(
71 r#"
72 UPDATE transactions
73 SET buyer_id = $1, claimed_by = $1, claim_token = NULL
74 WHERE LOWER(guest_email) = LOWER($2)
75 AND buyer_id IS NULL
76 AND status = 'completed'
77 "#,
78 user_id as UserId,
79 email,
80 )
81 .execute(pool)
82 .await?;
83
84 Ok(result.rows_affected())
85 }
86
87 /// Claim a single guest purchase by claim token.
88 #[tracing::instrument(skip_all)]
89 pub async fn claim_guest_purchase(
90 pool: &PgPool,
91 claim_token: ClaimToken,
92 user_id: UserId,
93 ) -> Result<Option<DbTransaction>> {
94 let tx = sqlx::query_as!(
95 DbTransaction,
96 r#"
97 UPDATE transactions
98 SET buyer_id = $2, claimed_by = $2, claim_token = NULL
99 WHERE claim_token = $1
100 AND buyer_id IS NULL
101 AND status = 'completed'
102 RETURNING
103 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
104 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
105 currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
106 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
107 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
108 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
109 guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId",
110 download_token AS "download_token: DownloadToken",
111 presentment_amount_cents, presentment_currency
112 "#,
113 claim_token as ClaimToken,
114 user_id as UserId,
115 )
116 .fetch_optional(pool)
117 .await?;
118
119 Ok(tx)
120 }
121
122 /// Look up a completed transaction by download token (for guest download links).
123 #[tracing::instrument(skip_all)]
124 pub async fn get_transaction_by_download_token(
125 pool: &PgPool,
126 download_token: DownloadToken,
127 ) -> Result<Option<DbTransaction>> {
128 let tx = sqlx::query_as!(
129 DbTransaction,
130 r#"
131 SELECT
132 id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
133 item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
134 currency, status AS "status: crate::db::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
135 created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
136 item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
137 parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
138 guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId",
139 download_token AS "download_token: DownloadToken",
140 presentment_amount_cents, presentment_currency
141 FROM transactions WHERE download_token = $1 AND status = 'completed'
142 "#,
143 download_token as DownloadToken,
144 )
145 .fetch_optional(pool)
146 .await?;
147
148 Ok(tx)
149 }
150
151 /// Create a completed free guest transaction.
152 ///
153 /// Returns the number of rows inserted (0 if already claimed via ON CONFLICT).
154 #[allow(clippy::too_many_arguments)]
155 #[tracing::instrument(skip_all)]
156 pub async fn create_free_guest_transaction(
157 pool: &PgPool,
158 buyer_id: Option<UserId>,
159 seller_id: UserId,
160 item_id: ItemId,
161 checkout_session_id: &str,
162 item_title: &str,
163 seller_username: &str,
164 guest_email: &str,
165 claim_token: Option<ClaimToken>,
166 download_token: DownloadToken,
167 ) -> std::result::Result<u64, sqlx::Error> {
168 let result = sqlx::query!(
169 r#"
170 INSERT INTO transactions (
171 buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
172 stripe_checkout_session_id, status, completed_at,
173 item_title, seller_username, share_contact,
174 guest_email, claim_token, download_token
175 )
176 VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, false, $7, $8, $9)
177 ON CONFLICT (guest_email, item_id) WHERE status = 'completed' AND guest_email IS NOT NULL DO NOTHING
178 "#,
179 buyer_id as Option<UserId>,
180 seller_id as UserId,
181 item_id as ItemId,
182 checkout_session_id,
183 item_title,
184 seller_username,
185 guest_email,
186 claim_token as Option<ClaimToken>,
187 download_token as DownloadToken,
188 )
189 .execute(pool)
190 .await?;
191
192 Ok(result.rows_affected())
193 }
194