checkout_controller 0.0.7

a set of interfaces to create robust backends for a pleasant checkout experience
use crate::error::Error;
use crate::inventory::InventoryController;
use crate::item::ItemTitleResolver;
use crate::money::{Currency, Money};
use address::Address;
use async_trait::async_trait;
pub use chrono::{DateTime, Utc};
use inventory::{ReserveResult, StockIssue};
use invoice::{Invoice, InvoiceCalculator};
use item::Item;
use price::PriceCalculator;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use shipping::{FulfillmentType, FulfillmentTypeSelection, ShippingCalculator, ShippingQuote};
use std::matches;
use uuid::Uuid;

pub mod address;
pub mod error;
pub mod inventory;
pub mod invoice;
pub mod item;
pub mod money;
pub mod price;
pub mod server;
pub mod shipping;

#[derive(Serialize, Deserialize, JsonSchema)]
pub enum State {
    Shopping,
    ItemsConfirmed(DateTime<Utc>),
    Completed,
    Abandoned,
}

#[derive(Serialize, Deserialize, JsonSchema)]
pub struct Checkout {
    pub id: Uuid,
    pub state: State,
    pub currency: Currency,
    pub promo_codes: Vec<String>,
    pub items: Vec<Item>,
    pub stock_issues: Vec<StockIssue>,
    pub fulfillment_type: Option<FulfillmentType>,
    pub shipping_address: Option<Address>,
    pub shipping_quotes: Vec<ShippingQuote>,
    pub invoice: Option<Invoice>,
}

impl Checkout {
    // private methods
    async fn enter_shopping_state<C: CheckoutContext + Send>(
        &mut self,
        ctx: &mut C,
    ) -> Result<(), CheckoutError> {
        match self.state {
            State::Shopping => Ok(()),
            State::ItemsConfirmed(_) => {
                ctx.free_items(&self.items).await?;
                self.fulfillment_type = None;
                self.shipping_quotes = vec![];
                Ok(self.state = State::Shopping)
            }
            _ => Err(new_invalid_state_error(
                "must be in the shopping or items_confirmed state",
            )),
        }
    }

    async fn update_invoice<C: CheckoutContext + Send>(
        &mut self,
        ctx: &mut C,
    ) -> Result<(), CheckoutError> {
        Ok(self.invoice = Some(ctx.generate_invoice(self).await?))
    }

    pub async fn create<C: CheckoutContext + Send>(
        ctx: &mut C,
        currency: Currency,
    ) -> Result<Self, CheckoutError> {
        let checkout = Checkout {
            id: Uuid::new_v4(),
            state: State::Shopping,
            currency,
            promo_codes: vec![],
            items: vec![],
            stock_issues: vec![],
            fulfillment_type: None,
            shipping_address: None,
            shipping_quotes: vec![],
            invoice: None,
        };

        ctx.on_create(&checkout).await?;
        Ok(checkout)
    }

    pub async fn add_item<C: CheckoutContext + Send>(
        &mut self,
        ctx: &mut C,
        sku: String,
        quantity: u64,
    ) -> Result<(), CheckoutError> {
        self.enter_shopping_state(ctx).await?;

        let mut is_added = false;
        for item in self.items.iter_mut() {
            if item.sku == sku {
                item.quantity += quantity;
                is_added = true;
                break;
            }
        }

        if !is_added {
            let title = ctx.resolve_item_title(&sku).await?;
            let item = Item {
                sku: sku.to_string(),
                title,
                quantity,
                price: Money::new(self.currency.clone(), "0"),
                discount: Money::new(self.currency.clone(), "0"),
            };
            self.items.push(item);
        }

        ctx.update_item_prices(&self.currency, &self.promo_codes, &mut self.items)
            .await?;
        ctx.on_add_item(self, &sku, quantity).await
    }

    pub async fn remove_item<C: CheckoutContext + Send>(
        &mut self,
        ctx: &mut C,
        sku: String,
        quantity: u64,
    ) -> Result<(), CheckoutError> {
        self.enter_shopping_state(ctx).await?;

        let mut index_to_remove: Option<usize> = None;
        for (index, item) in self.items.iter_mut().enumerate() {
            if item.sku == sku {
                if item.quantity <= quantity {
                    index_to_remove = Some(index);
                    break;
                }

                item.quantity -= quantity;
                break;
            }
        }

        if let Some(index) = index_to_remove {
            self.items.remove(index);
        }

        ctx.update_item_prices(&self.currency, &self.promo_codes, &mut self.items)
            .await?;
        ctx.on_remove_item(self, &sku, quantity).await
    }

    pub async fn confirm_items<C: CheckoutContext + Send>(
        &mut self,
        ctx: &mut C,
    ) -> Result<(), CheckoutError> {
        match self.state {
            State::ItemsConfirmed(_) => Ok(()),
            State::Shopping => {
                match ctx.reserve_items(&self.items).await? {
                    ReserveResult::Success(deadline) => {
                        self.fulfillment_type = None;
                        if let Some(address) = &self.shipping_address {
                            self.shipping_quotes = ctx
                                .get_shipping_quotes(
                                    &self.currency,
                                    &self.promo_codes,
                                    &self.items,
                                    address,
                                )
                                .await?;
                        }

                        ctx.update_item_prices(&self.currency, &self.promo_codes, &mut self.items)
                            .await?;
                        self.update_invoice(ctx).await?;
                        self.state = State::ItemsConfirmed(deadline);
                        self.stock_issues = vec![];
                        ctx.on_confirm_items(self).await?;
                    }
                    ReserveResult::StockIssue(stock_issues) => {
                        self.stock_issues = stock_issues;
                    }
                }

                Ok(())
            }
            _ => Err(new_invalid_state_error(
                "must be in the shopping or items_confirmed state",
            )),
        }
    }

    pub async fn update_shipping_address<C: CheckoutContext + Send>(
        &mut self,
        ctx: &mut C,
        address: Address,
    ) -> Result<(), CheckoutError> {
        self.shipping_address = Some(address);
        self.fulfillment_type = None;
        if let State::ItemsConfirmed(_) = self.state {
            self.shipping_quotes = ctx
                .get_shipping_quotes(
                    &self.currency,
                    &self.promo_codes,
                    &self.items,
                    self.shipping_address.as_ref().unwrap(),
                )
                .await?;
        }

        ctx.on_update_shipping_address(self).await
    }

    pub async fn update_fulfillment_type<C: CheckoutContext + Send>(
        &mut self,
        ctx: &mut C,
        fulfillment_type: FulfillmentTypeSelection,
    ) -> Result<(), CheckoutError> {
        if !matches!(self.state, State::ItemsConfirmed(_)) {
            return Err(new_invalid_state_error(
                "must be in the items_confirmed state",
            ));
        }

        match fulfillment_type {
            FulfillmentTypeSelection::Pickup => {
                self.fulfillment_type = Some(FulfillmentType::Pickup);
            }
            FulfillmentTypeSelection::Shipping(quote_id) => {
                let mut updated = false;

                for quote in self.shipping_quotes.iter() {
                    if quote.id == quote_id {
                        self.fulfillment_type = Some(FulfillmentType::Shipping(quote.clone()));
                        updated = true;
                        break;
                    }
                }

                if !updated {
                    return Err(new_bad_request_error(
                        "the requested quote is not available",
                    ));
                }
            }
        }

        self.update_invoice(ctx).await?;
        ctx.on_update_fulfillment_type(self).await
    }
}

#[async_trait]
pub trait CheckoutContext:
    InvoiceCalculator + ItemTitleResolver + InventoryController + ShippingCalculator + PriceCalculator
{
    async fn start_transaction(&mut self) -> Result<(), CheckoutError> {
        Ok(())
    }

    async fn commit_transaction(&mut self) -> Result<(), CheckoutError> {
        Ok(())
    }

    async fn abort_transaction(&mut self) -> Result<(), CheckoutError> {
        Ok(())
    }

    async fn on_create(&mut self, _co: &Checkout) -> Result<(), CheckoutError> {
        Ok(())
    }

    async fn on_add_item(
        &mut self,
        _co: &Checkout,
        _sku: &str,
        _quantity: u64,
    ) -> Result<(), CheckoutError> {
        Ok(())
    }

    async fn on_remove_item(
        &mut self,
        _co: &Checkout,
        _sku: &str,
        _quantity: u64,
    ) -> Result<(), CheckoutError> {
        Ok(())
    }

    async fn on_confirm_items(&mut self, _co: &Checkout) -> Result<(), CheckoutError> {
        Ok(())
    }

    async fn on_update_shipping_address(&mut self, _co: &Checkout) -> Result<(), CheckoutError> {
        Ok(())
    }

    async fn on_update_fulfillment_type(&mut self, _co: &Checkout) -> Result<(), CheckoutError> {
        Ok(())
    }
}

#[async_trait]
pub trait CheckoutContextProvider {
    type Context: CheckoutContext;
    async fn new_context(&self) -> Result<Self::Context, CheckoutError>;
}

#[derive(Debug, Serialize, Deserialize)]
pub enum CheckoutError {
    InvalidState(Error),
    BadRequest(Error),
}

fn new_invalid_state_error(msg: &str) -> CheckoutError {
    CheckoutError::InvalidState(Error {
        code: String::from("300"),
        message: msg.to_string(),
    })
}

fn new_bad_request_error(msg: &str) -> CheckoutError {
    CheckoutError::BadRequest(Error {
        code: String::from("400"),
        message: msg.to_string(),
    })
}