Skip to main content

coil_commerce/
orders.rs

1use crate::checkout::CheckoutLine;
2use crate::error::CommerceModelError;
3use crate::identifiers::{CurrencyCode, OrderId, RefundId, Sku};
4use crate::model::{Money, OrderStatus, ProductKind};
5use crate::pricing::{PriceQuote, ensure_same_currency};
6use crate::validation::require_non_empty;
7use coil_data::{DomainWrite, TransactionIsolation, TransactionPlan};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Refund {
11    pub id: RefundId,
12    pub amount: Money,
13    pub reason: String,
14}
15
16impl Refund {
17    pub fn new(
18        id: RefundId,
19        amount: Money,
20        reason: impl Into<String>,
21    ) -> Result<Self, CommerceModelError> {
22        Ok(Self {
23            id,
24            amount,
25            reason: require_non_empty("refund_reason", reason.into())?,
26        })
27    }
28}
29
30fn format_money(amount: &Money) -> String {
31    let minor = amount.amount_minor();
32    let major = minor / 100;
33    let remainder = minor % 100;
34
35    match amount.currency().as_str() {
36        "GBP" => format!("£{major}.{remainder:02}"),
37        code => format!("{code} {major}.{remainder:02}"),
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum OrderOutcome {
43    ShipPhysical {
44        sku: Sku,
45        quantity: u32,
46    },
47    DeliverDigital {
48        sku: Sku,
49        quantity: u32,
50    },
51    ScheduleService {
52        sku: Sku,
53        quantity: u32,
54    },
55    GrantMembership {
56        entitlement_key: crate::EntitlementKey,
57        quantity: u32,
58    },
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Order {
63    pub id: OrderId,
64    pub status: OrderStatus,
65    pub currency: CurrencyCode,
66    pub lines: Vec<CheckoutLine>,
67    pub totals: PriceQuote,
68    pub refunds: Vec<Refund>,
69}
70
71impl Order {
72    pub fn refunds(&self) -> &[Refund] {
73        &self.refunds
74    }
75
76    pub fn confirmation_message(&self) -> String {
77        if self.outcomes().iter().any(|outcome| {
78            matches!(
79                outcome,
80                OrderOutcome::GrantMembership {
81                    entitlement_key: _,
82                    quantity: _
83                }
84            )
85        }) {
86            "A confirmation email and membership activation will follow shortly.".to_string()
87        } else {
88            match self.status {
89                OrderStatus::PendingPayment => "Complete payment to place the order.".to_string(),
90                OrderStatus::Paid | OrderStatus::Fulfilled => {
91                    "A confirmation email will follow shortly.".to_string()
92                }
93                OrderStatus::PartiallyRefunded => {
94                    "A partial refund is being reconciled.".to_string()
95                }
96                OrderStatus::Refunded => "This order has been refunded.".to_string(),
97                OrderStatus::Cancelled => {
98                    "This order was cancelled before fulfillment.".to_string()
99                }
100            }
101        }
102    }
103
104    pub fn history_status_label(&self) -> &'static str {
105        match self.status {
106            OrderStatus::PendingPayment => "Awaiting payment",
107            OrderStatus::Paid => "Paid",
108            OrderStatus::Fulfilled => "Fulfilled",
109            OrderStatus::PartiallyRefunded => "Partially refunded",
110            OrderStatus::Refunded => "Refunded",
111            OrderStatus::Cancelled => "Cancelled",
112        }
113    }
114
115    pub fn display_total(&self) -> String {
116        format_money(&self.totals.total)
117    }
118
119    pub fn outcomes(&self) -> Vec<OrderOutcome> {
120        self.lines
121            .iter()
122            .map(|line| match &line.product_kind {
123                ProductKind::Physical => OrderOutcome::ShipPhysical {
124                    sku: line.sku.clone(),
125                    quantity: line.quantity,
126                },
127                ProductKind::Digital => OrderOutcome::DeliverDigital {
128                    sku: line.sku.clone(),
129                    quantity: line.quantity,
130                },
131                ProductKind::Service => OrderOutcome::ScheduleService {
132                    sku: line.sku.clone(),
133                    quantity: line.quantity,
134                },
135                ProductKind::Membership { entitlement_key } => OrderOutcome::GrantMembership {
136                    entitlement_key: entitlement_key.clone(),
137                    quantity: line.quantity,
138                },
139            })
140            .collect()
141    }
142
143    pub fn fulfill(&mut self) -> Result<(), CommerceModelError> {
144        if self.status != OrderStatus::Paid {
145            return Err(CommerceModelError::OrderNotRefundable {
146                order_id: self.id.to_string(),
147                status: self.status,
148            });
149        }
150
151        self.status = OrderStatus::Fulfilled;
152        Ok(())
153    }
154
155    pub fn issue_refund(&mut self, refund: Refund) -> Result<(), CommerceModelError> {
156        if !matches!(
157            self.status,
158            OrderStatus::Paid | OrderStatus::Fulfilled | OrderStatus::PartiallyRefunded
159        ) {
160            return Err(CommerceModelError::OrderNotRefundable {
161                order_id: self.id.to_string(),
162                status: self.status,
163            });
164        }
165
166        ensure_same_currency(&self.currency, refund.amount.currency())?;
167
168        let captured_minor = self.totals.total.amount_minor();
169        let refunded_minor: i64 = self
170            .refunds
171            .iter()
172            .map(|existing| existing.amount.amount_minor())
173            .sum();
174        let requested_minor = refund.amount.amount_minor();
175
176        if refunded_minor + requested_minor > captured_minor {
177            return Err(CommerceModelError::RefundExceedsCaptured {
178                order_id: self.id.to_string(),
179                captured_minor,
180                refunded_minor,
181                requested_minor,
182            });
183        }
184
185        self.refunds.push(refund);
186        let total_refunded = refunded_minor + requested_minor;
187        self.status = if total_refunded == captured_minor {
188            OrderStatus::Refunded
189        } else {
190            OrderStatus::PartiallyRefunded
191        };
192        Ok(())
193    }
194
195    pub fn fulfillment_transaction_plan(&self) -> Result<TransactionPlan, CommerceModelError> {
196        if self.status != OrderStatus::Paid {
197            return Err(CommerceModelError::OrderNotRefundable {
198                order_id: self.id.to_string(),
199                status: self.status,
200            });
201        }
202
203        TransactionPlan::new("commerce.order.fulfill", TransactionIsolation::Serializable)?
204            .with_write(DomainWrite::new("order", "update")?)
205            .with_write(DomainWrite::new("fulfillment_job", "enqueue")?)
206            .with_after_commit_job(format!("commerce.jobs.fulfillment.dispatch:{}", self.id))
207            .and_then(|plan| {
208                plan.with_after_commit_event(format!(
209                    "commerce.order.fulfillment_requested:{}",
210                    self.id
211                ))
212            })
213            .map_err(Into::into)
214    }
215
216    pub fn refund_transaction_plan(
217        &self,
218        refund: &Refund,
219    ) -> Result<TransactionPlan, CommerceModelError> {
220        if !matches!(
221            self.status,
222            OrderStatus::Paid | OrderStatus::Fulfilled | OrderStatus::PartiallyRefunded
223        ) {
224            return Err(CommerceModelError::OrderNotRefundable {
225                order_id: self.id.to_string(),
226                status: self.status,
227            });
228        }
229
230        TransactionPlan::new("commerce.order.refund", TransactionIsolation::Serializable)?
231            .with_write(DomainWrite::new("order_refund", "insert")?)
232            .with_write(DomainWrite::new("order", "update")?)
233            .with_write(DomainWrite::new("payment_refund", "request")?)
234            .with_after_commit_job(format!("commerce.jobs.refund.reconcile:{}", refund.id))
235            .and_then(|plan| {
236                plan.with_after_commit_event(format!("commerce.order.refund_issued:{}", refund.id))
237            })
238            .map_err(Into::into)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::checkout::CheckoutSession;
246    use crate::identifiers::{CheckoutId, CurrencyCode, EntitlementKey, OrderId, ProductId, Sku};
247    use crate::model::ProductKind;
248    use crate::pricing::PricingPolicy;
249
250    fn gbp(amount_minor: i64) -> Money {
251        Money::new(CurrencyCode::new("GBP").unwrap(), amount_minor).unwrap()
252    }
253
254    fn membership_order() -> Order {
255        let mut checkout = CheckoutSession::new(
256            CheckoutId::new("chk-order").unwrap(),
257            CurrencyCode::new("GBP").unwrap(),
258        );
259        checkout
260            .add_line(
261                CheckoutLine::new(
262                    ProductId::new("product-gold-membership").unwrap(),
263                    ProductKind::Membership {
264                        entitlement_key: EntitlementKey::new("membership.gold").unwrap(),
265                    },
266                    "Gold Membership",
267                    Sku::new("sku-gold-membership").unwrap(),
268                    "Annual plan",
269                    1,
270                    gbp(8_900),
271                )
272                .unwrap(),
273            )
274            .unwrap();
275        checkout.ready_for_payment().unwrap();
276        checkout.awaiting_payment().unwrap();
277        checkout.mark_paid().unwrap();
278        checkout
279            .finalize(
280                OrderId::new("ord-order").unwrap(),
281                &PricingPolicy::new(CurrencyCode::new("GBP").unwrap()),
282            )
283            .unwrap()
284    }
285
286    #[test]
287    fn membership_orders_expose_confirmation_and_history_copy() {
288        let order = membership_order();
289
290        assert!(
291            order
292                .confirmation_message()
293                .contains("membership activation")
294        );
295        assert_eq!(order.history_status_label(), "Paid");
296        assert_eq!(order.display_total(), "£89.00");
297    }
298}