mostro_core/dispute.rs
1//! Dispute representation and lifecycle states.
2//!
3//! A [`Dispute`] is opened when one of the counterparts of a trade asks
4//! Mostro to involve a solver. The dispute moves through a small state
5//! machine described by [`Status`] until it is either settled, refunded or
6//! released.
7//!
8//! [`SolverDisputeInfo`] is the payload surfaced to solvers with all the
9//! trade details they need to render the dispute in their UI without
10//! loading additional data.
11
12use crate::{order::Order, user::User, user::UserInfo};
13use chrono::Utc;
14use nostr_sdk::Timestamp;
15use serde::{Deserialize, Serialize};
16#[cfg(feature = "sqlx")]
17use sqlx::{FromRow, Type};
18#[cfg(feature = "sqlx")]
19use sqlx_crud::SqlxCrud;
20use std::{fmt::Display, str::FromStr};
21use uuid::Uuid;
22
23/// Lifecycle status of a [`Dispute`].
24#[cfg_attr(feature = "sqlx", derive(Type))]
25#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
26#[serde(rename_all = "kebab-case")]
27pub enum Status {
28 /// Dispute has been initiated and is waiting to be taken by a solver.
29 #[default]
30 Initiated,
31 /// A solver has taken the dispute and is working on it.
32 InProgress,
33 /// Admin/solver canceled the trade and refunded the seller.
34 SellerRefunded,
35 /// Admin/solver settled the seller's hold invoice and initiated payment
36 /// to the buyer.
37 Settled,
38 /// The seller released the funds before the dispute was resolved.
39 Released,
40}
41
42impl Display for Status {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Status::Initiated => write!(f, "initiated"),
46 Status::InProgress => write!(f, "in-progress"),
47 Status::SellerRefunded => write!(f, "seller-refunded"),
48 Status::Settled => write!(f, "settled"),
49 Status::Released => write!(f, "released"),
50 }
51 }
52}
53
54impl FromStr for Status {
55 type Err = ();
56
57 /// Parse a [`Status`] from its kebab-case string representation.
58 ///
59 /// Returns `Err(())` if `s` does not match a known variant.
60 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
61 match s {
62 "initiated" => std::result::Result::Ok(Self::Initiated),
63 "in-progress" => std::result::Result::Ok(Self::InProgress),
64 "seller-refunded" => std::result::Result::Ok(Self::SellerRefunded),
65 "settled" => std::result::Result::Ok(Self::Settled),
66 "released" => std::result::Result::Ok(Self::Released),
67 _ => Err(()),
68 }
69 }
70}
71
72/// Database representation of a dispute.
73///
74/// Disputes are always bound to a parent [`Order`]; `order_previous_status`
75/// preserves the status the order had before the dispute was filed so that
76/// it can be restored if the dispute is dismissed.
77#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud), external_id)]
78#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
79pub struct Dispute {
80 /// Unique identifier for the dispute.
81 pub id: Uuid,
82 /// Id of the order the dispute is attached to.
83 pub order_id: Uuid,
84 /// Current [`Status`] of the dispute, serialized as kebab-case.
85 pub status: String,
86 /// The status the underlying order had before the dispute was opened.
87 pub order_previous_status: String,
88 /// Public key of the solver that has taken the dispute, if any.
89 pub solver_pubkey: Option<String>,
90 /// Unix timestamp (seconds) when the dispute was created.
91 pub created_at: i64,
92 /// Unix timestamp (seconds) when the dispute was taken by a solver.
93 /// `0` when it has not been taken yet.
94 pub taken_at: i64,
95}
96
97/// Extended dispute view for solvers.
98///
99/// Bundles the [`Dispute`] together with the key fields of its parent
100/// [`Order`] so a solver UI can render everything needed without additional
101/// database lookups, while still respecting the `full privacy` setting of
102/// each counterpart.
103#[derive(Debug, Default, Deserialize, Serialize, Clone)]
104pub struct SolverDisputeInfo {
105 /// Order id the dispute is attached to.
106 pub id: Uuid,
107 /// Order kind (`buy` or `sell`), serialized as kebab-case.
108 pub kind: String,
109 /// Order status at the time the dispute view was built.
110 pub status: String,
111 /// Payment hash of the hold invoice.
112 pub hash: Option<String>,
113 /// Preimage revealed once the hold invoice is settled.
114 pub preimage: Option<String>,
115 /// Status the order had immediately before the dispute was opened.
116 pub order_previous_status: String,
117 /// Trade public key of the dispute initiator.
118 pub initiator_pubkey: String,
119 /// Buyer's trade public key, if available.
120 pub buyer_pubkey: Option<String>,
121 /// Seller's trade public key, if available.
122 pub seller_pubkey: Option<String>,
123 /// `true` when the initiator is operating in full privacy mode, hiding
124 /// its reputation.
125 pub initiator_full_privacy: bool,
126 /// `true` when the counterpart is operating in full privacy mode.
127 pub counterpart_full_privacy: bool,
128 /// Reputation snapshot of the initiator, when privacy allows it.
129 pub initiator_info: Option<UserInfo>,
130 /// Reputation snapshot of the counterpart, when privacy allows it.
131 pub counterpart_info: Option<UserInfo>,
132 /// Premium percentage applied to the order price.
133 pub premium: i64,
134 /// Payment method agreed upon for the fiat leg.
135 pub payment_method: String,
136 /// Sats amount of the trade.
137 pub amount: i64,
138 /// Fiat amount of the trade.
139 pub fiat_amount: i64,
140 /// Mostro fee charged for the trade.
141 pub fee: i64,
142 /// Lightning routing fee paid when settling the trade.
143 pub routing_fee: i64,
144 /// Buyer's Lightning invoice, if already provided.
145 pub buyer_invoice: Option<String>,
146 /// Unix timestamp (seconds) when the hold invoice was locked in.
147 pub invoice_held_at: i64,
148 /// Unix timestamp (seconds) when the order was taken.
149 pub taken_at: i64,
150 /// Unix timestamp (seconds) when the order was created.
151 pub created_at: i64,
152}
153
154impl SolverDisputeInfo {
155 /// Build a [`SolverDisputeInfo`] from an order, its dispute and the
156 /// optional [`User`] records of both counterparts.
157 ///
158 /// When a [`User`] is provided, the corresponding privacy flag is set to
159 /// `false` and a [`UserInfo`] snapshot is included (rating, reviews,
160 /// operating days computed from `created_at`). When a [`User`] is `None`,
161 /// the party is considered to be operating in full privacy mode.
162 pub fn new(
163 order: &Order,
164 dispute: &Dispute,
165 initiator_tradekey: String,
166 counterpart: Option<User>,
167 initiator: Option<User>,
168 ) -> Self {
169 // Get initiator and counterpart info if not in full privacy mode
170 let mut initiator_info = None;
171 let mut counterpart_info = None;
172 let mut initiator_full_privacy = true;
173 let mut counterpart_full_privacy = true;
174
175 if let Some(initiator) = initiator {
176 let now = Timestamp::now();
177 let initiator_operating_days = (now.as_secs() - initiator.created_at as u64) / 86400;
178 initiator_info = Some(UserInfo {
179 rating: initiator.total_rating,
180 reviews: initiator.total_reviews,
181 operating_days: initiator_operating_days,
182 });
183 initiator_full_privacy = false;
184 }
185 if let Some(counterpart) = counterpart {
186 let now = Timestamp::now();
187 let couterpart_operating_days = (now.as_secs() - counterpart.created_at as u64) / 86400;
188 counterpart_info = Some(UserInfo {
189 rating: counterpart.total_rating,
190 reviews: counterpart.total_reviews,
191 operating_days: couterpart_operating_days,
192 });
193 counterpart_full_privacy = false;
194 }
195
196 Self {
197 id: order.id,
198 kind: order.kind.clone(),
199 status: order.status.clone(),
200 hash: order.hash.clone(),
201 preimage: order.preimage.clone(),
202 order_previous_status: dispute.order_previous_status.clone(),
203 initiator_pubkey: initiator_tradekey,
204 buyer_pubkey: order.buyer_pubkey.clone(),
205 seller_pubkey: order.seller_pubkey.clone(),
206 initiator_full_privacy,
207 counterpart_full_privacy,
208 counterpart_info,
209 initiator_info,
210 premium: order.premium,
211 payment_method: order.payment_method.clone(),
212 amount: order.amount,
213 fiat_amount: order.fiat_amount,
214 fee: order.fee,
215 routing_fee: order.routing_fee,
216 buyer_invoice: order.buyer_invoice.clone(),
217 invoice_held_at: order.invoice_held_at,
218 taken_at: order.taken_at,
219 created_at: order.created_at,
220 }
221 }
222}
223
224impl Dispute {
225 /// Create a new dispute for an order.
226 ///
227 /// The dispute starts in [`Status::Initiated`] with a fresh UUID, the
228 /// current timestamp as `created_at`, no solver assigned and `taken_at`
229 /// set to `0`. `order_status` should be the current status of the order
230 /// at the moment the dispute is filed; it is preserved in
231 /// `order_previous_status`.
232 pub fn new(order_id: Uuid, order_status: String) -> Self {
233 Self {
234 id: Uuid::new_v4(),
235 order_id,
236 status: Status::Initiated.to_string(),
237 order_previous_status: order_status,
238 solver_pubkey: None,
239 created_at: Utc::now().timestamp(),
240 taken_at: 0,
241 }
242 }
243}