raisfast 0.2.21

The last backend you'll ever need. Rust-powered headless CMS with built-in blog, ecommerce, wallet, payment and 4 plugin engines.
use serde::{Deserialize, Serialize};

use crate::db::DbDriver;
use crate::errors::app_error::AppResult;
use crate::types::snowflake_id::SnowflakeId;
use crate::utils::tz::Timestamp;

#[derive(Debug, Serialize, Deserialize, Clone, sqlx::FromRow)]
pub struct PaymentChannel {
    pub id: SnowflakeId,
    pub tenant_id: Option<String>,
    pub provider: String,
    pub name: String,
    pub is_live: i64,
    pub credentials: String,
    pub webhook_secret: Option<String>,
    pub settings: Option<String>,
    pub is_active: i64,
    pub sort_order: i64,
    pub version: i64,
    pub created_at: Timestamp,
    pub updated_at: Timestamp,
}

pub async fn find_by_id(
    pool: &crate::db::Pool,
    id: SnowflakeId,
    tenant_id: Option<&str>,
) -> AppResult<Option<PaymentChannel>> {
    raisfast_derive::crud_find!(pool, "payment_channels", PaymentChannel, where: ("id", id), tenant: tenant_id)
        .map_err(Into::into)
}

pub async fn find_all_active(
    pool: &crate::db::Pool,
    tenant_id: Option<&str>,
) -> AppResult<Vec<PaymentChannel>> {
    raisfast_derive::crud_find_all!(pool, "payment_channels", PaymentChannel, where: ("is_active", 1_i64), tenant: tenant_id, order_by: "sort_order, created_at DESC")
        .map_err(Into::into)
}

pub async fn find_all_admin_paginated(
    pool: &crate::db::Pool,
    tenant_id: Option<&str>,
    page: i64,
    page_size: i64,
    is_active: Option<bool>,
) -> AppResult<(Vec<PaymentChannel>, i64)> {
    let active_val = is_active.map(|a| if a { 1_i64 } else { 0_i64 });
    let result = raisfast_derive::crud_query_paged!(
        pool, PaymentChannel,
        table: "payment_channels",
        where: ["is_active" => active_val],
        order_by: "sort_order, created_at DESC",
        tenant: tenant_id,
        page: page,
        page_size: page_size
    );
    Ok(result)
}

pub async fn insert(
    pool: &crate::db::Pool,
    cmd: &crate::commands::CreatePaymentChannelCmd,
    tenant_id: Option<&str>,
) -> AppResult<PaymentChannel> {
    let id = crate::utils::id::new_id();
    let is_live_val = if cmd.is_live { 1_i64 } else { 0_i64 };
    let is_active_val = if cmd.is_active { 1_i64 } else { 0_i64 };
    let now = crate::utils::tz::now_utc();
    raisfast_derive::crud_insert!(
        pool,
        "payment_channels",
        [
            "id" => id,
            "provider" => &cmd.provider,
            "name" => &cmd.name,
            "is_live" => is_live_val,
            "credentials" => &cmd.credentials,
            "webhook_secret" => &cmd.webhook_secret,
            "settings" => &cmd.settings,
            "is_active" => is_active_val,
            "sort_order" => cmd.sort_order,
            "created_at" => &now,
            "updated_at" => &now
        ],
        tenant: tenant_id
    )?;
    find_by_id(pool, SnowflakeId(id), tenant_id)
        .await?
        .ok_or_else(|| {
            crate::errors::app_error::AppError::Internal(anyhow::anyhow!(
                "inserted row not found: {id}"
            ))
        })
}

pub async fn update(
    pool: &crate::db::Pool,
    cmd: &crate::commands::UpdatePaymentChannelCmd,
    tenant_id: Option<&str>,
) -> AppResult<bool> {
    let is_live_val = if cmd.is_live { 1_i64 } else { 0_i64 };
    let is_active_val = if cmd.is_active { 1_i64 } else { 0_i64 };
    let affected = raisfast_derive::crud_update!(
        pool, "payment_channels",
        bind: [
            "provider" => &cmd.provider,
            "name" => &cmd.name,
            "is_live" => is_live_val,
            "credentials" => &cmd.credentials,
            "webhook_secret" => &cmd.webhook_secret,
            "settings" => &cmd.settings,
            "is_active" => is_active_val,
            "sort_order" => cmd.sort_order,
        ],
        raw: ["updated_at" => crate::db::Driver::now_fn(), "version" => "version + 1"],
        where: AND(("id", cmd.id), ("version", cmd.version)),
        tenant: tenant_id
    )?
    .rows_affected();
    Ok(affected > 0)
}

pub async fn delete_by_id(
    pool: &crate::db::Pool,
    id: SnowflakeId,
    tenant_id: Option<&str>,
) -> AppResult<bool> {
    let affected =
        raisfast_derive::crud_delete!(pool, "payment_channels", where: ("id", id), tenant: tenant_id)?
            .rows_affected();
    Ok(affected > 0)
}

#[cfg(test)]
mod tests {
    use super::*;

    async fn setup_pool() -> crate::db::Pool {
        crate::test_pool!()
    }

    async fn seed_channel(pool: &crate::db::Pool, provider: &str) -> PaymentChannel {
        super::insert(
            pool,
            &crate::commands::CreatePaymentChannelCmd {
                provider: provider.into(),
                name: format!("{}-channel-{}", provider, uuid::Uuid::now_v7()),
                is_live: false,
                credentials: r#"{"api_key":"test"}"#.into(),
                webhook_secret: None,
                settings: None,
                is_active: true,
                sort_order: 0,
            },
            None,
        )
        .await
        .unwrap()
    }

    #[tokio::test]
    async fn insert_and_find_by_id() {
        let pool = setup_pool().await;
        let ch = seed_channel(&pool, "stripe").await;
        let found = super::find_by_id(&pool, ch.id, None)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(found.id, ch.id);
        assert_eq!(found.provider, "stripe");
        assert_eq!(found.is_live, 0);
        assert_eq!(found.is_active, 1);
        assert_eq!(found.version, 1);
    }

    #[tokio::test]
    async fn find_by_id_not_found() {
        let pool = setup_pool().await;
        assert!(
            super::find_by_id(&pool, SnowflakeId(99999), None)
                .await
                .unwrap()
                .is_none()
        );
    }

    #[tokio::test]
    async fn find_all_active_channels() {
        let pool = setup_pool().await;
        let ch1 = seed_channel(&pool, "stripe").await;
        let _ch2 = seed_channel(&pool, "alipay").await;
        sqlx::query("UPDATE payment_channels SET is_active = 0 WHERE id = ?")
            .bind(ch1.id)
            .execute(&pool)
            .await
            .unwrap();
        let active = super::find_all_active(&pool, None).await.unwrap();
        assert_eq!(active.len(), 1);
        assert_eq!(active[0].provider, "alipay");
    }

    #[tokio::test]
    async fn find_all_admin_paginated_no_filter() {
        let pool = setup_pool().await;
        for _ in 0..3 {
            seed_channel(&pool, "stripe").await;
        }
        let (items, total) = super::find_all_admin_paginated(&pool, None, 1, 10, None)
            .await
            .unwrap();
        assert_eq!(total, 3);
        assert_eq!(items.len(), 3);
    }

    #[tokio::test]
    async fn find_all_admin_paginated_status_filter() {
        let pool = setup_pool().await;
        let ch = seed_channel(&pool, "wxpay").await;
        sqlx::query("UPDATE payment_channels SET is_active = 0 WHERE id = ?")
            .bind(ch.id)
            .execute(&pool)
            .await
            .unwrap();
        let (items, total) = super::find_all_admin_paginated(&pool, None, 1, 10, Some(true))
            .await
            .unwrap();
        assert_eq!(total, 0);
        assert!(items.is_empty());
    }

    #[tokio::test]
    async fn update_changes_fields() {
        let pool = setup_pool().await;
        let ch = seed_channel(&pool, "stripe").await;
        let ok = super::update(
            &pool,
            &crate::commands::UpdatePaymentChannelCmd {
                id: ch.id,
                provider: "paypal".into(),
                name: "PayPal Live".into(),
                is_live: true,
                credentials: r#"{"client_id":"new"}"#.into(),
                webhook_secret: Some("secret123".into()),
                settings: Some(r#"{"currencies":["USD"]}"#.into()),
                is_active: false,
                sort_order: 5,
                version: ch.version,
            },
            None,
        )
        .await
        .unwrap();
        assert!(ok);
        let found = super::find_by_id(&pool, ch.id, None)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(found.provider, "paypal");
        assert_eq!(found.name, "PayPal Live");
        assert_eq!(found.is_live, 1);
        assert_eq!(found.is_active, 0);
        assert_eq!(found.sort_order, 5);
        assert_eq!(found.version, ch.version + 1);
    }

    #[tokio::test]
    async fn update_version_conflict() {
        let pool = setup_pool().await;
        let ch = seed_channel(&pool, "stripe").await;
        let ok = super::update(
            &pool,
            &crate::commands::UpdatePaymentChannelCmd {
                id: ch.id,
                provider: "stripe".into(),
                name: "name".into(),
                is_live: false,
                credentials: "{}".into(),
                webhook_secret: None,
                settings: None,
                is_active: true,
                sort_order: 0,
                version: 999,
            },
            None,
        )
        .await
        .unwrap();
        assert!(!ok);
    }

    #[tokio::test]
    async fn delete_removes_channel() {
        let pool = setup_pool().await;
        let ch = seed_channel(&pool, "stripe").await;
        let ok = super::delete_by_id(&pool, ch.id, None).await.unwrap();
        assert!(ok);
        assert!(
            super::find_by_id(&pool, ch.id, None)
                .await
                .unwrap()
                .is_none()
        );
    }

    #[tokio::test]
    async fn delete_not_found() {
        let pool = setup_pool().await;
        let ok = super::delete_by_id(&pool, SnowflakeId(99999), None)
            .await
            .unwrap();
        assert!(!ok);
    }
}