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}