diff --git a/modules/meteroid/crates/diesel-models/src/configs.rs b/modules/meteroid/crates/diesel-models/src/configs.rs index c73a485b..038270f3 100644 --- a/modules/meteroid/crates/diesel-models/src/configs.rs +++ b/modules/meteroid/crates/diesel-models/src/configs.rs @@ -29,6 +29,7 @@ pub struct ProviderConfig { #[derive(Insertable, Debug)] #[diesel(table_name = crate::schema::provider_config)] pub struct ProviderConfigNew { + pub id: Uuid, pub tenant_id: Uuid, pub invoicing_provider: InvoicingProviderEnum, pub enabled: bool, diff --git a/modules/meteroid/crates/diesel-models/src/errors.rs b/modules/meteroid/crates/diesel-models/src/errors.rs index 1fdeadb9..e20a9e0a 100644 --- a/modules/meteroid/crates/diesel-models/src/errors.rs +++ b/modules/meteroid/crates/diesel-models/src/errors.rs @@ -64,7 +64,9 @@ impl IntoDbResult for Result { Ok(value) => Ok(value), Err(err) => { let db_err = DatabaseError::from(&err); - Err(Report::from(err).change_context(db_err)).map_err(DatabaseErrorContainer::from) + Err(DatabaseErrorContainer::from( + Report::from(err).change_context(db_err), + )) } } } @@ -77,7 +79,7 @@ impl IntoDbResult for error_stack::Result { Ok(value) => Ok(value), Err(err) => { let db_err = DatabaseError::from(err.current_context()); - Err(Report::from(err).change_context(db_err)).map_err(DatabaseErrorContainer::from) + Err(DatabaseErrorContainer::from(err.change_context(db_err))) } } } diff --git a/modules/meteroid/crates/diesel-models/src/organizations.rs b/modules/meteroid/crates/diesel-models/src/organizations.rs index b11c1da1..c2cc58b9 100644 --- a/modules/meteroid/crates/diesel-models/src/organizations.rs +++ b/modules/meteroid/crates/diesel-models/src/organizations.rs @@ -1,9 +1,9 @@ use chrono::NaiveDateTime; use uuid::Uuid; -use diesel::{Identifiable, Insertable, Queryable}; +use diesel::{Identifiable, Insertable, Queryable, Selectable}; -#[derive(Queryable, Debug, Identifiable)] +#[derive(Queryable, Debug, Identifiable, Selectable)] #[diesel(table_name = crate::schema::organization)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Organization { diff --git a/modules/meteroid/crates/diesel-models/src/query/configs.rs b/modules/meteroid/crates/diesel-models/src/query/configs.rs index 7a4a1908..b3e391f3 100644 --- a/modules/meteroid/crates/diesel-models/src/query/configs.rs +++ b/modules/meteroid/crates/diesel-models/src/query/configs.rs @@ -2,7 +2,9 @@ use crate::configs::{InvoicingConfig, ProviderConfig, ProviderConfigNew}; use crate::errors::IntoDbResult; use crate::{DbResult, PgConn}; -use diesel::debug_query; +use crate::enums::InvoicingProviderEnum; +use diesel::prelude::{ExpressionMethods, QueryDsl}; +use diesel::{debug_query, DecoratableTarget}; use error_stack::ResultExt; impl ProviderConfigNew { @@ -10,7 +12,16 @@ impl ProviderConfigNew { use crate::schema::provider_config::dsl::*; use diesel_async::RunQueryDsl; - let query = diesel::insert_into(provider_config).values(self); + let query = diesel::insert_into(provider_config) + .values(self) + .on_conflict((tenant_id, invoicing_provider)) + .filter_target(enabled.eq(true)) + .do_update() + .set(( + enabled.eq(self.enabled), + webhook_security.eq(&self.webhook_security), + api_security.eq(&self.api_security), + )); log::debug!("{}", debug_query::(&query).to_string()); @@ -38,3 +49,27 @@ impl InvoicingConfig { .into_db_result() } } + +impl ProviderConfig { + pub async fn find_provider_config( + conn: &mut PgConn, + tenant_uid: uuid::Uuid, + provider: InvoicingProviderEnum, + ) -> DbResult { + use crate::schema::provider_config::dsl::*; + use diesel_async::RunQueryDsl; + + let query = provider_config + .filter(tenant_id.eq(tenant_uid)) + .filter(invoicing_provider.eq(provider)) + .filter(enabled.eq(true)); + + log::debug!("{}", debug_query::(&query).to_string()); + + query + .first(conn) + .await + .attach_printable("Error while finding provider config") + .into_db_result() + } +} diff --git a/modules/meteroid/crates/diesel-models/src/query/organizations.rs b/modules/meteroid/crates/diesel-models/src/query/organizations.rs index d4ad10b1..2cb659c2 100644 --- a/modules/meteroid/crates/diesel-models/src/query/organizations.rs +++ b/modules/meteroid/crates/diesel-models/src/query/organizations.rs @@ -3,7 +3,8 @@ use crate::organizations::{Organization, OrganizationNew}; use crate::{DbResult, PgConn}; -use diesel::debug_query; +use diesel::prelude::{ExpressionMethods, QueryDsl}; +use diesel::{debug_query, JoinOnDsl, SelectableHelper}; use error_stack::ResultExt; impl OrganizationNew { @@ -22,3 +23,26 @@ impl OrganizationNew { .into_db_result() } } + +impl Organization { + pub async fn by_user_id(conn: &mut PgConn, user_id: uuid::Uuid) -> DbResult { + use crate::schema::organization::dsl as o_dsl; + use crate::schema::organization_member::dsl as om_dsl; + use crate::schema::user::dsl as u_dsl; + use diesel_async::RunQueryDsl; + + let query = o_dsl::organization + .inner_join(om_dsl::organization_member.on(o_dsl::id.eq(om_dsl::organization_id))) + .inner_join(u_dsl::user.on(om_dsl::user_id.eq(u_dsl::id))) + .filter(u_dsl::id.eq(user_id)) + .select(Organization::as_select()); + + log::debug!("{}", debug_query::(&query).to_string()); + + query + .first(conn) + .await + .attach_printable("Error while finding organization by user_id") + .into_db_result() + } +} diff --git a/modules/meteroid/crates/diesel-models/src/query/tenants.rs b/modules/meteroid/crates/diesel-models/src/query/tenants.rs index ff5e6de1..86427d1f 100644 --- a/modules/meteroid/crates/diesel-models/src/query/tenants.rs +++ b/modules/meteroid/crates/diesel-models/src/query/tenants.rs @@ -2,8 +2,8 @@ use crate::errors::IntoDbResult; use crate::tenants::{Tenant, TenantNew}; use crate::{DbResult, PgConn}; -use diesel::debug_query; use diesel::prelude::{ExpressionMethods, QueryDsl}; +use diesel::{debug_query, JoinOnDsl, SelectableHelper}; use error_stack::ResultExt; impl TenantNew { @@ -36,4 +36,27 @@ impl Tenant { .attach_printable("Error while finding tenant by id") .into_db_result() } + + pub async fn list_by_user_id(conn: &mut PgConn, user_id: uuid::Uuid) -> DbResult> { + use crate::schema::organization::dsl as o_dsl; + use crate::schema::organization_member::dsl as om_dsl; + use crate::schema::tenant::dsl as t_dsl; + use crate::schema::user::dsl as u_dsl; + use diesel_async::RunQueryDsl; + + let query = t_dsl::tenant + .inner_join(o_dsl::organization.on(t_dsl::organization_id.eq(o_dsl::id))) + .inner_join(om_dsl::organization_member.on(om_dsl::organization_id.eq(o_dsl::id))) + .inner_join(u_dsl::user.on(u_dsl::id.eq(om_dsl::user_id))) + .filter(u_dsl::id.eq(user_id)) + .select(Tenant::as_select()); + + log::debug!("{}", debug_query::(&query).to_string()); + + query + .get_results(conn) + .await + .attach_printable("Error while fetching tenants by user_id") + .into_db_result() + } } diff --git a/modules/meteroid/crates/diesel-models/src/tenants.rs b/modules/meteroid/crates/diesel-models/src/tenants.rs index b0c8ca72..723af702 100644 --- a/modules/meteroid/crates/diesel-models/src/tenants.rs +++ b/modules/meteroid/crates/diesel-models/src/tenants.rs @@ -3,9 +3,9 @@ use uuid::Uuid; use crate::enums::TenantEnvironmentEnum; -use diesel::{Identifiable, Insertable, Queryable}; +use diesel::{Identifiable, Insertable, Queryable, Selectable}; -#[derive(Clone, Queryable, Debug, Identifiable)] +#[derive(Clone, Queryable, Debug, Identifiable, Selectable)] #[diesel(table_name = crate::schema::tenant)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Tenant { diff --git a/modules/meteroid/crates/meteroid-store/Cargo.toml b/modules/meteroid/crates/meteroid-store/Cargo.toml index 5fc9b6f9..2d1ec54e 100644 --- a/modules/meteroid/crates/meteroid-store/Cargo.toml +++ b/modules/meteroid/crates/meteroid-store/Cargo.toml @@ -31,7 +31,9 @@ rust_decimal.workspace = true rust_decimal_macros.workspace = true common-utils = { workspace = true, features = ["decimal"] } itertools.workspace = true - +secrecy = { workspace = true, features = ["default", "serde"] } +chacha20poly1305 = { workspace = true } +hex = { workspace = true } [dev-dependencies] rstest = { workspace = true } diff --git a/modules/meteroid/src/crypt.rs b/modules/meteroid/crates/meteroid-store/src/crypt.rs similarity index 85% rename from modules/meteroid/src/crypt.rs rename to modules/meteroid/crates/meteroid-store/src/crypt.rs index 89088c5d..a97ea88c 100644 --- a/modules/meteroid/src/crypt.rs +++ b/modules/meteroid/crates/meteroid-store/src/crypt.rs @@ -4,7 +4,6 @@ use chacha20poly1305::{ }; use error_stack::{Result, ResultExt}; use secrecy::{ExposeSecret, SecretString}; -use tonic::Status; const NONCE_SIZE: usize = 12; @@ -20,13 +19,7 @@ pub enum EncryptionError { DecryptError, } -impl From for Status { - fn from(error: EncryptionError) -> Self { - Status::new(tonic::Code::Internal, error.to_string()) - } -} - -pub fn encrypt(crypt_key: &SecretString, value: &str) -> Result { +pub fn encrypt(crypt_key: &SecretString, value: &str) -> Result { let cipher = ChaCha20Poly1305::new_from_slice(crypt_key.expose_secret().as_bytes()) .change_context(EncryptionError::InvalidKey)?; @@ -36,7 +29,7 @@ pub fn encrypt(crypt_key: &SecretString, value: &str) -> Result Result { @@ -90,9 +83,9 @@ mod tests { let encrypted = super::encrypt(&key, raw_str).unwrap(); - assert_eq!(encrypted.expose_secret().as_str(), encrypted_str); + assert_eq!(encrypted.as_str(), encrypted_str); - let decrypted = super::decrypt(&key, encrypted.expose_secret().as_str()).unwrap(); + let decrypted = super::decrypt(&key, encrypted.as_str()).unwrap(); assert_eq!(decrypted.expose_secret().as_str(), raw_str); } diff --git a/modules/meteroid/crates/meteroid-store/src/domain/configs.rs b/modules/meteroid/crates/meteroid-store/src/domain/configs.rs new file mode 100644 index 00000000..587035e2 --- /dev/null +++ b/modules/meteroid/crates/meteroid-store/src/domain/configs.rs @@ -0,0 +1,120 @@ +use crate::domain::enums::InvoicingProviderEnum; +use crate::errors::StoreError; +use crate::StoreResult; +use chrono::NaiveDateTime; +use error_stack::ResultExt; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WebhookSecurity { + pub secret: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApiSecurity { + pub api_key: String, +} + +#[derive(Clone, Debug)] +pub struct ProviderConfig { + pub id: Uuid, + pub created_at: NaiveDateTime, + pub tenant_id: Uuid, + pub invoicing_provider: InvoicingProviderEnum, + pub enabled: bool, + pub webhook_security: WebhookSecurity, + pub api_security: ApiSecurity, +} + +impl ProviderConfig { + pub fn from_row( + key: &SecretString, + row: diesel_models::configs::ProviderConfig, + ) -> StoreResult { + let enc_wh_sec: WebhookSecurity = + serde_json::from_value(row.webhook_security).map_err(|e| { + StoreError::SerdeError("Failed to deserialize webhook_security".to_string(), e) + })?; + + let enc_api_sec: ApiSecurity = serde_json::from_value(row.api_security).map_err(|e| { + StoreError::SerdeError("Failed to deserialize api_security".to_string(), e) + })?; + + let wh_sec = WebhookSecurity { + secret: crate::crypt::decrypt(key, enc_wh_sec.secret.as_str()) + .change_context(StoreError::CryptError( + "webhook_security decryption error".into(), + ))? + .expose_secret() + .clone(), + }; + + let api_sec = ApiSecurity { + api_key: crate::crypt::decrypt(key, enc_api_sec.api_key.as_str()) + .change_context(StoreError::CryptError( + "api_security decryption error".into(), + ))? + .expose_secret() + .clone(), + }; + + Ok(ProviderConfig { + id: row.id, + created_at: row.created_at, + tenant_id: row.tenant_id, + invoicing_provider: row.invoicing_provider.into(), + enabled: row.enabled, + webhook_security: wh_sec, + api_security: api_sec, + }) + } +} + +#[derive(Clone, Debug)] +pub struct ProviderConfigNew { + pub tenant_id: Uuid, + pub invoicing_provider: InvoicingProviderEnum, + pub enabled: bool, + pub webhook_security: WebhookSecurity, + pub api_security: ApiSecurity, +} + +impl ProviderConfigNew { + pub fn domain_to_row( + key: &SecretString, + domain: &ProviderConfigNew, + ) -> StoreResult { + let wh_sec_enc = WebhookSecurity { + secret: crate::crypt::encrypt(key, domain.webhook_security.secret.as_str()) + .change_context(StoreError::CryptError( + "webhook_security encryption error".into(), + ))?, + }; + + let api_sec_enc = ApiSecurity { + api_key: crate::crypt::encrypt(key, domain.api_security.api_key.as_str()) + .change_context(StoreError::CryptError( + "api_security encryption error".into(), + ))?, + }; + + let wh_sec = serde_json::to_value(&wh_sec_enc).map_err(|e| { + StoreError::SerdeError("Failed to serialize webhook_security".to_string(), e) + })?; + + let api_sec = serde_json::to_value(&api_sec_enc).map_err(|e| { + StoreError::SerdeError("Failed to serialize api_security".to_string(), e) + })?; + + Ok(diesel_models::configs::ProviderConfigNew { + id: Uuid::now_v7(), + tenant_id: domain.tenant_id, + invoicing_provider: domain.invoicing_provider.clone().into(), + enabled: domain.enabled, + webhook_security: wh_sec, + api_security: api_sec, + }) + } +} diff --git a/modules/meteroid/crates/meteroid-store/src/domain/mod.rs b/modules/meteroid/crates/meteroid-store/src/domain/mod.rs index f010c227..127035a3 100644 --- a/modules/meteroid/crates/meteroid-store/src/domain/mod.rs +++ b/modules/meteroid/crates/meteroid-store/src/domain/mod.rs @@ -20,6 +20,7 @@ pub mod tenants; pub mod api_tokens; pub mod billable_metrics; +pub mod configs; pub mod enums; pub mod misc; pub mod product_families; diff --git a/modules/meteroid/crates/meteroid-store/src/domain/tenants.rs b/modules/meteroid/crates/meteroid-store/src/domain/tenants.rs index f79b90f1..ec7c4e81 100644 --- a/modules/meteroid/crates/meteroid-store/src/domain/tenants.rs +++ b/modules/meteroid/crates/meteroid-store/src/domain/tenants.rs @@ -25,7 +25,7 @@ pub struct Tenant { #[derive(Clone, Debug, o2o)] #[owned_into(DieselTenantNew)] #[ghosts(id: {uuid::Uuid::now_v7()})] -pub struct TenantNew { +pub struct OrgTenantNew { pub name: String, pub slug: String, pub organization_id: Uuid, @@ -33,3 +33,18 @@ pub struct TenantNew { #[into(~.map(|x| x.into()))] pub environment: Option, } + +#[derive(Clone, Debug)] +pub struct UserTenantNew { + pub name: String, + pub slug: String, + pub user_id: Uuid, + pub currency: String, + pub environment: Option, +} + +#[derive(Clone, Debug)] +pub enum TenantNew { + ForOrg(OrgTenantNew), + ForUser(UserTenantNew), +} diff --git a/modules/meteroid/crates/meteroid-store/src/errors.rs b/modules/meteroid/crates/meteroid-store/src/errors.rs index 527c8b96..a4587d49 100644 --- a/modules/meteroid/crates/meteroid-store/src/errors.rs +++ b/modules/meteroid/crates/meteroid-store/src/errors.rs @@ -33,6 +33,8 @@ pub enum StoreError { InvalidPriceComponents(String), #[error("Failed to serialize/deserialize data: {0}")] SerdeError(String, #[source] serde_json::Error), + #[error("Failed to encrypt/decrypt data")] + CryptError(String), } impl From for StoreError { diff --git a/modules/meteroid/crates/meteroid-store/src/lib.rs b/modules/meteroid/crates/meteroid-store/src/lib.rs index f63d9484..f8570a91 100644 --- a/modules/meteroid/crates/meteroid-store/src/lib.rs +++ b/modules/meteroid/crates/meteroid-store/src/lib.rs @@ -1,3 +1,4 @@ +pub mod crypt; pub mod domain; pub mod errors; pub mod repositories; diff --git a/modules/meteroid/crates/meteroid-store/src/repositories/configs.rs b/modules/meteroid/crates/meteroid-store/src/repositories/configs.rs new file mode 100644 index 00000000..e1f49949 --- /dev/null +++ b/modules/meteroid/crates/meteroid-store/src/repositories/configs.rs @@ -0,0 +1,58 @@ +use error_stack::Report; +use uuid::Uuid; + +use crate::domain::configs::{ProviderConfig, ProviderConfigNew}; +use crate::domain::enums::InvoicingProviderEnum; +use crate::errors::StoreError; +use crate::{Store, StoreResult}; + +#[async_trait::async_trait] +pub trait ConfigsInterface { + async fn insert_provider_config( + &self, + config: ProviderConfigNew, + ) -> StoreResult; + + async fn find_provider_config( + &self, + provider: InvoicingProviderEnum, + tenant_id: Uuid, + ) -> StoreResult; +} + +#[async_trait::async_trait] +impl ConfigsInterface for Store { + async fn insert_provider_config( + &self, + config: ProviderConfigNew, + ) -> StoreResult { + let insertable = ProviderConfigNew::domain_to_row(&self.crypt_key, &config)?; + + let mut conn = self.get_conn().await?; + + let row = insertable + .insert(&mut conn) + .await + .map_err(Into::>::into)?; + + ProviderConfig::from_row(&self.crypt_key, row) + } + + async fn find_provider_config( + &self, + provider: InvoicingProviderEnum, + tenant_id: Uuid, + ) -> StoreResult { + let mut conn = self.get_conn().await?; + + let row = diesel_models::configs::ProviderConfig::find_provider_config( + &mut conn, + tenant_id, + provider.into(), + ) + .await + .map_err(Into::>::into)?; + + ProviderConfig::from_row(&self.crypt_key, row) + } +} diff --git a/modules/meteroid/crates/meteroid-store/src/repositories/mod.rs b/modules/meteroid/crates/meteroid-store/src/repositories/mod.rs index f22a790f..9843343e 100644 --- a/modules/meteroid/crates/meteroid-store/src/repositories/mod.rs +++ b/modules/meteroid/crates/meteroid-store/src/repositories/mod.rs @@ -11,6 +11,7 @@ pub mod plans; pub mod tenants; pub mod api_tokens; +pub mod configs; pub mod price_components; pub mod product_families; pub mod subscriptions; diff --git a/modules/meteroid/crates/meteroid-store/src/repositories/tenants.rs b/modules/meteroid/crates/meteroid-store/src/repositories/tenants.rs index bc9c9b56..259775da 100644 --- a/modules/meteroid/crates/meteroid-store/src/repositories/tenants.rs +++ b/modules/meteroid/crates/meteroid-store/src/repositories/tenants.rs @@ -1,13 +1,16 @@ -use crate::domain::Tenant; +use crate::domain::{OrgTenantNew, Tenant, TenantNew}; +use error_stack::Report; use crate::store::Store; -use crate::{domain, StoreResult}; +use crate::{domain, errors, StoreResult}; +use diesel_models::organizations::Organization; use uuid::Uuid; #[async_trait::async_trait] pub trait TenantInterface { async fn insert_tenant(&self, tenant: domain::TenantNew) -> StoreResult; async fn find_tenant_by_id(&self, tenant_id: Uuid) -> StoreResult; + async fn list_tenants_by_user_id(&self, user_id: Uuid) -> StoreResult>; } #[async_trait::async_trait] @@ -15,7 +18,24 @@ impl TenantInterface for Store { async fn insert_tenant(&self, tenant: domain::TenantNew) -> StoreResult { let mut conn = self.get_conn().await?; - let insertable_tenant: diesel_models::tenants::TenantNew = tenant.into(); + let insertable_tenant: diesel_models::tenants::TenantNew = match tenant { + TenantNew::ForOrg(tenant_new) => tenant_new.into(), + TenantNew::ForUser(tenant_new) => { + let org = Organization::by_user_id(&mut conn, tenant_new.user_id) + .await + .map_err(Into::>::into)?; + + let org_tenant = OrgTenantNew { + organization_id: org.id, + name: tenant_new.name, + slug: tenant_new.slug, + currency: tenant_new.currency, + environment: tenant_new.environment, + }; + + org_tenant.into() + } + }; insertable_tenant .insert(&mut conn) @@ -32,4 +52,13 @@ impl TenantInterface for Store { .map_err(Into::into) .map(Into::into) } + + async fn list_tenants_by_user_id(&self, user_id: Uuid) -> StoreResult> { + let mut conn = self.get_conn().await?; + + diesel_models::tenants::Tenant::list_by_user_id(&mut conn, user_id) + .await + .map_err(Into::into) + .map(|x| x.into_iter().map(Into::into).collect()) + } } diff --git a/modules/meteroid/crates/meteroid-store/src/store.rs b/modules/meteroid/crates/meteroid-store/src/store.rs index b22f8273..e01ca01e 100644 --- a/modules/meteroid/crates/meteroid-store/src/store.rs +++ b/modules/meteroid/crates/meteroid-store/src/store.rs @@ -14,6 +14,7 @@ pub type PgConn = Object; #[derive(Clone)] pub struct Store { pub pool: PgPool, + pub crypt_key: secrecy::SecretString, } pub fn diesel_make_pg_pool(db_url: String) -> StoreResult { @@ -28,14 +29,10 @@ pub fn diesel_make_pg_pool(db_url: String) -> StoreResult { } impl Store { - pub fn from_pool(pool: PgPool) -> Self { - Store { pool } - } - - pub fn new(database_url: String) -> StoreResult { + pub fn new(database_url: String, crypt_key: secrecy::SecretString) -> StoreResult { let pool: PgPool = diesel_make_pg_pool(database_url)?; - Ok(Store { pool }) + Ok(Store { pool, crypt_key }) } pub async fn get_conn(&self) -> StoreResult { diff --git a/modules/meteroid/src/api/server.rs b/modules/meteroid/src/api/server.rs index 66d5bd30..f13014da 100644 --- a/modules/meteroid/src/api/server.rs +++ b/modules/meteroid/src/api/server.rs @@ -19,7 +19,6 @@ use crate::compute::InvoiceEngine; use crate::eventbus::analytics_handler::AnalyticsHandler; use crate::eventbus::webhook_handler::WebhookHandler; use crate::eventbus::{Event, EventBus}; -use crate::repo::provider_config::ProviderConfigRepo; use super::super::config::Config; @@ -27,7 +26,6 @@ pub async fn start_api_server( config: Config, pool: Pool, store: Store, - provider_config_repo: Arc, ) -> Result<(), Box> { log::info!( "Starting Billing API grpc server on port {}", @@ -115,7 +113,7 @@ pub async fn start_api_server( metering_service, )) .add_service(api::customers::service(pool.clone(), eventbus.clone())) - .add_service(api::tenants::service(pool.clone(), provider_config_repo)) + .add_service(api::tenants::service(store.clone())) .add_service(api::apitokens::service(store.clone(), eventbus.clone())) .add_service(api::pricecomponents::service( store.clone(), diff --git a/modules/meteroid/src/api/tenants/error.rs b/modules/meteroid/src/api/tenants/error.rs index 6aadfe58..6f6971e6 100644 --- a/modules/meteroid/src/api/tenants/error.rs +++ b/modules/meteroid/src/api/tenants/error.rs @@ -1,4 +1,5 @@ use deadpool_postgres::tokio_postgres; +use std::error::Error; use thiserror::Error; use common_grpc_error_as_tonic_macros_impl::ErrorAsTonic; @@ -16,4 +17,15 @@ pub enum TenantApiError { #[error("Database error: {0}")] #[code(Internal)] DatabaseError(String, #[source] tokio_postgres::Error), + + #[error("Store error: {0}")] + #[code(Internal)] + StoreError(String, #[source] Box), +} + +impl Into for error_stack::Report { + fn into(self) -> TenantApiError { + let err = Box::new(self.into_error()); + TenantApiError::StoreError("Error in tenant service".to_string(), err) + } } diff --git a/modules/meteroid/src/api/tenants/mapping.rs b/modules/meteroid/src/api/tenants/mapping.rs index 972682a1..280d86b9 100644 --- a/modules/meteroid/src/api/tenants/mapping.rs +++ b/modules/meteroid/src/api/tenants/mapping.rs @@ -1,35 +1,81 @@ pub mod tenants { + use meteroid_grpc::meteroid::api::tenants::v1::CreateTenantRequest; use meteroid_grpc::meteroid::api::tenants::v1::Tenant; - use meteroid_repository::tenants::Tenant as DbTenant; + use meteroid_store::domain; + use uuid::Uuid; - pub fn db_to_server(db_model: DbTenant) -> Tenant { + pub fn domain_to_server(tenant: domain::Tenant) -> Tenant { Tenant { - id: db_model.id.to_string(), - name: db_model.name, - slug: db_model.slug, - currency: db_model.currency, + id: tenant.id.to_string(), + name: tenant.name, + slug: tenant.slug, + currency: tenant.currency, } } + + pub fn create_req_to_domain(req: CreateTenantRequest, user_id: Uuid) -> domain::TenantNew { + domain::TenantNew::ForUser(domain::UserTenantNew { + name: req.name, + currency: req.currency, + slug: req.slug, + user_id, + environment: None, // todo add to the api + }) + } } pub mod provider_configs { - use crate::repo::provider_config::model::RepoProviderConfig; - use meteroid_grpc::meteroid::api::tenants::v1::TenantBillingConfiguration; - use secrecy::ExposeSecret; + use crate::api::tenants::error::TenantApiError; + use meteroid_grpc::meteroid::api::tenants::v1::tenant_billing_configuration::BillingConfigOneof; + use meteroid_grpc::meteroid::api::tenants::v1::{ + ConfigureTenantBillingRequest, TenantBillingConfiguration, + }; + use meteroid_store::domain::configs::{ + ApiSecurity, ProviderConfig, ProviderConfigNew, WebhookSecurity, + }; + use meteroid_store::domain::enums::InvoicingProviderEnum; + use uuid::Uuid; + + pub fn domain_to_server(db_model: ProviderConfig) -> TenantBillingConfiguration { + TenantBillingConfiguration { + billing_config_oneof: Some(BillingConfigOneof::Stripe( + meteroid_grpc::meteroid::api::tenants::v1::tenant_billing_configuration::Stripe { + api_secret: db_model.api_security.api_key, + webhook_secret: db_model.webhook_security.secret, + }, + )), + } + } - pub fn db_to_server(db_model: RepoProviderConfig) -> Option { - let api_key = db_model.api_key?.expose_secret().to_string(); - let webhook_secret = db_model.webhook_secret?.expose_secret().to_string(); + pub fn create_req_server_to_domain( + req: ConfigureTenantBillingRequest, + tenant_id: Uuid, + ) -> Result { + let billing_config = req + .billing_config + .clone() + .ok_or(TenantApiError::MissingArgument( + "billing_config".to_string(), + ))? + .billing_config_oneof + .ok_or(TenantApiError::MissingArgument( + "billing_config_oneof".to_string(), + ))?; - Some(TenantBillingConfiguration { - billing_config_oneof: Some( - meteroid_grpc::meteroid::api::tenants::v1::tenant_billing_configuration::BillingConfigOneof::Stripe( - meteroid_grpc::meteroid::api::tenants::v1::tenant_billing_configuration::Stripe { - api_secret: api_key, - webhook_secret, - }, - ), - ), - }) + let cfg = match billing_config { + BillingConfigOneof::Stripe(stripe) => ProviderConfigNew { + tenant_id, + invoicing_provider: InvoicingProviderEnum::Stripe, + enabled: true, + webhook_security: WebhookSecurity { + secret: stripe.webhook_secret, + }, + api_security: ApiSecurity { + api_key: stripe.api_secret, + }, + }, + }; + + Ok(cfg) } } diff --git a/modules/meteroid/src/api/tenants/mod.rs b/modules/meteroid/src/api/tenants/mod.rs index 6aa7e947..c4b02595 100644 --- a/modules/meteroid/src/api/tenants/mod.rs +++ b/modules/meteroid/src/api/tenants/mod.rs @@ -1,40 +1,16 @@ -use crate::db::{get_connection, get_transaction}; -use crate::repo::provider_config::ProviderConfigRepo; -use deadpool_postgres::{Object, Transaction}; use meteroid_grpc::meteroid::api::tenants::v1::tenants_service_server::TenantsServiceServer; -use meteroid_repository::Pool; -use std::sync::Arc; -use tonic::Status; +use meteroid_store::Store; mod error; mod mapping; mod service; pub struct TenantServiceComponents { - pub pool: Pool, - pub provider_config_repo: Arc, + pub store: Store, } -impl TenantServiceComponents { - pub async fn get_connection(&self) -> Result { - get_connection(&self.pool).await - } - pub async fn get_transaction<'a>( - &'a self, - client: &'a mut Object, - ) -> Result, Status> { - get_transaction(client).await - } -} - -pub fn service( - pool: Pool, - provider_config_repo: Arc, -) -> TenantsServiceServer { - let inner = TenantServiceComponents { - pool, - provider_config_repo, - }; +pub fn service(store: Store) -> TenantsServiceServer { + let inner = TenantServiceComponents { store }; TenantsServiceServer::new(inner) } diff --git a/modules/meteroid/src/api/tenants/service.rs b/modules/meteroid/src/api/tenants/service.rs index 21f23ca7..834b0dbd 100644 --- a/modules/meteroid/src/api/tenants/service.rs +++ b/modules/meteroid/src/api/tenants/service.rs @@ -1,22 +1,17 @@ use tonic::{Request, Response, Status}; use common_grpc::middleware::server::auth::RequestExt; -use meteroid_grpc::meteroid::api::tenants::v1::tenant_billing_configuration::BillingConfigOneof; use meteroid_grpc::meteroid::api::tenants::v1::{ tenants_service_server::TenantsService, ActiveTenantRequest, ActiveTenantResponse, ConfigureTenantBillingRequest, ConfigureTenantBillingResponse, CreateTenantRequest, CreateTenantResponse, GetTenantByIdRequest, GetTenantByIdResponse, ListTenantsRequest, - ListTenantsResponse, Tenant, + ListTenantsResponse, }; -use meteroid_repository as db; -use meteroid_repository::Params; +use meteroid_store::repositories::configs::ConfigsInterface; +use meteroid_store::repositories::TenantInterface; use crate::api::tenants::error::TenantApiError; -use crate::repo::provider_config::model::InvoicingProvider; -use crate::{ - api::utils::{parse_uuid, uuid_gen}, - parse_uuid, -}; +use crate::{api::utils::parse_uuid, parse_uuid}; use super::{mapping, TenantServiceComponents}; @@ -28,23 +23,16 @@ impl TenantsService for TenantServiceComponents { request: Request, ) -> Result, Status> { let tenant_id = request.tenant()?; - let connection = self.get_connection().await?; - let db_tenant = db::tenants::get_tenant_by_id() - .bind(&connection, &tenant_id) - .one() + let tenant = self + .store + .find_tenant_by_id(tenant_id) .await - .map_err(|e| { - TenantApiError::DatabaseError("failed to get tenant by id".to_string(), e) - })?; + .map(mapping::tenants::domain_to_server) + .map_err(Into::::into)?; Ok(Response::new(ActiveTenantResponse { - tenant: Some(Tenant { - id: db_tenant.id.to_string(), - name: db_tenant.name, - slug: db_tenant.slug, - currency: db_tenant.currency, - }), + tenant: Some(tenant), billing_config: None, // todo load it from provider_config if needed })) } @@ -54,20 +42,16 @@ impl TenantsService for TenantServiceComponents { &self, request: Request, ) -> Result, Status> { - let connection = self.get_connection().await?; - - let tenants = db::tenants::tenants_per_user() - .bind(&connection, &request.actor()?) - .all() + let result = self + .store + .list_tenants_by_user_id(request.actor()?) .await - .map_err(|e| { - TenantApiError::DatabaseError("failed to get tenants for user".to_string(), e) - })?; - - let result = tenants - .into_iter() - .map(mapping::tenants::db_to_server) - .collect::>(); + .map(|x| { + x.into_iter() + .map(mapping::tenants::domain_to_server) + .collect() + }) + .map_err(Into::::into)?; Ok(Response::new(ListTenantsResponse { tenants: result })) } @@ -78,23 +62,17 @@ impl TenantsService for TenantServiceComponents { request: Request, ) -> Result, Status> { let req = request.into_inner(); - let connection = self.get_connection().await?; + let tenant_id = parse_uuid!(&req.tenant_id)?; - let db_tenant = db::tenants::get_tenant_by_id() - .bind(&connection, &parse_uuid!(&req.tenant_id)?) - .one() + let tenant = self + .store + .find_tenant_by_id(tenant_id) .await - .map_err(|e| { - TenantApiError::DatabaseError("failed to get tenant by id".to_string(), e) - })?; + .map(mapping::tenants::domain_to_server) + .map_err(Into::::into)?; Ok(Response::new(GetTenantByIdResponse { - tenant: Some(Tenant { - id: db_tenant.id.to_string(), - name: db_tenant.name, - slug: db_tenant.slug, - currency: db_tenant.currency, - }), + tenant: Some(tenant), billing_config: None, // todo load it from provider_config if needed })) } @@ -106,25 +84,16 @@ impl TenantsService for TenantServiceComponents { ) -> Result, Status> { let actor = request.actor()?; - let req = request.into_inner(); - let connection = self.get_connection().await?; - - let params = db::tenants::CreateTenantForUserParams { - id: uuid_gen::v7(), - name: req.name, - slug: req.slug, - currency: req.currency, - user_id: actor, - }; - - let tenant = db::tenants::create_tenant_for_user() - .params(&connection, ¶ms) - .one() + let req = mapping::tenants::create_req_to_domain(request.into_inner(), actor); + + let res = self + .store + .insert_tenant(req) .await - .map_err(|e| TenantApiError::DatabaseError("failed to create tenant".to_string(), e))?; + .map(mapping::tenants::domain_to_server) + .map_err(Into::::into)?; - let rs = mapping::tenants::db_to_server(tenant); - Ok(Response::new(CreateTenantResponse { tenant: Some(rs) })) + Ok(Response::new(CreateTenantResponse { tenant: Some(res) })) } #[tracing::instrument(skip_all)] @@ -135,42 +104,17 @@ impl TenantsService for TenantServiceComponents { let tenant_id = request.tenant()?; let req = request.into_inner(); - let billing_config = req - .billing_config - .clone() - .ok_or(TenantApiError::MissingArgument( - "billing_config".to_string(), - ))? - .billing_config_oneof - .ok_or(TenantApiError::MissingArgument( - "billing_config_oneof".to_string(), - ))?; - - match billing_config { - BillingConfigOneof::Stripe(stripe) => { - let wh_secret = secrecy::SecretString::new(stripe.webhook_secret); - let api_secret = secrecy::SecretString::new(stripe.api_secret); - - let cfg = self - .provider_config_repo - .create_provider_config( - InvoicingProvider::Stripe, - tenant_id, - api_secret, - wh_secret, - ) - .await - .map_err(|e| { - TenantApiError::DownstreamApiError( - "failed to create tenant billing_config".to_string(), - Box::new(e.into_error()), - ) - })?; - - Ok(Response::new(ConfigureTenantBillingResponse { - billing_config: mapping::provider_configs::db_to_server(cfg), - })) - } - } + let cfg = mapping::provider_configs::create_req_server_to_domain(req, tenant_id)?; + + let res = self + .store + .insert_provider_config(cfg) + .await + .map(mapping::provider_configs::domain_to_server) + .map_err(Into::::into)?; + + Ok(Response::new(ConfigureTenantBillingResponse { + billing_config: Some(res), + })) } } diff --git a/modules/meteroid/src/api/webhooksout/mapping.rs b/modules/meteroid/src/api/webhooksout/mapping.rs index 1666d240..1540d23f 100644 --- a/modules/meteroid/src/api/webhooksout/mapping.rs +++ b/modules/meteroid/src/api/webhooksout/mapping.rs @@ -3,6 +3,7 @@ pub mod endpoint { use crate::api::webhooksout::mapping::event_type; use meteroid_grpc::meteroid::api::webhooks::out::v1::WebhookEndpoint as WebhookEndpointProto; use meteroid_repository::webhook_out_endpoints::WebhookOutEndpoint as WebhookEndpointDb; + use meteroid_store::crypt; use secrecy::{ExposeSecret, SecretString}; use tonic::Status; @@ -10,8 +11,12 @@ pub mod endpoint { endpoint: &WebhookEndpointDb, crypt_key: &SecretString, ) -> Result { - let secret = crate::crypt::decrypt(crypt_key, endpoint.secret.as_str()) - .map_err(|x| x.current_context().clone())?; + let secret = crypt::decrypt(crypt_key, endpoint.secret.as_str()).map_err(|x| { + Status::new( + tonic::Code::Internal, + x.current_context().clone().to_string(), + ) + })?; let endpoint = WebhookEndpointProto { id: endpoint.id.to_string(), diff --git a/modules/meteroid/src/api/webhooksout/service.rs b/modules/meteroid/src/api/webhooksout/service.rs index ed5cde73..041ad833 100644 --- a/modules/meteroid/src/api/webhooksout/service.rs +++ b/modules/meteroid/src/api/webhooksout/service.rs @@ -1,5 +1,4 @@ use cornucopia_async::Params; -use secrecy::ExposeSecret; use tonic::{Request, Response, Status}; use common_grpc::middleware::server::auth::RequestExt; @@ -12,6 +11,7 @@ use meteroid_grpc::meteroid::api::webhooks::out::v1::{ use meteroid_repository::webhook_out_endpoints::CreateEndpointParams; use meteroid_repository::webhook_out_events::ListEventsParams; use meteroid_repository::WebhookOutEventTypeEnum; +use meteroid_store::crypt; use crate::api::utils::parse_uuid; use crate::api::utils::{uuid_gen, webhook_security, PaginationExt}; @@ -39,15 +39,15 @@ impl WebhooksService for WebhooksServiceComponents { .map_err(|e| WebhookApiError::InvalidArgument(format!("Invalid URL: {}", e)))?; let secret_raw = webhook_security::gen(); - let secret = crate::crypt::encrypt(&self.crypt_key, secret_raw.as_str()) - .map_err(|x| x.current_context().clone())?; + let secret = crypt::encrypt(&self.crypt_key, secret_raw.as_str()) + .map_err(|x| Status::internal(x.current_context().clone().to_string()))?; let params = CreateEndpointParams { id: uuid_gen::v7(), tenant_id, url: req.url, description: req.description, - secret: secret.expose_secret().to_string(), + secret, events_to_listen: event_types, enabled: true, }; diff --git a/modules/meteroid/src/bin/scheduler.rs b/modules/meteroid/src/bin/scheduler.rs index 2622d38e..8fb69d0d 100644 --- a/modules/meteroid/src/bin/scheduler.rs +++ b/modules/meteroid/src/bin/scheduler.rs @@ -10,7 +10,7 @@ use common_build_info::BuildInfo; use common_logging::init::init_telemetry; use distributed_lock::locks::LockKey; use meteroid::config::Config; -use meteroid::repo::get_pool; +use meteroid::singletons::get_pool; use meteroid::workers::fang; use meteroid::workers::invoicing::draft_worker::DraftWorker; use meteroid::workers::invoicing::finalize_worker::FinalizeWorker; diff --git a/modules/meteroid/src/bin/seeder.rs b/modules/meteroid/src/bin/seeder.rs index 1149640a..9f0f295b 100644 --- a/modules/meteroid/src/bin/seeder.rs +++ b/modules/meteroid/src/bin/seeder.rs @@ -20,9 +20,13 @@ async fn main() -> error_stack::Result<(), SeederError> { init_regular_logging(); let _exit = signal::ctrl_c(); - let store = - Store::new(env::var("DATABASE_URL").change_context(SeederError::InitializationError)?) - .change_context(SeederError::InitializationError)?; + let crypt_key = secrecy::SecretString::new("00000000000000000000000000000000".into()); + + let store = Store::new( + env::var("DATABASE_URL").change_context(SeederError::InitializationError)?, + crypt_key, + ) + .change_context(SeederError::InitializationError)?; let organization_id = uuid::uuid!("018dfa06-2e9b-7c70-a6a9-7a9e4dc7ce70"); let user_id = uuid::uuid!("018dfa06-2e9c-74b8-a6ea-247967b75a63"); diff --git a/modules/meteroid/src/bin/server.rs b/modules/meteroid/src/bin/server.rs index 8cf550c5..7a52d55f 100644 --- a/modules/meteroid/src/bin/server.rs +++ b/modules/meteroid/src/bin/server.rs @@ -6,8 +6,7 @@ use common_build_info::BuildInfo; use common_logging::init::init_telemetry; use meteroid::adapters::stripe::Stripe; use meteroid::config::Config; -use meteroid::repo::get_pool; -use meteroid::repo::provider_config::{ProviderConfigRepo, ProviderConfigRepoCornucopia}; +use meteroid::singletons::get_pool; use meteroid::webhook_in_api; use meteroid_repository::migrations; @@ -28,18 +27,14 @@ async fn main() -> Result<(), Box> { let pool = get_pool(); - let provider_config_repo: Arc = - Arc::new(ProviderConfigRepoCornucopia::get().clone()); - // this creates a new pool, as it is incompatible with the one for cornucopia. - let store = meteroid_store::Store::new(config.database_url.clone())?; + let store = meteroid_store::Store::new( + config.database_url.clone(), + config.secrets_crypt_key.clone(), + )?; - let private_server = meteroid::api::server::start_api_server( - config.clone(), - pool.clone(), - store, - provider_config_repo.clone(), - ); + let private_server = + meteroid::api::server::start_api_server(config.clone(), pool.clone(), store.clone()); let exit = signal::ctrl_c(); @@ -60,7 +55,7 @@ async fn main() -> Result<(), Box> { object_store_client, pool.clone(), stripe_adapter.clone(), - provider_config_repo.clone(), + store, ) => {}, _ = exit => { log::info!("Interrupted"); diff --git a/modules/meteroid/src/eventbus/mod.rs b/modules/meteroid/src/eventbus/mod.rs index 4477ef8c..7bc7debe 100644 --- a/modules/meteroid/src/eventbus/mod.rs +++ b/modules/meteroid/src/eventbus/mod.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::api::utils::uuid_gen; use crate::config::Config; -use crate::repo::get_pool; +use crate::singletons; pub mod analytics_handler; pub mod memory; @@ -39,7 +39,7 @@ impl EventBusStatic { CONFIG .get_or_init(|| async { let config = Config::get(); - let pool = get_pool(); + let pool = singletons::get_pool(); let bus: Arc> = Arc::new(memory::InMemory::new()); diff --git a/modules/meteroid/src/eventbus/webhook_handler.rs b/modules/meteroid/src/eventbus/webhook_handler.rs index 997b8110..d5ba7e94 100644 --- a/modules/meteroid/src/eventbus/webhook_handler.rs +++ b/modules/meteroid/src/eventbus/webhook_handler.rs @@ -12,6 +12,7 @@ use uuid::Uuid; use crate::api::utils::uuid_gen; use meteroid_repository::webhook_out_events::CreateEventParams; +use meteroid_store::crypt; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; @@ -465,7 +466,7 @@ async fn get_active_endpoints_by_tenant( .into_iter() .filter_map(|e| { if e.enabled { - let secret = crate::crypt::decrypt(crypt_key, e.secret.as_str()).ok()?; + let secret = crypt::decrypt(crypt_key, e.secret.as_str()).ok()?; Some(Endpoint { id: e.id, diff --git a/modules/meteroid/src/lib.rs b/modules/meteroid/src/lib.rs index 60973bdf..7269131d 100644 --- a/modules/meteroid/src/lib.rs +++ b/modules/meteroid/src/lib.rs @@ -3,7 +3,6 @@ pub mod api; pub mod compute; pub mod config; pub mod constants; -pub mod crypt; mod datetime; pub mod db; pub mod encoding; @@ -11,9 +10,9 @@ mod errors; pub mod eventbus; pub mod mapping; pub mod models; -pub mod repo; pub mod seeder; pub mod services; +pub mod singletons; pub mod webhook; pub mod webhook_in_api; pub mod workers; diff --git a/modules/meteroid/src/repo/errors.rs b/modules/meteroid/src/repo/errors.rs deleted file mode 100644 index 1ca270de..00000000 --- a/modules/meteroid/src/repo/errors.rs +++ /dev/null @@ -1,9 +0,0 @@ -#[derive(Debug, thiserror::Error, PartialEq, Clone)] -pub enum RepoError { - #[error("Database error")] - DatabaseError, - #[error("Json field decoding error")] - JsonFieldDecodingError, - #[error("Json field encoding error")] - JsonFieldEncodingError, -} diff --git a/modules/meteroid/src/repo/provider_config.rs b/modules/meteroid/src/repo/provider_config.rs deleted file mode 100644 index 7da27bd4..00000000 --- a/modules/meteroid/src/repo/provider_config.rs +++ /dev/null @@ -1,260 +0,0 @@ -use crate::repo::errors::RepoError; -use crate::repo::provider_config::model::{ - ApiSecurityDb, InvoicingProvider, RepoProviderConfig, WebhookSecurityDb, -}; -use common_repository::Pool; -use cornucopia_async::Params; -use meteroid_repository as db; - -use crate::api::utils::uuid_gen; -use error_stack::{Result, ResultExt}; -use secrecy::{ExposeSecret, SecretString}; -use uuid::Uuid; - -use super::get_pool; - -static PROVIDER_CONF_REPO_CORN: std::sync::OnceLock = - std::sync::OnceLock::new(); - -#[async_trait::async_trait] -pub trait ProviderConfigRepo: Send + Sync + core::fmt::Debug + 'static { - async fn get_config_by_provider_and_tenant( - &self, - provider: InvoicingProvider, - tenant_id: uuid::Uuid, - ) -> Result; - - async fn create_provider_config( - &self, - provider: InvoicingProvider, - tenant_id: uuid::Uuid, - api_secret: secrecy::SecretString, - webhook_secret: secrecy::SecretString, - ) -> Result; -} - -#[derive(Clone, Debug)] -pub struct ProviderConfigRepoCornucopia { - pub pool: Pool, - pub crypt_key: secrecy::SecretString, -} - -impl ProviderConfigRepoCornucopia { - pub fn get() -> &'static Self { - PROVIDER_CONF_REPO_CORN.get_or_init(|| { - let config = crate::config::Config::get(); - ProviderConfigRepoCornucopia { - pool: get_pool().clone(), - crypt_key: config.secrets_crypt_key.clone(), - } - }) - } -} - -#[async_trait::async_trait] -impl ProviderConfigRepo for ProviderConfigRepoCornucopia { - #[tracing::instrument(skip_all)] - async fn get_config_by_provider_and_tenant( - &self, - provider: InvoicingProvider, - tenant_id: uuid::Uuid, - ) -> Result { - let conn = self - .pool - .get() - .await - .change_context(RepoError::DatabaseError)?; - - let provider_config = db::provider_configs::get_config_by_provider_and_endpoint() - .params( - &conn, - &db::provider_configs::GetConfigByProviderAndEndpointParams { - invoicing_provider: provider.into(), - tenant_id, - }, - ) - .one() - .await - .change_context(RepoError::DatabaseError)?; - - mapping::from_db(provider_config, &self.crypt_key) - } - - async fn create_provider_config( - &self, - provider: InvoicingProvider, - tenant_id: Uuid, - api_secret: SecretString, - webhook_secret: SecretString, - ) -> Result { - let conn = self - .pool - .get() - .await - .change_context(RepoError::DatabaseError)?; - - let wh_security = WebhookSecurityDb { - secret: webhook_secret.expose_secret().into(), - } - .encrypt_and_serialize(&self.crypt_key)?; - - let api_security = ApiSecurityDb { - api_key: api_secret.expose_secret().into(), - } - .encrypt_and_serialize(&self.crypt_key)?; - - let params = db::provider_configs::CreateProviderConfigParams { - id: uuid_gen::v7(), - tenant_id, - invoicing_provider: provider.into(), - enabled: true, - webhook_security: Some(wh_security), - api_security: Some(api_security), - }; - - let provider_config = db::provider_configs::create_provider_config() - .params(&conn, ¶ms) - .one() - .await - .change_context(RepoError::DatabaseError)?; - - mapping::from_db(provider_config, &self.crypt_key) - } -} - -pub mod model { - use crate::crypt; - use crate::repo::errors::RepoError; - use error_stack::{Result, ResultExt}; - use meteroid_repository::InvoicingProviderEnum; - use secrecy::{ExposeSecret, SecretString}; - use serde::{Deserialize, Serialize}; - use serde_json::Value; - - pub struct RepoProviderConfig { - pub id: uuid::Uuid, - pub tenant_id: uuid::Uuid, - pub invoicing_provider: InvoicingProvider, - pub enabled: bool, - pub webhook_secret: Option, - pub api_key: Option, - } - - #[derive(Clone, Debug)] - pub enum InvoicingProvider { - Stripe, - } - - impl From for InvoicingProvider { - fn from(value: InvoicingProviderEnum) -> Self { - match value { - InvoicingProviderEnum::STRIPE => InvoicingProvider::Stripe, - } - } - } - - impl From for InvoicingProviderEnum { - fn from(val: InvoicingProvider) -> Self { - match val { - InvoicingProvider::Stripe => InvoicingProviderEnum::STRIPE, - } - } - } - - #[derive(Clone, Debug, Serialize, Deserialize)] - pub struct WebhookSecurityDb { - pub secret: String, - } - - impl WebhookSecurityDb { - pub fn parse_and_decrypt( - json: Value, - crypt_key: &SecretString, - ) -> Result { - let db_encrypted: WebhookSecurityDb = - serde_json::from_value(json).change_context(RepoError::JsonFieldDecodingError)?; - - let decrypted_secret = crypt::decrypt(crypt_key, db_encrypted.secret.as_str()) - .change_context(RepoError::JsonFieldDecodingError)?; - - Ok(WebhookSecurityDb { - secret: decrypted_secret.expose_secret().to_string(), - }) - } - - pub fn encrypt_and_serialize(&self, crypt_key: &SecretString) -> Result { - let encrypted_secret = crypt::encrypt(crypt_key, self.secret.as_str()) - .change_context(RepoError::JsonFieldEncodingError)?; - - let encrypted = WebhookSecurityDb { - secret: encrypted_secret.expose_secret().to_string(), - }; - - serde_json::to_value(encrypted).change_context(RepoError::JsonFieldEncodingError) - } - } - - #[derive(Clone, Debug, Serialize, Deserialize)] - pub struct ApiSecurityDb { - pub api_key: String, - } - - impl ApiSecurityDb { - pub fn parse_and_decrypt( - json: Value, - crypt_key: &SecretString, - ) -> Result { - let db_encrypted: ApiSecurityDb = - serde_json::from_value(json).change_context(RepoError::JsonFieldDecodingError)?; - - let decrypted_secret = crypt::decrypt(crypt_key, db_encrypted.api_key.as_str()) - .change_context(RepoError::JsonFieldDecodingError)?; - - Ok(ApiSecurityDb { - api_key: decrypted_secret.expose_secret().to_string(), - }) - } - - pub fn encrypt_and_serialize(&self, crypt_key: &SecretString) -> Result { - let encrypted_secret = crypt::encrypt(crypt_key, self.api_key.as_str()) - .change_context(RepoError::JsonFieldEncodingError)?; - - let encrypted = ApiSecurityDb { - api_key: encrypted_secret.expose_secret().to_string(), - }; - - serde_json::to_value(encrypted).change_context(RepoError::JsonFieldEncodingError) - } - } -} - -pub mod mapping { - use super::model::{ApiSecurityDb, RepoProviderConfig, WebhookSecurityDb}; - use crate::repo::errors::RepoError; - use error_stack::Result; - use meteroid_repository::provider_configs as db; - - pub fn from_db( - db_config: db::ProviderConfig, - crypt_key: &secrecy::SecretString, - ) -> Result { - let wh_security = db_config - .webhook_security - .map(|security| WebhookSecurityDb::parse_and_decrypt(security, crypt_key)) - .transpose()?; - - let api_security = db_config - .api_security - .map(|security| ApiSecurityDb::parse_and_decrypt(security, crypt_key)) - .transpose()?; - - Ok(RepoProviderConfig { - id: db_config.id, - tenant_id: db_config.tenant_id, - invoicing_provider: db_config.invoicing_provider.into(), - enabled: db_config.enabled, - webhook_secret: wh_security.map(|security| security.secret.into()), - api_key: api_security.map(|security| security.api_key.into()), - }) - } -} diff --git a/modules/meteroid/src/seeder/runner.rs b/modules/meteroid/src/seeder/runner.rs index 07ed54e1..63cce425 100644 --- a/modules/meteroid/src/seeder/runner.rs +++ b/modules/meteroid/src/seeder/runner.rs @@ -52,13 +52,15 @@ pub async fn run( }; let tenant = store - .insert_tenant(store_domain::TenantNew { - name: scenario.tenant.name, - slug: scenario.tenant.slug, - organization_id: organization_id.clone(), - currency: scenario.tenant.currency, - environment: Some(TenantEnvironmentEnum::Sandbox), - }) + .insert_tenant(store_domain::TenantNew::ForOrg( + store_domain::OrgTenantNew { + name: scenario.tenant.name, + slug: scenario.tenant.slug, + organization_id: organization_id.clone(), + currency: scenario.tenant.currency, + environment: Some(TenantEnvironmentEnum::Sandbox), + }, + )) .await .change_context(SeederError::TempError)?; diff --git a/modules/meteroid/src/repo/mod.rs b/modules/meteroid/src/singletons.rs similarity index 70% rename from modules/meteroid/src/repo/mod.rs rename to modules/meteroid/src/singletons.rs index 487c4331..493839a2 100644 --- a/modules/meteroid/src/repo/mod.rs +++ b/modules/meteroid/src/singletons.rs @@ -3,9 +3,6 @@ use deadpool_postgres::Pool; use meteroid_store::Store; use std::sync::OnceLock; -pub mod errors; -pub mod provider_config; - static POOL: OnceLock = OnceLock::new(); pub fn get_pool() -> &'static Pool { @@ -18,8 +15,12 @@ pub fn get_pool() -> &'static Pool { static STORE: OnceLock = OnceLock::new(); pub fn get_store() -> &'static Store { - crate::repo::STORE.get_or_init(|| { + STORE.get_or_init(|| { let config = Config::get(); - Store::new(config.database_url.clone()).expect("Failed to initialize store") + Store::new( + config.database_url.clone(), + config.secrets_crypt_key.clone(), + ) + .expect("Failed to initialize store") }) } diff --git a/modules/meteroid/src/webhook_in_api.rs b/modules/meteroid/src/webhook_in_api.rs index a5d5f4ef..1133b8c1 100644 --- a/modules/meteroid/src/webhook_in_api.rs +++ b/modules/meteroid/src/webhook_in_api.rs @@ -12,11 +12,7 @@ use object_store::ObjectStore; use std::net::SocketAddr; use std::sync::Arc; -use crate::{ - adapters::types::WebhookAdapter, - errors, - repo::provider_config::{model::InvoicingProvider, ProviderConfigRepo}, -}; +use crate::{adapters::types::WebhookAdapter, errors}; use crate::{ adapters::{stripe::Stripe, types::ParsedRequest}, encoding, @@ -25,11 +21,15 @@ use cornucopia_async::Params; use meteroid_repository as db; use error_stack::{bail, Result, ResultExt}; +use meteroid_store::domain::enums::InvoicingProviderEnum; +use meteroid_store::repositories::configs::ConfigsInterface; +use meteroid_store::Store; +use secrecy::SecretString; -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct AppState { pub object_store: Arc, - pub provider_config_repo: Arc, + pub store: Store, pub db_pool: Pool, pub stripe_adapter: Arc, } @@ -39,11 +39,11 @@ pub async fn serve( object_store_client: Arc, db_pool: Pool, stripe_adapter: Arc, - provider_config_repo: Arc, + store: Store, ) { let app_state = AppState { object_store: object_store_client.clone(), - provider_config_repo: provider_config_repo.clone(), + store, db_pool: db_pool.clone(), stripe_adapter: stripe_adapter.clone(), }; @@ -94,7 +94,7 @@ async fn handler( ); let provider = match provider_str.as_str() { - "stripe" => InvoicingProvider::Stripe, + "stripe" => InvoicingProviderEnum::Stripe, // add other providers here _ => bail!(errors::AdapterWebhookError::UnknownProvider(provider_str)), }; @@ -107,8 +107,8 @@ async fn handler( // - get webhook from storage (db, optional redis cache) let provider_config = app_state - .provider_config_repo - .get_config_by_provider_and_tenant(provider.clone(), tenant_id) + .store + .find_provider_config(provider.clone(), tenant_id) .await .change_context(errors::AdapterWebhookError::UnknownEndpointId)?; @@ -149,7 +149,7 @@ async fn handler( // - get adapter let adapter = match provider { - InvoicingProvider::Stripe => app_state.stripe_adapter, + InvoicingProviderEnum::Stripe => app_state.stripe_adapter, }; // - decode body @@ -174,9 +174,7 @@ async fn handler( adapter .verify_webhook( &parsed_request, - &provider_config - .webhook_secret - .ok_or(errors::AdapterWebhookError::SignatureNotFound)?, + &SecretString::new(provider_config.webhook_security.secret), ) .await?; // TODO save errors in webhook_events db diff --git a/modules/meteroid/src/workers/invoicing/draft_worker.rs b/modules/meteroid/src/workers/invoicing/draft_worker.rs index 9ea53a28..da06f8f6 100644 --- a/modules/meteroid/src/workers/invoicing/draft_worker.rs +++ b/modules/meteroid/src/workers/invoicing/draft_worker.rs @@ -1,5 +1,5 @@ use crate::workers::metrics::record_call; -use crate::{errors, repo::get_pool}; +use crate::{errors, singletons}; use common_utils::timed::*; use cornucopia_async::Params; use deadpool_postgres::Pool; @@ -25,7 +25,7 @@ pub struct DraftWorker; impl AsyncRunnable for DraftWorker { #[tracing::instrument(skip_all)] async fn run(&self, _queue: &mut dyn AsyncQueueable) -> core::result::Result<(), FangError> { - let pool = get_pool(); + let pool = singletons::get_pool(); let eventbus = EventBusStatic::get().await; draft_worker( diff --git a/modules/meteroid/src/workers/invoicing/finalize_worker.rs b/modules/meteroid/src/workers/invoicing/finalize_worker.rs index 76ae8927..52224319 100644 --- a/modules/meteroid/src/workers/invoicing/finalize_worker.rs +++ b/modules/meteroid/src/workers/invoicing/finalize_worker.rs @@ -3,11 +3,10 @@ use std::sync::Arc; use common_repository::Pool; use meteroid_repository as db; -use crate::{compute::InvoiceEngine, errors}; +use crate::{compute::InvoiceEngine, errors, singletons}; use crate::compute::clients::usage::MeteringUsageClient; use crate::eventbus::{Event, EventBus, EventBusStatic}; -use crate::repo::{get_pool, get_store}; use common_utils::timed::TimedExt; use error_stack::{Result, ResultExt}; use fang::{AsyncQueueable, AsyncRunnable, Deserialize, FangError, Scheduled, Serialize}; @@ -33,10 +32,10 @@ impl AsyncRunnable for FinalizeWorker { async fn run(&self, _queue: &mut dyn AsyncQueueable) -> core::result::Result<(), FangError> { let eventbus = EventBusStatic::get().await; finalize_worker( - get_pool().clone(), + singletons::get_pool().clone(), MeteringClient::get().clone(), eventbus.clone(), - get_store().clone(), + singletons::get_store().clone(), ) .timed(|res, elapsed| record_call("finalize", res, elapsed)) .await diff --git a/modules/meteroid/src/workers/invoicing/issue_worker.rs b/modules/meteroid/src/workers/invoicing/issue_worker.rs index 150d356d..ab7f7e31 100644 --- a/modules/meteroid/src/workers/invoicing/issue_worker.rs +++ b/modules/meteroid/src/workers/invoicing/issue_worker.rs @@ -1,10 +1,7 @@ use crate::adapters::stripe::Stripe; use crate::adapters::types::InvoicingAdapter; -use crate::errors; -use crate::repo::get_pool; -use crate::repo::provider_config::model::InvoicingProvider; -use crate::repo::provider_config::{ProviderConfigRepo, ProviderConfigRepoCornucopia}; use crate::workers::metrics::record_call; +use crate::{errors, singletons}; use common_utils::timed::TimedExt; use cornucopia_async::Params; use deadpool_postgres::Pool; @@ -13,6 +10,9 @@ use fang::{AsyncQueueable, AsyncRunnable, Deserialize, FangError, Scheduled, Ser use meteroid_repository as db; use meteroid_repository::invoices::UpdateInvoiceIssueErrorParams; use meteroid_repository::InvoicingProviderEnum; +use meteroid_store::repositories::configs::ConfigsInterface; +use meteroid_store::Store; +use secrecy::SecretString; #[derive(Serialize, Deserialize)] #[serde(crate = "fang::serde")] @@ -24,9 +24,9 @@ impl AsyncRunnable for IssueWorker { #[tracing::instrument(skip(self, _queue))] async fn run(&self, _queue: &mut dyn AsyncQueueable) -> core::result::Result<(), FangError> { issue_worker( - get_pool(), + singletons::get_pool(), Stripe::get(), - ProviderConfigRepoCornucopia::get() as &dyn ProviderConfigRepo, + singletons::get_store(), ) .timed(|res, elapsed| record_call("issue", res, elapsed)) .await @@ -56,7 +56,7 @@ impl AsyncRunnable for IssueWorker { async fn issue_worker( pool: &Pool, stripe_adapter: &Stripe, - provider_config_repo: &dyn ProviderConfigRepo, + store: &Store, ) -> Result<(), errors::WorkerError> { // fetch all invoices with issue=false and send to stripe @@ -75,7 +75,7 @@ async fn issue_worker( .change_context(errors::WorkerError::DatabaseError)?; for invoice in invoices { - let result = issue_invoice(&invoice, stripe_adapter, provider_config_repo).await; + let result = issue_invoice(&invoice, stripe_adapter, store, pool).await; let connection = pool .get() @@ -117,12 +117,11 @@ async fn issue_worker( async fn issue_invoice( invoice: &db::invoices::Invoice, stripe_adapter: &Stripe, - provider_config_repo: &dyn ProviderConfigRepo, + store: &Store, + pool: &Pool, ) -> Result<(), errors::WorkerError> { match invoice.invoicing_provider { InvoicingProviderEnum::STRIPE => { - let pool = get_pool(); - let conn = pool .get() .await @@ -137,15 +136,18 @@ async fn issue_invoice( let customer = crate::api::customers::mapping::customer::db_to_server(customer) .change_context(errors::WorkerError::DatabaseError)?; - let api_key = provider_config_repo - .get_config_by_provider_and_tenant(InvoicingProvider::Stripe, invoice.tenant_id) + let api_key = store + .find_provider_config( + meteroid_store::domain::enums::InvoicingProviderEnum::Stripe, + invoice.tenant_id, + ) .await .change_context(errors::WorkerError::DatabaseError)? - .api_key - .ok_or(errors::WorkerError::ProviderError)?; + .api_security + .api_key; stripe_adapter - .send_invoice(invoice, &customer, api_key) + .send_invoice(invoice, &customer, SecretString::new(api_key)) .await .change_context(errors::WorkerError::ProviderError)?; diff --git a/modules/meteroid/src/workers/invoicing/pending_status_worker.rs b/modules/meteroid/src/workers/invoicing/pending_status_worker.rs index a5410cfb..cd348481 100644 --- a/modules/meteroid/src/workers/invoicing/pending_status_worker.rs +++ b/modules/meteroid/src/workers/invoicing/pending_status_worker.rs @@ -1,4 +1,4 @@ -use crate::{errors, repo::get_pool}; +use crate::{errors, singletons}; use deadpool_postgres::Pool; use fang::{AsyncQueueable, AsyncRunnable, Deserialize, FangError, Scheduled, Serialize}; use meteroid_repository as db; @@ -16,7 +16,7 @@ pub struct PendingStatusWorker; impl AsyncRunnable for PendingStatusWorker { #[tracing::instrument(skip_all)] async fn run(&self, _queue: &mut dyn AsyncQueueable) -> core::result::Result<(), FangError> { - let pool = get_pool(); + let pool = singletons::get_pool(); pending_worker(pool) .timed(|res, elapsed| record_call("pending", res, elapsed)) diff --git a/modules/meteroid/src/workers/invoicing/price_worker.rs b/modules/meteroid/src/workers/invoicing/price_worker.rs index 9e992d74..e8768213 100644 --- a/modules/meteroid/src/workers/invoicing/price_worker.rs +++ b/modules/meteroid/src/workers/invoicing/price_worker.rs @@ -7,10 +7,9 @@ use std::sync::Arc; use common_repository::Pool; use meteroid_repository as db; -use crate::{compute::InvoiceEngine, errors}; +use crate::{compute::InvoiceEngine, errors, singletons}; use crate::compute::clients::usage::MeteringUsageClient; -use crate::repo::{get_pool, get_store}; use crate::workers::clients::metering::MeteringClient; use crate::workers::metrics::record_call; use common_utils::timed::TimedExt; @@ -43,8 +42,8 @@ impl AsyncRunnable for PriceWorker { #[tracing::instrument(skip_all)] async fn run(&self, _queue: &mut dyn AsyncQueueable) -> core::result::Result<(), FangError> { price_worker( - get_pool().clone(), - get_store().clone(), + singletons::get_pool().clone(), + singletons::get_store().clone(), MeteringClient::get().clone(), ) .timed(|res, elapsed| record_call("price", res, elapsed)) diff --git a/modules/meteroid/src/workers/misc/currency_rates_worker.rs b/modules/meteroid/src/workers/misc/currency_rates_worker.rs index d2062c68..0b5112a8 100644 --- a/modules/meteroid/src/workers/misc/currency_rates_worker.rs +++ b/modules/meteroid/src/workers/misc/currency_rates_worker.rs @@ -1,6 +1,5 @@ use crate::api::utils::uuid_gen; -use crate::errors; -use crate::repo::get_pool; +use crate::{errors, singletons}; use crate::services::currency_rates::{CurrencyRatesService, OpenexchangeRatesService}; use crate::workers::metrics::record_call; @@ -21,7 +20,7 @@ pub struct CurrencyRatesWorker; impl AsyncRunnable for CurrencyRatesWorker { #[tracing::instrument(skip(self, _queue))] async fn run(&self, _queue: &mut dyn AsyncQueueable) -> core::result::Result<(), FangError> { - let pool = get_pool(); + let pool = singletons::get_pool(); currency_rates_worker(pool, OpenexchangeRatesService::get()) .timed(|res, elapsed| record_call("issue", res, elapsed)) .await diff --git a/modules/meteroid/tests/integration/main.rs b/modules/meteroid/tests/integration/main.rs index 3f729b38..7f59b2a9 100644 --- a/modules/meteroid/tests/integration/main.rs +++ b/modules/meteroid/tests/integration/main.rs @@ -11,5 +11,6 @@ mod test_idempotency; mod test_idempotency_cache; mod test_slot_transaction; mod test_subscription; +mod test_tenant; mod test_webhooks_out; mod test_workers; diff --git a/modules/meteroid/tests/integration/meteroid_it/container.rs b/modules/meteroid/tests/integration/meteroid_it/container.rs index 0abfb9ff..ffae9dd1 100644 --- a/modules/meteroid/tests/integration/meteroid_it/container.rs +++ b/modules/meteroid/tests/integration/meteroid_it/container.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use std::time::Duration; use deadpool_postgres::Pool; @@ -11,7 +10,6 @@ use tokio_util::sync::CancellationToken; use tonic::transport::Channel; use meteroid::config::Config; -use meteroid::repo::provider_config::ProviderConfigRepoCornucopia; use meteroid_repository::migrations; use crate::helpers; @@ -43,29 +41,20 @@ pub async fn start_meteroid_with_port( let pool = meteroid_repository::create_pool(&config.database_url); - let _store = - meteroid_store::Store::new(config.database_url.clone()).expect("Could not create store"); - populate_postgres(pool.clone(), seed_level).await; let token = CancellationToken::new(); let cloned_token = token.clone(); - let provider_config_repo = Arc::new(ProviderConfigRepoCornucopia { - pool: pool.clone(), - crypt_key: config.secrets_crypt_key.clone(), - }); - - let store = - meteroid_store::Store::new(config.database_url.clone()).expect("Could not create store"); + let store = meteroid_store::Store::new( + config.database_url.clone(), + config.secrets_crypt_key.clone(), + ) + .expect("Could not create store"); log::info!("Starting gRPC server {}", config.listen_addr); - let private_server = meteroid::api::server::start_api_server( - config.clone(), - pool.clone(), - store.clone(), - provider_config_repo, - ); + let private_server = + meteroid::api::server::start_api_server(config.clone(), pool.clone(), store.clone()); let join_handle_meteroid = tokio::spawn(async move { tokio::select! { diff --git a/modules/meteroid/tests/integration/test_tenant.rs b/modules/meteroid/tests/integration/test_tenant.rs new file mode 100644 index 00000000..2b40cc40 --- /dev/null +++ b/modules/meteroid/tests/integration/test_tenant.rs @@ -0,0 +1,127 @@ +use testcontainers::clients::Cli; + +use crate::helpers; +use crate::meteroid_it; +use crate::meteroid_it::container::SeedLevel; +use meteroid_grpc::meteroid::api; +use meteroid_grpc::meteroid::api::tenants::v1::tenant_billing_configuration::{ + BillingConfigOneof, Stripe, +}; +use meteroid_grpc::meteroid::api::tenants::v1::{ + ConfigureTenantBillingRequest, TenantBillingConfiguration, +}; +use meteroid_grpc::meteroid::api::users::v1::UserRole; + +#[tokio::test] +async fn test_tenants_basic() { + // Generic setup + helpers::init::logging(); + let docker = Cli::default(); + let (_postgres_container, postgres_connection_string) = + meteroid_it::container::start_postgres(&docker); + let setup = + meteroid_it::container::start_meteroid(postgres_connection_string, SeedLevel::MINIMAL) + .await; + + let auth = meteroid_it::svc_auth::login(setup.channel.clone()).await; + assert_eq!(auth.user.unwrap().role, UserRole::Admin as i32); + + let clients = meteroid_it::clients::AllClients::from_channel( + setup.channel.clone(), + auth.token.clone().as_str(), + "a712afi5lzhk", + ); + + let tenant_name = "meter_me"; + let tenant_slug = "meter-me"; + let tenant_currency = "EUR"; + + // create tenant + let created = clients + .tenants + .clone() + .create_tenant(api::tenants::v1::CreateTenantRequest { + name: tenant_name.to_string(), + slug: tenant_slug.to_string(), + currency: tenant_currency.to_string(), + }) + .await + .unwrap() + .into_inner() + .tenant + .unwrap(); + + assert_eq!(created.currency.as_str(), tenant_currency); + assert_eq!(created.name, tenant_name); + assert_eq!(created.slug, tenant_slug); + + // tenant by id + let by_id = clients + .tenants + .clone() + .get_tenant_by_id(api::tenants::v1::GetTenantByIdRequest { + tenant_id: created.id.clone(), + }) + .await + .unwrap() + .into_inner() + .tenant + .unwrap(); + + assert_eq!(by_id.currency.as_str(), tenant_currency); + assert_eq!(by_id.name, tenant_name); + assert_eq!(by_id.slug, tenant_slug); + + // active tenant + let active = clients + .tenants + .clone() + .active_tenant(api::tenants::v1::ActiveTenantRequest {}) + .await + .unwrap() + .into_inner() + .tenant + .unwrap(); + + assert_ne!(&active, &created); + + // list tenants + let listed = clients + .tenants + .clone() + .list_tenants(api::tenants::v1::ListTenantsRequest {}) + .await + .unwrap() + .into_inner() + .tenants; + + let listed_created = listed.iter().find(|x| *x == &created); + + assert_eq!(listed.len(), 2); + assert_eq!(listed_created, Some(created).as_ref()); + + // configure tenant billing + let cfg = TenantBillingConfiguration { + billing_config_oneof: Some(BillingConfigOneof::Stripe(Stripe { + api_secret: "api_secret".into(), + webhook_secret: "webhook_secret".into(), + })), + }; + + let cfg_res = clients + .tenants + .clone() + .configure_tenant_billing(ConfigureTenantBillingRequest { + billing_config: Some(cfg.clone()), + }) + .await + .unwrap() + .into_inner() + .billing_config + .unwrap(); + + assert_eq!(&cfg_res, &cfg); + + // teardown + meteroid_it::container::terminate_meteroid(setup.token, setup.join_handle).await +}