correlate 0.3.0

correlate is a standalone server that listens for Stripe webhook events and sends notification emails about successful orders.
mod mail;
mod order_info;
mod products;
mod stripe;

#[cfg(feature = "database")]
mod db;
#[cfg(feature = "database")]
mod schema;

use mail::generate_email::{send_email, AdditionalFields};
use order_info::create::{CheckoutCustomerCreation, CheckoutLineItem, CreateCheckoutSession};
use order_info::retrieve::find_order_info;
use products::ProductsConfig;
use rocket::serde::{json::Json, Deserialize};
use rocket::{fairing::AdHoc, figment::providers::Env, Config, State};
use rocket::{http::Status, response::status::BadRequest};
// rocket macro imports
use rocket::{get, launch, post, routes};
use stripe::checkout::{StripeCheckoutSession, StripeMode, StripeObjectType};
use stripe::customer::StripeCustomerDetails;
use stripe::event::StripeEventData;
use validator::Validate;

const VERSION: &str = env!("CARGO_PKG_VERSION");

fn process_webhook_data(
    webhook_data: &StripeEventData,
    app_config: &State<AppConfig>,
) -> Result<(), BadRequest<String>> {
    let (
        order_id,
        order_type,
        order_created,
        order_currency,
        order_paid,
        order_status,
        _order_mode,
        order_total,
        _order_subtotal,
        customer_details,
        receipt_url,
        statement_desc,
    ) = match &webhook_data {
        StripeEventData::StripeCheckoutObject { object } => (
            &object.id,
            &object.object_type,
            &object.created,
            &object.currency,
            object.is_successful(),
            &object.status,
            &object.mode,
            object.amount_total,
            object.amount_subtotal,
            &object.customer_details,
            None::<&str>,
            None::<&str>,
        ),
    };

    let customer = if let Some(customer) = customer_details.as_ref() {
        customer
    } else {
        // We should never get here because the webhook event provided by Stripe should have this info
        eprintln!("No customer information provided with checkout session.");
        &StripeCustomerDetails {
            email: "invalid@email.missing".to_string(),
            name: "Unknown Customer".to_string(),
        }
    };

    if order_paid {
        let line_items = match order_type {
            StripeObjectType::CheckoutSession => find_order_info(&order_id, &app_config.stripe)?,
        };
        let order_msg = format!(
            "New order recieved.\nStatus: {order_status}.\n\n{} placed an order on {order_created} for {order_currency}{}.",
            customer.name,
            (order_total as f32 / 100.0)
        );

        let additional_fields = AdditionalFields {
            message_summary: "A new Stripe order has been successfully created!",
            cta_link: receipt_url,
            ordered_items: Some(&line_items),
            logo_img: if statement_desc.is_some() && statement_desc.unwrap() == "SIMPLY SOURDOUGH" {
                // TODO: Add image to email
                // Some("https://simplysourdough.org/assets/logo.png")
                None
            } else {
                None
            },
        };

        let name = customer
            .name
            .split(',')
            .collect::<Vec<&str>>()
            .iter()
            .rev()
            .map(|s| s.trim())
            .collect::<Vec<&str>>()
            .join(" ");

        send_email(
            &customer.email,
            &name,
            additional_fields.message_summary,
            &order_msg,
            "Stripe",
            additional_fields,
            &app_config.email,
        )
        .map_err(|err| BadRequest(err.to_string()))
    } else {
        Err(BadRequest("Payment did not go through".to_string()))
    }
}

#[get("/")]
fn index() -> String {
    let desc = "cor·re·late, verb | ˈkôrəˌlāt | to have a mutual relationship or connection, in which one thing affects or depends on another.";
    format!("{}\n{}", desc, VERSION)
}

#[post("/", format = "json", data = "<payload>")]
fn webhook_event(
    payload: Json<stripe::event::StripeEvent>,
    app_config: &State<AppConfig>,
) -> Result<(Status, &'static str), BadRequest<String>> {
    if let Err(errors) = payload.validate() {
        Err(BadRequest(errors.to_string()))
    } else {
        process_webhook_data(&payload.data, app_config)
            .map(|_| (Status::Ok, "Order data processed successfully."))
    }
}

/// Order information submitted which will be used to create a checkout session
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
struct CheckoutOrderSubmission<'o> {
    /// Coupon code
    #[serde(alias = "coupon")]
    pub _coupon: Option<&'o str>,
    /// URL the customer will be redirected to if they successfully complete checkout
    #[serde(alias = "successUrl")]
    success_url: &'o str,
    /// URL that the customer can be sent back to, if they leave checkout early
    #[serde(alias = "submissionUrl")]
    submission_url: &'o str,
    /// Items in the checkout
    items: Vec<CheckoutOrderItem<'o>>,
}

/// Individual item in a checkout submission
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
struct CheckoutOrderItem<'o> {
    name: &'o str,
    sku: &'o str,
    quantity: u32,
}

#[post("/checkout", data = "<payload>")]
fn create_checkout(
    payload: Json<CheckoutOrderSubmission>,
    app_config: &State<AppConfig>,
) -> Result<(Status, String), BadRequest<String>> {
    let mut checkout_items = Vec::new();

    // Prepare the checkout session data based on the payload
    for item in &payload.items {
        let price = match app_config.products.get_price(&item.name, &item.sku) {
            Some(price) => price.to_string(),
            None => {
                return Err(BadRequest("Failed to find price for item".to_owned()));
            }
        };
        checkout_items.push(CheckoutLineItem::new(price, item.quantity));
    }

    let checkout_data = CreateCheckoutSession::new(
        payload.success_url.to_string(),
        payload.submission_url.to_string(),
        CheckoutCustomerCreation::Always,
        StripeMode::Payment,
        checkout_items,
    );

    // Get API key from environment
    let config = app_config
        .stripe
        .as_ref()
        .ok_or_else(|| BadRequest("Stripe configuration is missing".to_string()))?;

    // Make request to Stripe API
    match checkout_data.send_to_stripe(&config.secret) {
        Ok(mut response) => {
            // Get the response as JSON and properly parse it
            let checkout_session = response
                .body_mut()
                .read_json::<StripeCheckoutSession>()
                .map_err(|err| BadRequest(err.to_string()))?;
            println!("Created checkout session: {}", &checkout_session.id);
            Ok((Status::Ok, checkout_session.url))
        }
        Err(err) => {
            let error_message = format!("Failed to create checkout session: {}", err);
            eprintln!("{}", error_message);
            Err(BadRequest(error_message))
        }
    }
}

/// Configuration for the application, containing API configurations
/// and other settings needed for the application to run.
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
struct AppConfig {
    email: MailConfig,
    stripe: Option<StripeConfig>,
    #[serde(flatten)]
    products: ProductsConfig,
}

/// Configuration for Stripe API integration
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
struct StripeConfig {
    /// Secret key used for authenticating with Stripe's services.
    /// Needs to be provided via the [default.stripe] section in Rocket.toml
    /// or, the environment variable CORRELATE_STRIPE_SECRET.
    secret: String,
}

/// Defines app's email configuration structure.
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
struct MailConfig {
    /// Email address that the message will be sent from.
    sender: String,
    /// Email address that the message will be sent to.
    recipient: String,
    /// SMTP server hostname to use for sending mail
    #[serde(alias = "MAIL_HOST")]
    smtp_relay_host: Option<String>,
    /// SMTP server port used to listen for incoming mail
    #[serde(alias = "MAIL_PORT")]
    #[serde(default = "default_smtp_port")]
    smtp_relay_port: u16,
    /// User (email address) used to authenticate with SMTP server defined above
    smtp_username: Option<String>,
    /// Password used to authenticate with SMTP server defined above
    smtp_password: Option<String>,
}

/// Gives the default SMTP port if none is provided via config
fn default_smtp_port() -> u16 {
    25
}

// Pulls app config from default profile of "Rocket.toml" or environment variables prefixed with "CORRELATE_".
// Then listens on endpoints for requests to respond to.
#[launch]
fn rocket() -> _ {
    let figment = Config::figment().merge(Env::prefixed("CORRELATE_").split("_"));

    let rocket = rocket::custom(figment)
        .mount("/", routes![index, create_checkout, webhook_event])
        .attach(AdHoc::config::<AppConfig>());

    #[cfg(feature = "database")]
    let rocket = rocket.attach(db::stage());

    rocket
}

#[cfg(test)]
mod tests;

#[cfg(test)]
mod integration_tests;