Skip to main content

max / makenotwork

8.9 KB · 233 lines History Blame Raw
1 //! Transaction and purchase models.
2
3 use chrono::{DateTime, Utc};
4 use serde::Serialize;
5 use sqlx::FromRow;
6
7 use super::super::id_types::{
8 ClaimToken, DownloadToken, ItemId, LoginTokenId, ProjectId, PromoCodeId, TransactionId, UserId,
9 };
10 use super::super::validated_types::{Cents, KeyCode};
11
12 /// Completed-transaction state: fields that are always present when
13 /// `status == Completed`.
14 #[derive(Debug, Clone)]
15 pub struct CompletedTransactionInfo {
16 /// Stripe PaymentIntent ID.
17 pub stripe_payment_intent_id: String,
18 /// When the payment was confirmed.
19 pub completed_at: DateTime<Utc>,
20 }
21
22 /// A purchase transaction between buyer and seller.
23 ///
24 /// **State invariant:** When `status == Completed`, both
25 /// `stripe_payment_intent_id` and `completed_at` are `Some`. For free-item
26 /// claims (amount_cents == 0), `completed_at` is set at creation but
27 /// `stripe_payment_intent_id` may be `None` (no Stripe involved).
28 ///
29 /// **Guest checkout:** When `buyer_id` is `None`, this is a guest purchase.
30 /// `guest_email` holds the buyer's email from Stripe, `download_token` provides
31 /// a signed download link, and `claim_token` allows attaching to an account later.
32 #[derive(Debug, Clone, FromRow, Serialize)]
33 pub struct DbTransaction {
34 /// Database primary key.
35 pub id: TransactionId,
36 /// User who made the purchase (None for guest checkouts).
37 pub buyer_id: Option<UserId>,
38 /// Seller user ID (nullable if seller deleted).
39 pub seller_id: Option<UserId>,
40 /// Purchased item ID (nullable if item deleted).
41 pub item_id: Option<ItemId>,
42 /// Total charge in cents.
43 pub amount_cents: Cents,
44 /// Platform fee in cents (always 0 on Makenotwork).
45 pub platform_fee_cents: Cents,
46 /// ISO 4217 currency code (e.g. "usd"). Read it via
47 /// [`DbTransaction::currency`] rather than parsing the string at call sites.
48 pub currency: String,
49 /// What the buyer was actually charged, when Stripe converted at checkout.
50 /// `None` when they paid in the seller's currency, which is the common case.
51 /// The creator is paid `amount_cents` in `currency` either way.
52 pub presentment_amount_cents: Option<i64>,
53 /// The currency `presentment_amount_cents` is in. Any currency Stripe can
54 /// present, not just the six MNW settles in, so it stays a plain string.
55 pub presentment_currency: Option<String>,
56 /// Transaction status.
57 pub status: super::super::TransactionStatus,
58 /// Stripe PaymentIntent ID. Present when `status == Completed` and amount > 0.
59 pub stripe_payment_intent_id: Option<String>,
60 /// Stripe Checkout Session ID for idempotency.
61 pub stripe_checkout_session_id: Option<String>,
62 /// When the transaction was initiated.
63 pub created_at: DateTime<Utc>,
64 /// When the payment was confirmed. Present when `status == Completed`.
65 pub completed_at: Option<DateTime<Utc>>,
66 // Denormalized fields preserved after seller/item deletion
67 /// Snapshot of item title at purchase time.
68 pub item_title: Option<String>,
69 /// Snapshot of seller username at purchase time.
70 pub seller_username: Option<String>,
71 /// Whether the buyer opted to share their email with the creator.
72 pub share_contact: bool,
73 /// Purchased project ID (for project-level purchases). Nullable.
74 pub project_id: Option<ProjectId>,
75 /// Parent bundle transaction that granted this child item. Nullable.
76 pub parent_transaction_id: Option<TransactionId>,
77 /// Promo code used for this purchase (for releasing reservations on stale cleanup).
78 pub promo_code_id: Option<PromoCodeId>,
79 /// Guest buyer's email from Stripe (None for logged-in purchases).
80 pub guest_email: Option<String>,
81 /// Token for attaching this guest purchase to an account later.
82 pub claim_token: Option<ClaimToken>,
83 /// User ID that claimed this guest purchase (None until claimed).
84 pub claimed_by: Option<UserId>,
85 /// Token for direct download links (no auth required).
86 pub download_token: Option<DownloadToken>,
87 }
88
89 impl DbTransaction {
90 /// The currency this sale was denominated in.
91 ///
92 /// The column predates settlement currency and held a constant 'USD' for
93 /// every row until migration 190, so it is a `String` on the row and a typed
94 /// value here. Historical rows keep the currency they were written with,
95 /// which is what makes an old sale still readable after a creator's
96 /// settlement currency changes.
97 pub fn currency(&self) -> crate::currency::SettlementCurrency {
98 crate::currency::SettlementCurrency::from_db(&self.currency)
99 }
100
101 /// Extract the completed-state fields as a coherent unit.
102 ///
103 /// Returns `Some` only for paid completed transactions (amount > 0).
104 /// Free claims have `completed_at` but no `stripe_payment_intent_id`.
105 pub fn completed_info(&self) -> Option<CompletedTransactionInfo> {
106 Some(CompletedTransactionInfo {
107 stripe_payment_intent_id: self.stripe_payment_intent_id.clone()?,
108 completed_at: self.completed_at?,
109 })
110 }
111 }
112
113 /// A transaction row for CSV export, with conditional buyer email.
114 #[derive(Debug, Clone, FromRow)]
115 pub struct DbTransactionExportRow {
116 pub created_at: DateTime<Utc>,
117 pub item_id: Option<ItemId>,
118 pub item_title: Option<String>,
119 pub amount_cents: Cents,
120 pub status: super::super::TransactionStatus,
121 /// Buyer email, only present when share_contact is true.
122 pub buyer_email: Option<String>,
123 }
124
125 /// A row from the user's purchase history (used on the "For You" page).
126 #[derive(Debug, Clone, FromRow)]
127 pub struct DbPurchaseRow {
128 /// Transaction ID for receipt links.
129 pub transaction_id: TransactionId,
130 /// Purchased item's ID.
131 pub item_id: ItemId,
132 /// Item title at the time of query.
133 pub title: String,
134 /// Creator's username.
135 pub creator: String,
136 /// Content type of the item.
137 pub item_type: super::super::ItemType,
138 /// When the purchase was completed.
139 pub purchased_at: DateTime<Utc>,
140 /// Whether the item was free (price_cents = 0).
141 pub is_free: bool,
142 /// License key code for this item (if any, non-revoked).
143 pub license_key_code: Option<KeyCode>,
144 /// True if the item has a version the user hasn't downloaded yet.
145 pub has_new_version: bool,
146 }
147
148 /// A one-time passwordless login token (magic link).
149 #[derive(Debug, Clone, FromRow)]
150 #[allow(dead_code)] // Fields populated by sqlx query
151 pub struct DbLoginToken {
152 /// Database primary key.
153 pub id: LoginTokenId,
154 /// User this token authenticates.
155 pub user_id: UserId,
156 /// SHA-256 hash of the actual token value.
157 pub token_hash: String,
158 /// When this token becomes invalid.
159 pub expires_at: DateTime<Utc>,
160 /// When this token was consumed (set on use, prevents replay).
161 pub used_at: Option<DateTime<Utc>>,
162 /// When this token was created.
163 pub created_at: DateTime<Utc>,
164 }
165
166 #[cfg(test)]
167 mod tests {
168 use super::*;
169
170 fn make_transaction(
171 status: super::super::super::TransactionStatus,
172 pi_id: Option<&str>,
173 completed: Option<DateTime<Utc>>,
174 ) -> DbTransaction {
175 DbTransaction {
176 id: TransactionId::nil(),
177 buyer_id: Some(UserId::nil()),
178 seller_id: None,
179 item_id: None,
180 amount_cents: Cents::ZERO,
181 platform_fee_cents: Cents::ZERO,
182 currency: "usd".to_string(),
183 presentment_amount_cents: None,
184 presentment_currency: None,
185 status,
186 stripe_payment_intent_id: pi_id.map(std::string::ToString::to_string),
187 stripe_checkout_session_id: None,
188 created_at: Utc::now(),
189 completed_at: completed,
190 item_title: None,
191 seller_username: None,
192 share_contact: false,
193 project_id: None,
194 parent_transaction_id: None,
195 promo_code_id: None,
196 guest_email: None,
197 claim_token: None,
198 claimed_by: None,
199 download_token: None,
200 }
201 }
202
203 #[test]
204 fn completed_info_for_paid_transaction() {
205 let now = Utc::now();
206 let tx = make_transaction(
207 super::super::super::TransactionStatus::Completed,
208 Some("pi_123"),
209 Some(now),
210 );
211 let info = tx.completed_info().unwrap();
212 assert_eq!(info.stripe_payment_intent_id, "pi_123");
213 assert_eq!(info.completed_at, now);
214 }
215
216 #[test]
217 fn completed_info_none_for_pending() {
218 let tx = make_transaction(super::super::super::TransactionStatus::Pending, None, None);
219 assert!(tx.completed_info().is_none());
220 }
221
222 #[test]
223 fn completed_info_none_for_free_claim() {
224 // Free claims have completed_at but no stripe_payment_intent_id
225 let tx = make_transaction(
226 super::super::super::TransactionStatus::Completed,
227 None,
228 Some(Utc::now()),
229 );
230 assert!(tx.completed_info().is_none());
231 }
232 }
233