Skip to main content

max / makenotwork

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