Skip to main content

coil_commerce/
checkout.rs

1use crate::error::CommerceModelError;
2use crate::identifiers::{CheckoutId, CurrencyCode, OrderId, Sku};
3use crate::model::{CheckoutStatus, Money, OrderStatus, ProductKind};
4use crate::orders::Order;
5use crate::pricing::{PriceQuote, PricingPolicy, ensure_same_currency};
6use crate::validation::require_non_empty;
7use coil_data::{DomainWrite, TransactionIsolation, TransactionPlan};
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct CheckoutLine {
12    pub product_id: crate::ProductId,
13    pub product_kind: ProductKind,
14    pub product_title: String,
15    pub sku: Sku,
16    pub variant_title: String,
17    pub quantity: u32,
18    pub unit_price: Money,
19}
20
21impl CheckoutLine {
22    pub fn new(
23        product_id: crate::ProductId,
24        product_kind: ProductKind,
25        product_title: impl Into<String>,
26        sku: Sku,
27        variant_title: impl Into<String>,
28        quantity: u32,
29        unit_price: Money,
30    ) -> Result<Self, CommerceModelError> {
31        if quantity == 0 {
32            return Err(CommerceModelError::ZeroQuantity { field: "quantity" });
33        }
34
35        Ok(Self {
36            product_id,
37            product_kind,
38            product_title: require_non_empty("product_title", product_title.into())?,
39            sku,
40            variant_title: require_non_empty("variant_title", variant_title.into())?,
41            quantity,
42            unit_price,
43        })
44    }
45
46    pub fn subtotal(&self) -> Result<Money, CommerceModelError> {
47        self.unit_price.checked_mul(self.quantity)
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct CheckoutSession {
53    pub id: CheckoutId,
54    pub currency: CurrencyCode,
55    pub status: CheckoutStatus,
56    lines: BTreeMap<Sku, CheckoutLine>,
57}
58
59impl CheckoutSession {
60    pub fn new(id: CheckoutId, currency: CurrencyCode) -> Self {
61        Self {
62            id,
63            currency,
64            status: CheckoutStatus::Draft,
65            lines: BTreeMap::new(),
66        }
67    }
68
69    pub fn lines(&self) -> impl Iterator<Item = &CheckoutLine> {
70        self.lines.values()
71    }
72
73    pub fn line(&self, sku: &Sku) -> Result<&CheckoutLine, CommerceModelError> {
74        self.lines
75            .get(sku)
76            .ok_or_else(|| CommerceModelError::MissingLine {
77                sku: sku.to_string(),
78            })
79    }
80
81    pub fn add_line(&mut self, line: CheckoutLine) -> Result<(), CommerceModelError> {
82        ensure_same_currency(&self.currency, line.unit_price.currency())?;
83        if let Some(existing) = self.lines.get_mut(&line.sku) {
84            existing.quantity = existing.quantity.checked_add(line.quantity).ok_or(
85                CommerceModelError::AmountOverflow {
86                    field: "checkout_line_quantity",
87                },
88            )?;
89        } else {
90            self.lines.insert(line.sku.clone(), line);
91        }
92        Ok(())
93    }
94
95    pub fn remove_line(&mut self, sku: &Sku) -> Option<CheckoutLine> {
96        self.lines.remove(sku)
97    }
98
99    pub fn replace_quantity(&mut self, sku: &Sku, quantity: u32) -> Result<(), CommerceModelError> {
100        if quantity == 0 {
101            return Err(CommerceModelError::ZeroQuantity { field: "quantity" });
102        }
103
104        let line = self
105            .lines
106            .get_mut(sku)
107            .ok_or_else(|| CommerceModelError::MissingLine {
108                sku: sku.to_string(),
109            })?;
110        line.quantity = quantity;
111        Ok(())
112    }
113
114    pub fn price(&self, policy: &PricingPolicy) -> Result<PriceQuote, CommerceModelError> {
115        ensure_same_currency(&self.currency, &policy.currency)?;
116
117        let mut subtotal = Money::zero(self.currency.clone());
118        for line in self.lines() {
119            subtotal = subtotal.checked_add(&line.subtotal()?)?;
120        }
121
122        let adjustments = policy.adjustments_for_subtotal(&subtotal)?;
123        PriceQuote::new(subtotal, adjustments)
124    }
125
126    pub fn ready_for_payment(&mut self) -> Result<(), CommerceModelError> {
127        if self.lines.is_empty() {
128            return Err(CommerceModelError::EmptyCheckout);
129        }
130
131        self.transition_to(CheckoutStatus::ReadyForPayment)
132    }
133
134    pub fn awaiting_payment(&mut self) -> Result<(), CommerceModelError> {
135        if self.status != CheckoutStatus::ReadyForPayment {
136            return Err(CommerceModelError::CheckoutNotReady {
137                status: self.status,
138            });
139        }
140
141        self.transition_to(CheckoutStatus::AwaitingPayment)
142    }
143
144    pub fn mark_paid(&mut self) -> Result<(), CommerceModelError> {
145        if self.status != CheckoutStatus::AwaitingPayment {
146            return Err(CommerceModelError::CheckoutNotReady {
147                status: self.status,
148            });
149        }
150
151        self.transition_to(CheckoutStatus::Paid)
152    }
153
154    pub fn complete(&mut self) -> Result<(), CommerceModelError> {
155        if self.status != CheckoutStatus::Paid {
156            return Err(CommerceModelError::CheckoutNotReady {
157                status: self.status,
158            });
159        }
160
161        self.transition_to(CheckoutStatus::Completed)
162    }
163
164    pub fn finalize(
165        &mut self,
166        order_id: OrderId,
167        pricing: &PricingPolicy,
168    ) -> Result<Order, CommerceModelError> {
169        self.complete()?;
170        self.to_order(order_id, pricing)
171    }
172
173    pub fn cancel(&mut self) -> Result<(), CommerceModelError> {
174        self.transition_to(CheckoutStatus::Cancelled)
175    }
176
177    pub fn to_order(
178        &self,
179        order_id: OrderId,
180        pricing: &PricingPolicy,
181    ) -> Result<Order, CommerceModelError> {
182        if self.lines.is_empty() {
183            return Err(CommerceModelError::EmptyCheckout);
184        }
185
186        let status = match self.status {
187            CheckoutStatus::Paid | CheckoutStatus::Completed => OrderStatus::Paid,
188            CheckoutStatus::Cancelled => OrderStatus::Cancelled,
189            _ => OrderStatus::PendingPayment,
190        };
191
192        Ok(Order {
193            id: order_id,
194            status,
195            currency: self.currency.clone(),
196            lines: self.lines.values().cloned().collect(),
197            totals: self.price(pricing)?,
198            refunds: Vec::new(),
199        })
200    }
201
202    pub fn completion_transaction_plan(
203        &self,
204        order: &Order,
205    ) -> Result<TransactionPlan, CommerceModelError> {
206        TransactionPlan::new(
207            "commerce.checkout.complete",
208            TransactionIsolation::Serializable,
209        )?
210        .with_write(DomainWrite::new("checkout_session", "update")?)
211        .with_write(DomainWrite::new("checkout_line", "replace")?)
212        .with_write(DomainWrite::new("order", "insert")?)
213        .with_write(DomainWrite::new("inventory_reservation", "insert")?)
214        .with_after_commit_job(format!("commerce.jobs.fulfillment.prepare:{}", order.id))
215        .and_then(|plan| {
216            plan.with_after_commit_event(format!("commerce.order.created:{}", order.id))
217        })
218        .and_then(|plan| plan.with_after_commit_event(format!("commerce.order.paid:{}", order.id)))
219        .map_err(Into::into)
220    }
221
222    fn transition_to(&mut self, next: CheckoutStatus) -> Result<(), CommerceModelError> {
223        let valid = matches!(
224            (self.status, next),
225            (CheckoutStatus::Draft, CheckoutStatus::ReadyForPayment)
226                | (
227                    CheckoutStatus::ReadyForPayment,
228                    CheckoutStatus::AwaitingPayment
229                )
230                | (CheckoutStatus::AwaitingPayment, CheckoutStatus::Paid)
231                | (CheckoutStatus::Paid, CheckoutStatus::Completed)
232                | (
233                    CheckoutStatus::Draft
234                        | CheckoutStatus::ReadyForPayment
235                        | CheckoutStatus::AwaitingPayment
236                        | CheckoutStatus::Paid,
237                    CheckoutStatus::Cancelled
238                )
239        );
240
241        if valid {
242            self.status = next;
243            Ok(())
244        } else {
245            Err(CommerceModelError::InvalidStatusTransition {
246                from: self.status,
247                to: next,
248            })
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::identifiers::{CheckoutId, CurrencyCode, EntitlementKey, OrderId, ProductId};
257    use crate::model::{CheckoutStatus, OrderStatus, ProductKind};
258
259    fn gbp(amount_minor: i64) -> Money {
260        Money::new(CurrencyCode::new("GBP").unwrap(), amount_minor).unwrap()
261    }
262
263    fn membership_checkout() -> CheckoutSession {
264        let mut checkout = CheckoutSession::new(
265            CheckoutId::new("chk-finalize").unwrap(),
266            CurrencyCode::new("GBP").unwrap(),
267        );
268        checkout
269            .add_line(
270                CheckoutLine::new(
271                    ProductId::new("product-gold-membership").unwrap(),
272                    ProductKind::Membership {
273                        entitlement_key: EntitlementKey::new("membership.gold").unwrap(),
274                    },
275                    "Gold Membership",
276                    Sku::new("sku-gold-membership").unwrap(),
277                    "Annual plan",
278                    1,
279                    gbp(8_900),
280                )
281                .unwrap(),
282            )
283            .unwrap();
284        checkout.ready_for_payment().unwrap();
285        checkout.awaiting_payment().unwrap();
286        checkout.mark_paid().unwrap();
287        checkout
288    }
289
290    #[test]
291    fn finalize_completes_a_paid_checkout_into_an_order() {
292        let pricing = PricingPolicy::new(CurrencyCode::new("GBP").unwrap());
293        let mut checkout = membership_checkout();
294        let order = checkout
295            .finalize(OrderId::new("ord-finalize").unwrap(), &pricing)
296            .unwrap();
297
298        assert_eq!(checkout.status, CheckoutStatus::Completed);
299        assert_eq!(order.status, OrderStatus::Paid);
300        assert_eq!(order.id.to_string(), "ord-finalize");
301    }
302
303    #[test]
304    fn completion_transaction_plan_emits_paid_confirmation_event() {
305        let pricing = PricingPolicy::new(CurrencyCode::new("GBP").unwrap());
306        let checkout = membership_checkout();
307        let order = checkout
308            .to_order(OrderId::new("ord-plan").unwrap(), &pricing)
309            .unwrap();
310
311        let plan = checkout.completion_transaction_plan(&order).unwrap();
312        assert!(
313            plan.after_commit_events
314                .iter()
315                .any(|event| event == "commerce.order.created:ord-plan")
316        );
317        assert!(
318            plan.after_commit_events
319                .iter()
320                .any(|event| event == "commerce.order.paid:ord-plan")
321        );
322    }
323}