//! Transaction and purchase models. use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::FromRow; use super::super::id_types::*; use super::super::validated_types::*; /// Completed-transaction state: fields that are always present when /// `status == Completed`. #[derive(Debug, Clone)] pub struct CompletedTransactionInfo { /// Stripe PaymentIntent ID. pub stripe_payment_intent_id: String, /// When the payment was confirmed. pub completed_at: DateTime, } /// A purchase transaction between buyer and seller. /// /// **State invariant:** When `status == Completed`, both /// `stripe_payment_intent_id` and `completed_at` are `Some`. For free-item /// claims (amount_cents == 0), `completed_at` is set at creation but /// `stripe_payment_intent_id` may be `None` (no Stripe involved). /// /// **Guest checkout:** When `buyer_id` is `None`, this is a guest purchase. /// `guest_email` holds the buyer's email from Stripe, `download_token` provides /// a signed download link, and `claim_token` allows attaching to an account later. #[derive(Debug, Clone, FromRow, Serialize)] pub struct DbTransaction { /// Database primary key. pub id: TransactionId, /// User who made the purchase (None for guest checkouts). pub buyer_id: Option, /// Seller user ID (nullable if seller deleted). pub seller_id: Option, /// Purchased item ID (nullable if item deleted). pub item_id: Option, /// Total charge in cents. pub amount_cents: Cents, /// Platform fee in cents (always 0 on Makenotwork). pub platform_fee_cents: Cents, /// ISO 4217 currency code (e.g. "usd"). pub currency: String, /// Transaction status. pub status: super::super::TransactionStatus, /// Stripe PaymentIntent ID. Present when `status == Completed` and amount > 0. pub stripe_payment_intent_id: Option, /// Stripe Checkout Session ID for idempotency. pub stripe_checkout_session_id: Option, /// When the transaction was initiated. pub created_at: DateTime, /// When the payment was confirmed. Present when `status == Completed`. pub completed_at: Option>, // Denormalized fields preserved after seller/item deletion /// Snapshot of item title at purchase time. pub item_title: Option, /// Snapshot of seller username at purchase time. pub seller_username: Option, /// Whether the buyer opted to share their email with the creator. pub share_contact: bool, /// Purchased project ID (for project-level purchases). Nullable. pub project_id: Option, /// Parent bundle transaction that granted this child item. Nullable. pub parent_transaction_id: Option, /// Promo code used for this purchase (for releasing reservations on stale cleanup). pub promo_code_id: Option, /// Guest buyer's email from Stripe (None for logged-in purchases). pub guest_email: Option, /// Token for attaching this guest purchase to an account later. pub claim_token: Option, /// User ID that claimed this guest purchase (None until claimed). pub claimed_by: Option, /// Token for direct download links (no auth required). pub download_token: Option, } impl DbTransaction { /// Extract the completed-state fields as a coherent unit. /// /// Returns `Some` only for paid completed transactions (amount > 0). /// Free claims have `completed_at` but no `stripe_payment_intent_id`. pub fn completed_info(&self) -> Option { Some(CompletedTransactionInfo { stripe_payment_intent_id: self.stripe_payment_intent_id.clone()?, completed_at: self.completed_at?, }) } } /// A transaction row for CSV export, with conditional buyer email. #[derive(Debug, Clone, FromRow)] pub struct DbTransactionExportRow { pub created_at: DateTime, pub item_id: Option, pub item_title: Option, pub amount_cents: Cents, pub status: super::super::TransactionStatus, /// Buyer email, only present when share_contact is true. pub buyer_email: Option, } /// A row from the user's purchase history (used on the "For You" page). #[derive(Debug, Clone, FromRow)] pub struct DbPurchaseRow { /// Transaction ID for receipt links. pub transaction_id: TransactionId, /// Purchased item's ID. pub item_id: ItemId, /// Item title at the time of query. pub title: String, /// Creator's username. pub creator: String, /// Content type of the item. pub item_type: super::super::ItemType, /// When the purchase was completed. pub purchased_at: DateTime, /// Whether the item was free (price_cents = 0). pub is_free: bool, /// License key code for this item (if any, non-revoked). pub license_key_code: Option, /// True if the item has a version the user hasn't downloaded yet. pub has_new_version: bool, } /// A one-time passwordless login token (magic link). #[derive(Debug, Clone, FromRow)] #[allow(dead_code)] // Fields populated by sqlx query pub struct DbLoginToken { /// Database primary key. pub id: LoginTokenId, /// User this token authenticates. pub user_id: UserId, /// SHA-256 hash of the actual token value. pub token_hash: String, /// When this token becomes invalid. pub expires_at: DateTime, /// When this token was consumed (set on use, prevents replay). pub used_at: Option>, /// When this token was created. pub created_at: DateTime, } #[cfg(test)] mod tests { use super::*; fn make_transaction( status: super::super::super::TransactionStatus, pi_id: Option<&str>, completed: Option>, ) -> DbTransaction { DbTransaction { id: TransactionId::nil(), buyer_id: Some(UserId::nil()), seller_id: None, item_id: None, amount_cents: Cents::ZERO, platform_fee_cents: Cents::ZERO, currency: "usd".to_string(), status, stripe_payment_intent_id: pi_id.map(|s| s.to_string()), stripe_checkout_session_id: None, created_at: Utc::now(), completed_at: completed, item_title: None, seller_username: None, share_contact: false, project_id: None, parent_transaction_id: None, promo_code_id: None, guest_email: None, claim_token: None, claimed_by: None, download_token: None, } } #[test] fn completed_info_for_paid_transaction() { let now = Utc::now(); let tx = make_transaction( super::super::super::TransactionStatus::Completed, Some("pi_123"), Some(now), ); let info = tx.completed_info().unwrap(); assert_eq!(info.stripe_payment_intent_id, "pi_123"); assert_eq!(info.completed_at, now); } #[test] fn completed_info_none_for_pending() { let tx = make_transaction( super::super::super::TransactionStatus::Pending, None, None, ); assert!(tx.completed_info().is_none()); } #[test] fn completed_info_none_for_free_claim() { // Free claims have completed_at but no stripe_payment_intent_id let tx = make_transaction( super::super::super::TransactionStatus::Completed, None, Some(Utc::now()), ); assert!(tx.completed_info().is_none()); } }