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}