Skip to main content

max / makenotwork

7.7 KB · 212 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").
47 pub currency: String,
48 /// Transaction status.
49 pub status: super::super::TransactionStatus,
50 /// Stripe PaymentIntent ID. Present when `status == Completed` and amount > 0.
51 pub stripe_payment_intent_id: Option<String>,
52 /// Stripe Checkout Session ID for idempotency.
53 pub stripe_checkout_session_id: Option<String>,
54 /// When the transaction was initiated.
55 pub created_at: DateTime<Utc>,
56 /// When the payment was confirmed. Present when `status == Completed`.
57 pub completed_at: Option<DateTime<Utc>>,
58 // Denormalized fields preserved after seller/item deletion
59 /// Snapshot of item title at purchase time.
60 pub item_title: Option<String>,
61 /// Snapshot of seller username at purchase time.
62 pub seller_username: Option<String>,
63 /// Whether the buyer opted to share their email with the creator.
64 pub share_contact: bool,
65 /// Purchased project ID (for project-level purchases). Nullable.
66 pub project_id: Option<ProjectId>,
67 /// Parent bundle transaction that granted this child item. Nullable.
68 pub parent_transaction_id: Option<TransactionId>,
69 /// Promo code used for this purchase (for releasing reservations on stale cleanup).
70 pub promo_code_id: Option<PromoCodeId>,
71 /// Guest buyer's email from Stripe (None for logged-in purchases).
72 pub guest_email: Option<String>,
73 /// Token for attaching this guest purchase to an account later.
74 pub claim_token: Option<ClaimToken>,
75 /// User ID that claimed this guest purchase (None until claimed).
76 pub claimed_by: Option<UserId>,
77 /// Token for direct download links (no auth required).
78 pub download_token: Option<DownloadToken>,
79 }
80
81 impl DbTransaction {
82 /// Extract the completed-state fields as a coherent unit.
83 ///
84 /// Returns `Some` only for paid completed transactions (amount > 0).
85 /// Free claims have `completed_at` but no `stripe_payment_intent_id`.
86 pub fn completed_info(&self) -> Option<CompletedTransactionInfo> {
87 Some(CompletedTransactionInfo {
88 stripe_payment_intent_id: self.stripe_payment_intent_id.clone()?,
89 completed_at: self.completed_at?,
90 })
91 }
92 }
93
94 /// A transaction row for CSV export, with conditional buyer email.
95 #[derive(Debug, Clone, FromRow)]
96 pub struct DbTransactionExportRow {
97 pub created_at: DateTime<Utc>,
98 pub item_id: Option<ItemId>,
99 pub item_title: Option<String>,
100 pub amount_cents: Cents,
101 pub status: super::super::TransactionStatus,
102 /// Buyer email, only present when share_contact is true.
103 pub buyer_email: Option<String>,
104 }
105
106 /// A row from the user's purchase history (used on the "For You" page).
107 #[derive(Debug, Clone, FromRow)]
108 pub struct DbPurchaseRow {
109 /// Transaction ID for receipt links.
110 pub transaction_id: TransactionId,
111 /// Purchased item's ID.
112 pub item_id: ItemId,
113 /// Item title at the time of query.
114 pub title: String,
115 /// Creator's username.
116 pub creator: String,
117 /// Content type of the item.
118 pub item_type: super::super::ItemType,
119 /// When the purchase was completed.
120 pub purchased_at: DateTime<Utc>,
121 /// Whether the item was free (price_cents = 0).
122 pub is_free: bool,
123 /// License key code for this item (if any, non-revoked).
124 pub license_key_code: Option<KeyCode>,
125 /// True if the item has a version the user hasn't downloaded yet.
126 pub has_new_version: bool,
127 }
128
129 /// A one-time passwordless login token (magic link).
130 #[derive(Debug, Clone, FromRow)]
131 #[allow(dead_code)] // Fields populated by sqlx query
132 pub struct DbLoginToken {
133 /// Database primary key.
134 pub id: LoginTokenId,
135 /// User this token authenticates.
136 pub user_id: UserId,
137 /// SHA-256 hash of the actual token value.
138 pub token_hash: String,
139 /// When this token becomes invalid.
140 pub expires_at: DateTime<Utc>,
141 /// When this token was consumed (set on use, prevents replay).
142 pub used_at: Option<DateTime<Utc>>,
143 /// When this token was created.
144 pub created_at: DateTime<Utc>,
145 }
146
147 #[cfg(test)]
148 mod tests {
149 use super::*;
150
151 fn make_transaction(
152 status: super::super::super::TransactionStatus,
153 pi_id: Option<&str>,
154 completed: Option<DateTime<Utc>>,
155 ) -> DbTransaction {
156 DbTransaction {
157 id: TransactionId::nil(),
158 buyer_id: Some(UserId::nil()),
159 seller_id: None,
160 item_id: None,
161 amount_cents: Cents::ZERO,
162 platform_fee_cents: Cents::ZERO,
163 currency: "usd".to_string(),
164 status,
165 stripe_payment_intent_id: pi_id.map(std::string::ToString::to_string),
166 stripe_checkout_session_id: None,
167 created_at: Utc::now(),
168 completed_at: completed,
169 item_title: None,
170 seller_username: None,
171 share_contact: false,
172 project_id: None,
173 parent_transaction_id: None,
174 promo_code_id: None,
175 guest_email: None,
176 claim_token: None,
177 claimed_by: None,
178 download_token: None,
179 }
180 }
181
182 #[test]
183 fn completed_info_for_paid_transaction() {
184 let now = Utc::now();
185 let tx = make_transaction(
186 super::super::super::TransactionStatus::Completed,
187 Some("pi_123"),
188 Some(now),
189 );
190 let info = tx.completed_info().unwrap();
191 assert_eq!(info.stripe_payment_intent_id, "pi_123");
192 assert_eq!(info.completed_at, now);
193 }
194
195 #[test]
196 fn completed_info_none_for_pending() {
197 let tx = make_transaction(super::super::super::TransactionStatus::Pending, None, None);
198 assert!(tx.completed_info().is_none());
199 }
200
201 #[test]
202 fn completed_info_none_for_free_claim() {
203 // Free claims have completed_at but no stripe_payment_intent_id
204 let tx = make_transaction(
205 super::super::super::TransactionStatus::Completed,
206 None,
207 Some(Utc::now()),
208 );
209 assert!(tx.completed_info().is_none());
210 }
211 }
212