From 5e48eed4869bde068ba6c2c6c9befd99afd6a32e Mon Sep 17 00:00:00 2001 From: Aravinth Manivannan Date: Mon, 16 Sep 2024 17:58:28 +0530 Subject: [PATCH 1/3] feat: import store aggregate from inventory into billing domain --- ...801fe319ba86a15c09aacc4990a7a58849081.json | 22 ++ ...e2649a7b5baa3fc3abbb6ee7f522fedf3062e.json | 28 ++ ...de6bdfefc19839129577407a1e4071f5c49f6.json | 19 ++ ...9217cd9f28f1ebe73ddc081ec2f7bec5dc1b3.json | 19 ++ ...dc89374f95ea0e3967124cb1e414c52d0428a.json | 22 ++ ...27178f9bcf4055cbf176b5e8fb2376a5972ba.json | 46 +++ ...0240916121242_cqrs_billing_store_query.sql | 16 + src/billing/adapters/output/db/mod.rs | 4 + .../adapters/output/db/postgres/errors.rs | 79 +++++ .../adapters/output/db/postgres/mod.rs | 28 ++ .../output/db/postgres/store_id_exists.rs | 87 +++++ .../output/db/postgres/store_name_exists.rs | 81 +++++ .../adapters/output/db/postgres/store_view.rs | 302 ++++++++++++++++++ src/billing/adapters/output/mod.rs | 1 + .../port/output/db/store_id_exists.rs | 55 ++++ .../port/output/db/store_name_exists.rs | 54 ++++ .../application/services/add_store_service.rs | 149 +++++++++ .../services/update_store_service.rs | 146 +++++++++ src/billing/domain/add_store_command.rs | 82 +++++ src/billing/domain/store_added_event.rs | 18 ++ src/billing/domain/store_aggregate.rs | 146 +++++++++ src/billing/domain/store_updated_event.rs | 43 +++ src/billing/domain/update_store_command.rs | 127 ++++++++ 23 files changed, 1574 insertions(+) create mode 100644 .sqlx/query-03b31d653e96f3b28ff15c86ff5801fe319ba86a15c09aacc4990a7a58849081.json create mode 100644 .sqlx/query-0bf69e4af7657572bf84633ecf8e2649a7b5baa3fc3abbb6ee7f522fedf3062e.json create mode 100644 .sqlx/query-3811531518316435c32223582f9de6bdfefc19839129577407a1e4071f5c49f6.json create mode 100644 .sqlx/query-78008f1b0d3f366937c72a181b19217cd9f28f1ebe73ddc081ec2f7bec5dc1b3.json create mode 100644 .sqlx/query-ba895b4310f182a73d88df08255dc89374f95ea0e3967124cb1e414c52d0428a.json create mode 100644 .sqlx/query-c3f26f3816104a510a4630d5d3d27178f9bcf4055cbf176b5e8fb2376a5972ba.json create mode 100644 migrations/20240916121242_cqrs_billing_store_query.sql create mode 100644 src/billing/adapters/output/db/mod.rs create mode 100644 src/billing/adapters/output/db/postgres/errors.rs create mode 100644 src/billing/adapters/output/db/postgres/mod.rs create mode 100644 src/billing/adapters/output/db/postgres/store_id_exists.rs create mode 100644 src/billing/adapters/output/db/postgres/store_name_exists.rs create mode 100644 src/billing/adapters/output/db/postgres/store_view.rs create mode 100644 src/billing/application/port/output/db/store_id_exists.rs create mode 100644 src/billing/application/port/output/db/store_name_exists.rs create mode 100644 src/billing/application/services/add_store_service.rs create mode 100644 src/billing/application/services/update_store_service.rs create mode 100644 src/billing/domain/add_store_command.rs create mode 100644 src/billing/domain/store_added_event.rs create mode 100644 src/billing/domain/store_aggregate.rs create mode 100644 src/billing/domain/store_updated_event.rs create mode 100644 src/billing/domain/update_store_command.rs diff --git a/.sqlx/query-03b31d653e96f3b28ff15c86ff5801fe319ba86a15c09aacc4990a7a58849081.json b/.sqlx/query-03b31d653e96f3b28ff15c86ff5801fe319ba86a15c09aacc4990a7a58849081.json new file mode 100644 index 0000000..8d3c328 --- /dev/null +++ b/.sqlx/query-03b31d653e96f3b28ff15c86ff5801fe319ba86a15c09aacc4990a7a58849081.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_billing_store_query\n WHERE\n name = $1\n AND\n deleted = false\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "03b31d653e96f3b28ff15c86ff5801fe319ba86a15c09aacc4990a7a58849081" +} diff --git a/.sqlx/query-0bf69e4af7657572bf84633ecf8e2649a7b5baa3fc3abbb6ee7f522fedf3062e.json b/.sqlx/query-0bf69e4af7657572bf84633ecf8e2649a7b5baa3fc3abbb6ee7f522fedf3062e.json new file mode 100644 index 0000000..eea4c61 --- /dev/null +++ b/.sqlx/query-0bf69e4af7657572bf84633ecf8e2649a7b5baa3fc3abbb6ee7f522fedf3062e.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n store_id, version\n FROM\n cqrs_billing_store_query\n WHERE\n store_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "store_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "0bf69e4af7657572bf84633ecf8e2649a7b5baa3fc3abbb6ee7f522fedf3062e" +} diff --git a/.sqlx/query-3811531518316435c32223582f9de6bdfefc19839129577407a1e4071f5c49f6.json b/.sqlx/query-3811531518316435c32223582f9de6bdfefc19839129577407a1e4071f5c49f6.json new file mode 100644 index 0000000..2a116d6 --- /dev/null +++ b/.sqlx/query-3811531518316435c32223582f9de6bdfefc19839129577407a1e4071f5c49f6.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n cqrs_billing_store_query\n SET\n version = $1,\n name = $2,\n address = $3,\n store_id = $4,\n owner = $5,\n deleted = $6;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Uuid", + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "3811531518316435c32223582f9de6bdfefc19839129577407a1e4071f5c49f6" +} diff --git a/.sqlx/query-78008f1b0d3f366937c72a181b19217cd9f28f1ebe73ddc081ec2f7bec5dc1b3.json b/.sqlx/query-78008f1b0d3f366937c72a181b19217cd9f28f1ebe73ddc081ec2f7bec5dc1b3.json new file mode 100644 index 0000000..2666482 --- /dev/null +++ b/.sqlx/query-78008f1b0d3f366937c72a181b19217cd9f28f1ebe73ddc081ec2f7bec5dc1b3.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO cqrs_billing_store_query (\n version, name, address, store_id, owner, deleted\n ) VALUES (\n $1, $2, $3, $4, $5, $6\n );", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Uuid", + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "78008f1b0d3f366937c72a181b19217cd9f28f1ebe73ddc081ec2f7bec5dc1b3" +} diff --git a/.sqlx/query-ba895b4310f182a73d88df08255dc89374f95ea0e3967124cb1e414c52d0428a.json b/.sqlx/query-ba895b4310f182a73d88df08255dc89374f95ea0e3967124cb1e414c52d0428a.json new file mode 100644 index 0000000..b469031 --- /dev/null +++ b/.sqlx/query-ba895b4310f182a73d88df08255dc89374f95ea0e3967124cb1e414c52d0428a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_billing_store_query\n WHERE\n store_id = $1\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ba895b4310f182a73d88df08255dc89374f95ea0e3967124cb1e414c52d0428a" +} diff --git a/.sqlx/query-c3f26f3816104a510a4630d5d3d27178f9bcf4055cbf176b5e8fb2376a5972ba.json b/.sqlx/query-c3f26f3816104a510a4630d5d3d27178f9bcf4055cbf176b5e8fb2376a5972ba.json new file mode 100644 index 0000000..8c0ba19 --- /dev/null +++ b/.sqlx/query-c3f26f3816104a510a4630d5d3d27178f9bcf4055cbf176b5e8fb2376a5972ba.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n name, address, store_id, owner, deleted\n FROM\n cqrs_billing_store_query\n WHERE\n store_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "store_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "owner", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "deleted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false, + false, + false + ] + }, + "hash": "c3f26f3816104a510a4630d5d3d27178f9bcf4055cbf176b5e8fb2376a5972ba" +} diff --git a/migrations/20240916121242_cqrs_billing_store_query.sql b/migrations/20240916121242_cqrs_billing_store_query.sql new file mode 100644 index 0000000..c858ca1 --- /dev/null +++ b/migrations/20240916121242_cqrs_billing_store_query.sql @@ -0,0 +1,16 @@ +--- SPDX-FileCopyrightText: 2024 Aravinth Manivannan +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +CREATE TABLE IF NOT EXISTS cqrs_billing_store_query +( + version bigint CHECK (version >= 0) NOT NULL, + + name TEXT NOT NULL, + address TEXT, + owner UUID NOT NULL, + store_id UUID NOT NULL UNIQUE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + + PRIMARY KEY (store_id) +); diff --git a/src/billing/adapters/output/db/mod.rs b/src/billing/adapters/output/db/mod.rs new file mode 100644 index 0000000..efa3961 --- /dev/null +++ b/src/billing/adapters/output/db/mod.rs @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later +mod postgres; diff --git a/src/billing/adapters/output/db/postgres/errors.rs b/src/billing/adapters/output/db/postgres/errors.rs new file mode 100644 index 0000000..ded92b6 --- /dev/null +++ b/src/billing/adapters/output/db/postgres/errors.rs @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::borrow::Cow; + +use cqrs_es::persist::PersistenceError; +use sqlx::Error as SqlxError; + +use crate::billing::application::port::output::db::errors::BillingDBError; + +impl From for BillingDBError { + fn from(e: SqlxError) -> Self { + log::error!("[postgres] err: {}", e); + if let SqlxError::Database(err) = e { + if err.code() == Some(Cow::from("23505")) { + let msg = err.message(); + if msg.contains("cqrs_inventory_store_query_store_id_key") { + return Self::DuplicateStoreID; + } else { + println!("{msg}"); + } + } + } + Self::InternalError + } +} + +/// map custom row not found error to DB error +pub fn map_row_not_found_err(e: SqlxError, row_not_found: BillingDBError) -> BillingDBError { + if let SqlxError::RowNotFound = e { + row_not_found + } else { + e.into() + } +} + +#[derive(Debug)] +pub enum PostgresAggregateError { + OptimisticLock, + ConnectionError(Box), + DeserializationError(Box), + UnknownError(Box), +} + +impl From for PostgresAggregateError { + fn from(err: SqlxError) -> Self { + // TODO: improve error handling + match &err { + SqlxError::Database(database_error) => { + if let Some(code) = database_error.code() { + if code.as_ref() == "23505" { + return PostgresAggregateError::OptimisticLock; + } + } + PostgresAggregateError::UnknownError(Box::new(err)) + } + SqlxError::Io(_) | SqlxError::Tls(_) => { + PostgresAggregateError::ConnectionError(Box::new(err)) + } + _ => PostgresAggregateError::UnknownError(Box::new(err)), + } + } +} + +impl From for PersistenceError { + fn from(err: PostgresAggregateError) -> Self { + match err { + PostgresAggregateError::OptimisticLock => PersistenceError::OptimisticLockError, + PostgresAggregateError::ConnectionError(error) => { + PersistenceError::ConnectionError(error) + } + PostgresAggregateError::DeserializationError(error) => { + PersistenceError::UnknownError(error) + } + PostgresAggregateError::UnknownError(error) => PersistenceError::UnknownError(error), + } + } +} diff --git a/src/billing/adapters/output/db/postgres/mod.rs b/src/billing/adapters/output/db/postgres/mod.rs new file mode 100644 index 0000000..d816ff5 --- /dev/null +++ b/src/billing/adapters/output/db/postgres/mod.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later +use std::sync::Arc; + +use sqlx::postgres::PgPool; + +use crate::db::{migrate::RunMigrations, sqlx_postgres::Postgres}; + +mod errors; +mod store_id_exists; +mod store_name_exists; +mod store_view; + +#[derive(Clone)] +pub struct BillingDBPostgresAdapter { + pool: PgPool, +} + +impl BillingDBPostgresAdapter { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub fn migratable(&self) -> Arc { + Arc::new(Postgres::new(self.pool.clone())) + } +} diff --git a/src/billing/adapters/output/db/postgres/store_id_exists.rs b/src/billing/adapters/output/db/postgres/store_id_exists.rs new file mode 100644 index 0000000..09baa97 --- /dev/null +++ b/src/billing/adapters/output/db/postgres/store_id_exists.rs @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use uuid::Uuid; + +use super::BillingDBPostgresAdapter; +use crate::billing::application::port::output::db::{errors::*, store_id_exists::*}; +use crate::billing::domain::store_aggregate::*; + +#[async_trait::async_trait] +impl StoreIDExistsDBPort for BillingDBPostgresAdapter { + async fn store_id_exists(&self, store_id: &Uuid) -> BillingDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_billing_store_query + WHERE + store_id = $1 + );", + store_id + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +pub mod tests { + use uuid::Uuid; + + use crate::utils::uuid::tests::UUID; + + use super::*; + + pub async fn create_dummy_store_record(s: &Store, db: &BillingDBPostgresAdapter) { + sqlx::query!( + "INSERT INTO cqrs_billing_store_query + (version, name, address, store_id, owner, deleted) + VALUES ($1, $2, $3, $4, $5 ,$6);", + 1, + s.name(), + s.address().as_ref().unwrap(), + s.store_id(), + s.owner(), + false + ) + .execute(&db.pool) + .await + .unwrap(); + } + + #[actix_rt::test] + async fn test_postgres_store_exists() { + let store_id = Uuid::new_v4(); + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::BillingDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let store = StoreBuilder::default() + .name("store_name".into()) + .owner(UUID) + .address(Some("store_address".into())) + .store_id(store_id) + .build() + .unwrap(); + + // state doesn't exist + assert!(!db.store_id_exists(store.store_id()).await.unwrap()); + + create_dummy_store_record(&store, &db).await; + + // state exists + assert!(db.store_id_exists(store.store_id()).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/billing/adapters/output/db/postgres/store_name_exists.rs b/src/billing/adapters/output/db/postgres/store_name_exists.rs new file mode 100644 index 0000000..f78c3f5 --- /dev/null +++ b/src/billing/adapters/output/db/postgres/store_name_exists.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::BillingDBPostgresAdapter; +use crate::billing::application::port::output::db::{errors::*, store_name_exists::*}; +use crate::billing::domain::store_aggregate::*; + +#[async_trait::async_trait] +impl StoreNameExistsDBPort for BillingDBPostgresAdapter { + async fn store_name_exists(&self, s: &Store) -> BillingDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_billing_store_query + WHERE + name = $1 + AND + deleted = false + );", + s.name(), + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use crate::utils::uuid::tests::UUID; + + use super::*; + use crate::billing::adapters::output::db::postgres::store_id_exists::tests::create_dummy_store_record; + + #[actix_rt::test] + async fn test_postgres_store_exists() { + let store_id = Uuid::new_v4(); + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::BillingDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let store = StoreBuilder::default() + .name("store_name".into()) + .owner(UUID) + .address(Some("store_address".into())) + .store_id(store_id) + .build() + .unwrap(); + + // state doesn't exist + assert!(!db.store_name_exists(&store).await.unwrap()); + + create_dummy_store_record(&store, &db).await; + + // state exists + assert!(db.store_name_exists(&store).await.unwrap()); + + // Set store.deleted = true; now db.store_name_exists must return false + sqlx::query!( + "UPDATE cqrs_billing_store_query SET deleted = true WHERE store_id = $1;", + store.store_id() + ) + .execute(&db.pool) + .await + .unwrap(); + assert!(!db.store_name_exists(&store).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/billing/adapters/output/db/postgres/store_view.rs b/src/billing/adapters/output/db/postgres/store_view.rs new file mode 100644 index 0000000..f8ee34f --- /dev/null +++ b/src/billing/adapters/output/db/postgres/store_view.rs @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::persist::{PersistenceError, ViewContext, ViewRepository}; +use cqrs_es::{EventEnvelope, Query, View}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::errors::*; +use super::BillingDBPostgresAdapter; +use crate::billing::domain::events::BillingEvent; +use crate::billing::domain::store_aggregate::Store; +use crate::utils::parse_aggregate_id::parse_aggregate_id; + +pub const NEW_STORE_NON_UUID: &str = "billing_new_store_non_uuid-asdfa"; + +// The view for a Store query, for a standard http application this should +// be designed to reflect the response dto that will be returned to a user. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct StoreView { + name: String, + address: Option, + store_id: Uuid, + owner: Uuid, + deleted: bool, +} + +// This updates the view with events as they are committed. +// The logic should be minimal here, e.g., don't calculate the account balance, +// design the events to carry the balance information instead. +impl View for StoreView { + fn update(&mut self, event: &EventEnvelope) { + if let BillingEvent::StoreAdded(val) = &event.payload { + self.name = val.name().into(); + self.address = val.address().clone(); + self.store_id = *val.store_id(); + self.owner = *val.owner(); + self.deleted = false; + } + } +} + +#[async_trait] +impl ViewRepository for BillingDBPostgresAdapter { + async fn load(&self, store_id: &str) -> Result, PersistenceError> { + let store_id = match parse_aggregate_id(store_id, NEW_STORE_NON_UUID)? { + Some((val, _)) => return Ok(Some(val)), + None => Uuid::parse_str(store_id).unwrap(), + }; + + let res = sqlx::query_as!( + StoreView, + "SELECT + name, address, store_id, owner, deleted + FROM + cqrs_billing_store_query + WHERE + store_id = $1;", + store_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + Ok(Some(res)) + } + + async fn load_with_context( + &self, + store_id: &str, + ) -> Result, PersistenceError> { + let store_id = match parse_aggregate_id(store_id, NEW_STORE_NON_UUID)? { + Some(val) => return Ok(Some(val)), + None => Uuid::parse_str(store_id).unwrap(), + }; + + let res = sqlx::query_as!( + StoreView, + "SELECT + name, address, store_id, owner, deleted + FROM + cqrs_billing_store_query + WHERE + store_id = $1;", + &store_id, + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + struct Context { + version: i64, + store_id: Uuid, + } + + let ctx = sqlx::query_as!( + Context, + "SELECT + store_id, version + FROM + cqrs_billing_store_query + WHERE + store_id = $1;", + store_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + let view_context = ViewContext::new(ctx.store_id.to_string(), ctx.version); + Ok(Some((res, view_context))) + } + + async fn update_view( + &self, + view: StoreView, + context: ViewContext, + ) -> Result<(), PersistenceError> { + match context.version { + 0 => { + let version = context.version + 1; + sqlx::query!( + "INSERT INTO cqrs_billing_store_query ( + version, name, address, store_id, owner, deleted + ) VALUES ( + $1, $2, $3, $4, $5, $6 + );", + version, + view.name, + view.address, + view.store_id, + view.owner, + view.deleted, + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + _ => { + let version = context.version + 1; + sqlx::query!( + "UPDATE + cqrs_billing_store_query + SET + version = $1, + name = $2, + address = $3, + store_id = $4, + owner = $5, + deleted = $6;", + version, + view.name, + view.address, + view.store_id, + view.owner, + view.deleted, + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + } + + Ok(()) + } +} + +pub struct SimpleLoggingQuery {} + +// Our simplest query, this is great for debugging but absolutely useless in production. +// This query just pretty prints the events as they are processed. +#[async_trait] +impl Query for SimpleLoggingQuery { + async fn dispatch(&self, aggregate_id: &str, events: &[EventEnvelope]) { + for event in events { + let payload = serde_json::to_string_pretty(&event.payload).unwrap(); + println!("{}-{}\n{}", aggregate_id, event.sequence, payload); + } + } +} + +#[async_trait] +impl Query for BillingDBPostgresAdapter { + async fn dispatch(&self, store_id: &str, events: &[EventEnvelope]) { + let res = self + .load_with_context(store_id) + .await + .unwrap_or_else(|_| Some((StoreView::default(), ViewContext::new(store_id.into(), 0)))); + let (mut view, view_context): (StoreView, ViewContext) = res.unwrap(); + for event in events { + view.update(event); + } + self.update_view(view, view_context).await.unwrap(); + } +} + +// Our second query, this one will be handled with Postgres `GenericQuery` +// which will serialize and persist our view after it is updated. It also +// provides a `load` method to deserialize the view on request. +//pub type StoreQuery = GenericQuery; +//pub type StoreQuery = Query; + +//#[cfg(test)] +//mod tests { +// use super::*; +// +// use postgres_es::PostgresCqrs; +// +// use crate::{ +// db::migrate::*, +// billing::{ +// application::services::{ +// add_category_service::tests::mock_add_category_service, add_customization_service::tests::mock_add_customization_service, add_line_item_service::tests::mock_add_line_item_service, add_product_service::tests::mock_add_product_service, add_store_service::AddStoreServiceBuilder, update_category_service::tests::mock_update_category_service, update_customization_service::tests::mock_update_customization_service, update_product_service::tests::mock_update_product_service, update_store_service::tests::mock_update_store_service, BillingServicesBuilder +// }, +// domain::{ +// add_category_command::AddCategoryCommand, add_customization_command, +// add_product_command::tests::get_command, add_store_command::AddStoreCommand, +// commands::BillingCommand, +// update_category_command::tests::get_update_category_command, +// update_customization_command::tests::get_update_customization_command, +// update_product_command, update_store_command::tests::get_update_store_cmd, +// }, +// }, +// tests::bdd::IS_NEVER_CALLED, +// utils::{random_string::GenerateRandomStringInterface, uuid::tests::UUID}, +// }; +// use std::sync::Arc; +// +// #[actix_rt::test] +// async fn pg_query() { +// let settings = crate::settings::tests::get_settings().await; +// //let settings = crate::settings::Settings::new().unwrap(); +// settings.create_db().await; +// +// let db = crate::db::sqlx_postgres::Postgres::init(&settings.database.url).await; +// db.migrate().await; +// let db = BillingDBPostgresAdapter::new(db.pool.clone()); +// +// let simple_query = SimpleLoggingQuery {}; +// +// let queries: Vec>> = +// vec![Box::new(simple_query), Box::new(db.clone())]; +// +// let services = BillingServicesBuilder::default() +// .add_store(Arc::new( +// AddStoreServiceBuilder::default() +// .db_store_id_exists(Arc::new(db.clone())) +// .db_store_name_exists(Arc::new(db.clone())) +// .get_uuid(Arc::new(crate::utils::uuid::GenerateUUID {})) +// .build() +// .unwrap(), +// )) +// .add_category(mock_add_category_service( +// IS_NEVER_CALLED, +// AddCategoryCommand::new("foo".into(), None, UUID, UUID).unwrap(), +// )) +// .add_product(mock_add_product_service(IS_NEVER_CALLED, get_command())) +// .add_customization(mock_add_customization_service( +// IS_NEVER_CALLED, +// add_customization_command::tests::get_command(), +// )) +// .update_product(mock_update_product_service( +// IS_NEVER_CALLED, +// update_product_command::tests::get_command(), +// )) +// .update_customization(mock_update_customization_service( +// IS_NEVER_CALLED, +// get_update_customization_command(), +// )) +// .update_category(mock_update_category_service( +// IS_NEVER_CALLED, +// get_update_category_command(), +// )) +// .update_store(mock_update_store_service( +// IS_NEVER_CALLED, +// get_update_store_cmd(), +// )) +// .build() +// .unwrap(); +// +// let (cqrs, _store_query): ( +// Arc>, +// Arc>, +// ) = ( +// Arc::new(postgres_es::postgres_cqrs( +// db.pool.clone(), +// queries, +// Arc::new(services), +// )), +// Arc::new(db.clone()), +// ); +// +// let rand = crate::utils::random_string::GenerateRandomString {}; +// let cmd = AddStoreCommand::new(rand.get_random(10), None, UUID).unwrap(); +// cqrs.execute("", BillingCommand::AddStore(cmd.clone())) +// .await +// .unwrap(); +// +// settings.drop_db().await; +// } +//} diff --git a/src/billing/adapters/output/mod.rs b/src/billing/adapters/output/mod.rs index 56f60de..f895176 100644 --- a/src/billing/adapters/output/mod.rs +++ b/src/billing/adapters/output/mod.rs @@ -1,3 +1,4 @@ // SPDX-FileCopyrightText: 2024 Aravinth Manivannan // // SPDX-License-Identifier: AGPL-3.0-or-later +mod db; diff --git a/src/billing/application/port/output/db/store_id_exists.rs b/src/billing/application/port/output/db/store_id_exists.rs new file mode 100644 index 0000000..974be31 --- /dev/null +++ b/src/billing/application/port/output/db/store_id_exists.rs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; +use uuid::Uuid; + +use crate::billing::domain::store_aggregate::Store; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait StoreIDExistsDBPort: Send + Sync { + async fn store_id_exists(&self, store_id: &Uuid) -> BillingDBResult; +} + +pub type StoreIDExistsDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_store_id_exists_db_port_false(times: Option) -> StoreIDExistsDBPortObj { + let mut m = MockStoreIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_store_id_exists() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_store_id_exists().returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_store_id_exists_db_port_true(times: Option) -> StoreIDExistsDBPortObj { + let mut m = MockStoreIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_store_id_exists() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_store_id_exists().returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/billing/application/port/output/db/store_name_exists.rs b/src/billing/application/port/output/db/store_name_exists.rs new file mode 100644 index 0000000..ef8eefa --- /dev/null +++ b/src/billing/application/port/output/db/store_name_exists.rs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; + +use crate::billing::domain::store_aggregate::Store; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait StoreNameExistsDBPort: Send + Sync { + async fn store_name_exists(&self, s: &Store) -> BillingDBResult; +} + +pub type StoreNameExistsDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_store_name_exists_db_port_false(times: Option) -> StoreNameExistsDBPortObj { + let mut m = MockStoreNameExistsDBPort::new(); + if let Some(times) = times { + m.expect_store_name_exists() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_store_name_exists().returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_store_name_exists_db_port_true(times: Option) -> StoreNameExistsDBPortObj { + let mut m = MockStoreNameExistsDBPort::new(); + if let Some(times) = times { + m.expect_store_name_exists() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_store_name_exists().returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/billing/application/services/add_store_service.rs b/src/billing/application/services/add_store_service.rs new file mode 100644 index 0000000..6fd2c53 --- /dev/null +++ b/src/billing/application/services/add_store_service.rs @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; + +use super::errors::*; +use crate::billing::{ + application::port::output::db::{store_id_exists::*, store_name_exists::*}, + domain::{ + add_store_command::AddStoreCommand, + store_added_event::{StoreAddedEvent, StoreAddedEventBuilder}, + store_aggregate::*, + }, +}; +use crate::utils::uuid::*; + +#[automock] +#[async_trait::async_trait] +pub trait AddStoreUseCase: Send + Sync { + async fn add_store(&self, cmd: AddStoreCommand) -> BillResult; +} + +pub type AddStoreServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct AddStoreService { + db_store_id_exists: StoreIDExistsDBPortObj, + db_store_name_exists: StoreNameExistsDBPortObj, + get_uuid: GetUUIDInterfaceObj, +} + +#[async_trait::async_trait] +impl AddStoreUseCase for AddStoreService { + async fn add_store(&self, cmd: AddStoreCommand) -> BillResult { + let mut store_id = self.get_uuid.get_uuid(); + + loop { + if self.db_store_id_exists.store_id_exists(&store_id).await? { + store_id = self.get_uuid.get_uuid(); + continue; + } else { + break; + } + } + + let store = StoreBuilder::default() + .name(cmd.name().into()) + .address(cmd.address().as_ref().map(|s| s.to_string())) + .owner(*cmd.owner()) + .store_id(store_id) + .build() + .unwrap(); + + if self.db_store_name_exists.store_name_exists(&store).await? { + return Err(BillError::DuplicateStoreName); + } + + Ok(StoreAddedEventBuilder::default() + .name(store.name().into()) + .address(store.address().as_ref().map(|s| s.to_string())) + .owner(*cmd.owner()) + .store_id(store_id) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::tests::bdd::*; + use crate::utils::uuid::tests::*; + + pub fn mock_add_store_service( + times: Option, + cmd: AddStoreCommand, + ) -> AddStoreServiceObj { + let mut m = MockAddStoreUseCase::new(); + + let res = StoreAddedEventBuilder::default() + .name(cmd.name().into()) + .address(cmd.address().as_ref().map(|s| s.to_string())) + .owner(*cmd.owner()) + .store_id(UUID) + .build() + .unwrap(); + + if let Some(times) = times { + m.expect_add_store() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_add_store().returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service_store_id_doesnt_exist() { + let name = "foo"; + let address = "bar"; + let owner = UUID; + + // address = None + let cmd = AddStoreCommand::new(name.into(), Some(address.into()), owner).unwrap(); + + let s = AddStoreServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_store_name_exists(mock_store_name_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.add_store(cmd.clone()).await.unwrap(); + assert_eq!(res.name(), cmd.name()); + assert_eq!(res.address(), cmd.address()); + assert_eq!(res.owner(), cmd.owner()); + assert_eq!(res.store_id(), &UUID); + } + + #[actix_rt::test] + async fn test_service_store_name_exists() { + let name = "foo"; + let address = "bar"; + let owner = UUID; + + // address = None + let cmd = AddStoreCommand::new(name.into(), Some(address.into()), owner).unwrap(); + + let s = AddStoreServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_store_name_exists(mock_store_name_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.add_store(cmd.clone()).await, + Err(BillError::DuplicateStoreName) + ); + } +} diff --git a/src/billing/application/services/update_store_service.rs b/src/billing/application/services/update_store_service.rs new file mode 100644 index 0000000..05d9efa --- /dev/null +++ b/src/billing/application/services/update_store_service.rs @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; + +use super::errors::*; +use crate::billing::{ + application::port::output::db::{store_id_exists::*, store_name_exists::*}, + domain::{ + store_aggregate::*, store_updated_event::*, update_store_command::UpdateStoreCommand, + }, +}; +use crate::utils::uuid::*; + +#[automock] +#[async_trait::async_trait] +pub trait UpdateStoreUseCase: Send + Sync { + async fn update_store(&self, cmd: UpdateStoreCommand) -> BillResult; +} + +pub type UpdateStoreServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct UpdateStoreService { + db_store_id_exists: StoreIDExistsDBPortObj, + db_store_name_exists: StoreNameExistsDBPortObj, +} + +#[async_trait::async_trait] +impl UpdateStoreUseCase for UpdateStoreService { + async fn update_store(&self, cmd: UpdateStoreCommand) -> BillResult { + if !self + .db_store_id_exists + .store_id_exists(cmd.old_store().store_id()) + .await? + { + return Err(BillError::StoreIDNotFound); + } + + let store = StoreBuilder::default() + .name(cmd.name().into()) + .address(cmd.address().as_ref().map(|s| s.to_string())) + .owner(*cmd.owner()) + .store_id(*cmd.old_store().store_id()) + .build() + .unwrap(); + + if cmd.name() != cmd.old_store().name() { + if self.db_store_name_exists.store_name_exists(&store).await? { + return Err(BillError::DuplicateStoreName); + } + } + + Ok(StoreUpdatedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .new_store(store) + .old_store(cmd.old_store().clone()) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::billing::domain::store_updated_event::tests::get_store_updated_event_from_command; + use crate::billing::domain::update_store_command::tests::get_update_store_cmd; + use crate::tests::bdd::*; + use crate::utils::uuid::tests::*; + + pub fn mock_update_store_service( + times: Option, + cmd: UpdateStoreCommand, + ) -> UpdateStoreServiceObj { + let mut m = MockUpdateStoreUseCase::new(); + + let res = get_store_updated_event_from_command(&cmd); + + if let Some(times) = times { + m.expect_update_store() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_update_store().returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service() { + let cmd = get_update_store_cmd(); + + let s = UpdateStoreServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_store_name_exists(mock_store_name_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.update_store(cmd.clone()).await.unwrap(); + assert_eq!(res.new_store().name(), cmd.name()); + assert_eq!(res.new_store().address(), cmd.address()); + assert_eq!(res.new_store().owner(), cmd.owner()); + assert_eq!(res.new_store().store_id(), cmd.old_store().store_id()); + assert_eq!(res.old_store(), cmd.old_store()); + assert_eq!(res.added_by_user(), cmd.adding_by()); + } + + #[actix_rt::test] + async fn test_service_store_name_exists() { + let cmd = get_update_store_cmd(); + + let s = UpdateStoreServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_store_name_exists(mock_store_name_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.update_store(cmd.clone()).await, + Err(BillError::DuplicateStoreName) + ); + } + + #[actix_rt::test] + async fn test_service_store_id_doesnt_exist() { + let cmd = get_update_store_cmd(); + + let s = UpdateStoreServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_store_name_exists(mock_store_name_exists_db_port_false(IS_NEVER_CALLED)) + .build() + .unwrap(); + + assert_eq!( + s.update_store(cmd.clone()).await, + Err(BillError::StoreIDNotFound) + ); + } +} diff --git a/src/billing/domain/add_store_command.rs b/src/billing/domain/add_store_command.rs new file mode 100644 index 0000000..3f11af8 --- /dev/null +++ b/src/billing/domain/add_store_command.rs @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_getters::Getters; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum AddStoreCommandError { + NameIsEmpty, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] +pub struct AddStoreCommand { + name: String, + address: Option, + owner: Uuid, +} + +impl AddStoreCommand { + pub fn new( + name: String, + address: Option, + owner: Uuid, + ) -> Result { + let address: Option = if let Some(address) = address { + let address = address.trim(); + if address.is_empty() { + None + } else { + Some(address.to_owned()) + } + } else { + None + }; + + let name = name.trim().to_owned(); + if name.is_empty() { + return Err(AddStoreCommandError::NameIsEmpty); + } + + Ok(Self { + name, + address, + owner, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::utils::uuid::tests::UUID; + + use super::*; + + #[test] + fn test_cmd() { + let name = "foo"; + let address = "bar"; + let owner = UUID; + + // address = None + let cmd = AddStoreCommand::new(name.into(), None, owner).unwrap(); + assert_eq!(cmd.name(), name); + assert_eq!(cmd.address(), &None); + assert_eq!(cmd.owner(), &owner); + + // address = Some + let cmd = AddStoreCommand::new(name.into(), Some(address.into()), owner).unwrap(); + assert_eq!(cmd.name(), name); + assert_eq!(cmd.address(), &Some(address.to_owned())); + assert_eq!(cmd.owner(), &owner); + + // AddStoreCommandError::NameIsEmpty + assert_eq!( + AddStoreCommand::new("".into(), Some(address.into()), owner), + Err(AddStoreCommandError::NameIsEmpty) + ) + } +} diff --git a/src/billing/domain/store_added_event.rs b/src/billing/domain/store_added_event.rs new file mode 100644 index 0000000..6336da0 --- /dev/null +++ b/src/billing/domain/store_added_event.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct StoreAddedEvent { + name: String, + address: Option, + owner: Uuid, + store_id: Uuid, +} diff --git a/src/billing/domain/store_aggregate.rs b/src/billing/domain/store_aggregate.rs new file mode 100644 index 0000000..cb4d7dd --- /dev/null +++ b/src/billing/domain/store_aggregate.rs @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::Aggregate; +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::billing::application::services::errors::*; +use crate::billing::application::services::BillServicesInterface; + +use super::{commands::BillingCommand, events::BillingEvent}; + +#[derive( + Clone, Default, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Builder, Getters, +)] +pub struct Store { + name: String, + address: Option, + owner: Uuid, + store_id: Uuid, + #[builder(default = "false")] + deleted: bool, +} + +#[async_trait] +impl Aggregate for Store { + type Command = BillingCommand; + type Event = BillingEvent; + type Error = BillError; + type Services = std::sync::Arc; + + // This identifier should be unique to the system. + fn aggregate_type() -> String { + "billing.store".to_string() + } + + // The aggregate logic goes here. Note that this will be the _bulk_ of a CQRS system + // so expect to use helper functions elsewhere to keep the code clean. + async fn handle( + &self, + command: Self::Command, + services: &Self::Services, + ) -> Result, Self::Error> { + match command { + BillingCommand::AddStore(cmd) => { + let res = services.add_store().add_store(cmd).await?; + Ok(vec![BillingEvent::StoreAdded(res)]) + } + BillingCommand::UpdateStore(cmd) => { + let res = services.update_store().update_store(cmd).await?; + Ok(vec![BillingEvent::StoreUpdated(res)]) + } + + _ => Ok(Vec::default()), + } + } + + fn apply(&mut self, event: Self::Event) { + match event { + BillingEvent::StoreAdded(e) => { + self.name = e.name().into(); + self.address = e.address().as_ref().map(|s| s.to_string()); + self.owner = *e.owner(); + self.store_id = *e.store_id(); + self.deleted = false; + } + BillingEvent::StoreUpdated(e) => *self = e.new_store().clone(), + _ => (), + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use cqrs_es::test::TestFramework; + use update_store_service::tests::mock_update_store_service; + + use super::*; + use crate::billing::{ + application::services::{add_store_service::tests::*, *}, + domain::{ + add_store_command::*, commands::BillingCommand, events::BillingEvent, + store_added_event::*, store_updated_event::tests::get_store_updated_event_from_command, + update_store_command::tests::get_update_store_cmd, + }, + }; + use crate::tests::bdd::*; + use crate::utils::uuid::tests::*; + + // A test framework that will apply our events and command + // and verify that the logic works as expected. + type StoreTestFramework = TestFramework; + + #[test] + fn test_create_store() { + let name = "store_name"; + let address = Some("store_address".to_string()); + let owner = UUID; + let store_id = UUID; + + let expected = StoreAddedEventBuilder::default() + .name(name.into()) + .address(address.clone()) + .store_id(store_id) + .owner(owner) + .build() + .unwrap(); + let expected = BillingEvent::StoreAdded(expected); + + let cmd = AddStoreCommand::new(name.into(), address.clone(), owner).unwrap(); + + let mut services = MockBillServicesInterface::new(); + services + .expect_add_store() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_add_store_service(IS_CALLED_ONLY_ONCE, cmd.clone())); + + StoreTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::AddStore(cmd)) + .then_expect_events(vec![expected]); + } + + #[test] + fn test_update_store() { + let cmd = get_update_store_cmd(); + let expected = BillingEvent::StoreUpdated(get_store_updated_event_from_command(&cmd)); + + let mut services = MockBillServicesInterface::new(); + services + .expect_update_store() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_update_store_service(IS_CALLED_ONLY_ONCE, cmd.clone())); + + StoreTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::UpdateStore(cmd)) + .then_expect_events(vec![expected]); + } +} diff --git a/src/billing/domain/store_updated_event.rs b/src/billing/domain/store_updated_event.rs new file mode 100644 index 0000000..de40114 --- /dev/null +++ b/src/billing/domain/store_updated_event.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::store_aggregate::*; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct StoreUpdatedEvent { + added_by_user: Uuid, + old_store: Store, + new_store: Store, +} + +#[cfg(test)] +pub mod tests { + use crate::billing::domain::update_store_command::UpdateStoreCommand; + + use super::*; + + pub fn get_store_updated_event_from_command(cmd: &UpdateStoreCommand) -> StoreUpdatedEvent { + let new_store = StoreBuilder::default() + .name(cmd.name().into()) + .address(cmd.address().as_ref().map(|s| s.to_string())) + .owner(*cmd.owner()) + .store_id(*cmd.old_store().store_id()) + .build() + .unwrap(); + + StoreUpdatedEventBuilder::default() + .new_store(new_store) + .old_store(cmd.old_store().clone()) + .added_by_user(*cmd.adding_by()) + .build() + .unwrap() + } +} diff --git a/src/billing/domain/update_store_command.rs b/src/billing/domain/update_store_command.rs new file mode 100644 index 0000000..53c2e2c --- /dev/null +++ b/src/billing/domain/update_store_command.rs @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_getters::Getters; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::store_aggregate::*; + +#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum UpdateStoreCommandError { + NameIsEmpty, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] +pub struct UpdateStoreCommand { + name: String, + address: Option, + owner: Uuid, + old_store: Store, + adding_by: Uuid, +} + +impl UpdateStoreCommand { + pub fn new( + name: String, + address: Option, + owner: Uuid, + old_store: Store, + adding_by: Uuid, + ) -> Result { + let address: Option = if let Some(address) = address { + let address = address.trim(); + if address.is_empty() { + None + } else { + Some(address.to_owned()) + } + } else { + None + }; + + let name = name.trim().to_owned(); + if name.is_empty() { + return Err(UpdateStoreCommandError::NameIsEmpty); + } + + Ok(Self { + name, + address, + owner, + old_store, + adding_by, + }) + } +} + +#[cfg(test)] +pub mod tests { + use crate::utils::uuid::tests::UUID; + + use super::*; + + pub fn get_update_store_cmd() -> UpdateStoreCommand { + let name = "foo"; + let address = "bar"; + let owner = UUID; + let adding_by = UUID; + let old_store = Store::default(); + + UpdateStoreCommand::new( + name.into(), + Some(address.into()), + owner, + old_store.clone(), + adding_by, + ) + .unwrap() + } + + #[test] + fn test_cmd() { + let name = "foo"; + let address = "bar"; + let owner = UUID; + let old_store = Store::default(); + let adding_by = Uuid::new_v4(); + + // address = None + let cmd = UpdateStoreCommand::new(name.into(), None, owner, old_store.clone(), adding_by) + .unwrap(); + assert_eq!(cmd.name(), name); + assert_eq!(cmd.address(), &None); + assert_eq!(cmd.owner(), &owner); + assert_eq!(cmd.old_store(), &old_store); + assert_eq!(cmd.adding_by(), &adding_by); + + // address = Some + let cmd = UpdateStoreCommand::new( + name.into(), + Some(address.into()), + owner, + old_store.clone(), + adding_by, + ) + .unwrap(); + assert_eq!(cmd.name(), name); + assert_eq!(cmd.address(), &Some(address.to_owned())); + assert_eq!(cmd.owner(), &owner); + assert_eq!(cmd.old_store(), &old_store); + assert_eq!(cmd.adding_by(), &adding_by); + + // UpdateStoreCommandError::NameIsEmpty + assert_eq!( + UpdateStoreCommand::new( + "".into(), + Some(address.into()), + owner, + old_store.clone(), + adding_by + ), + Err(UpdateStoreCommandError::NameIsEmpty) + ) + } +} From 1c64b62d5b8866daf50206300a2862eef47b19bb Mon Sep 17 00:00:00 2001 From: Aravinth Manivannan Date: Tue, 17 Sep 2024 14:20:15 +0530 Subject: [PATCH 2/3] feat: import line_item aggregate from inventory into billing domain --- ...11bf302a3750ecadf28b1d6af62da62934f87.json | 27 ++ ...ac7ca9cf88099aa273972271040852794caa5.json | 94 +++++ ...6d6a2c9e9b1e49258b38ed1dbebf2f601e99e.json | 27 ++ ...1258adf1c319543da07a8d79de67394b83eeb.json | 28 ++ ...e229e775326bf554e935b688585d0be253934.json | 22 ++ ...917082053_cqrs_billing_line_item_query.sql | 29 ++ .../output/db/postgres/line_item_id_exists.rs | 109 ++++++ .../output/db/postgres/line_item_view.rs | 347 ++++++++++++++++++ .../adapters/output/db/postgres/mod.rs | 2 + .../services/add_line_item_service.rs | 141 +++++++ .../services/delete_line_item_service.rs | 135 +++++++ src/billing/application/services/errors.rs | 43 +++ .../services/update_line_item_service.rs | 161 ++++++++ src/billing/domain/add_line_item_command.rs | 174 +++++++++ .../domain/delete_line_item_command.rs | 37 ++ src/billing/domain/line_item_added_event.rs | 41 +++ src/billing/domain/line_item_aggregate.rs | 219 +++++++++++ src/billing/domain/line_item_deleted_event.rs | 54 +++ src/billing/domain/line_item_updated_event.rs | 54 +++ .../domain/update_line_item_command.rs | 185 ++++++++++ 20 files changed, 1929 insertions(+) create mode 100644 .sqlx/query-0268f0c43abe34a3147f0a43f0e11bf302a3750ecadf28b1d6af62da62934f87.json create mode 100644 .sqlx/query-0d6ba3039f3419dd34a609ae207ac7ca9cf88099aa273972271040852794caa5.json create mode 100644 .sqlx/query-6ac0c3c64749d1eb4a93f8727b56d6a2c9e9b1e49258b38ed1dbebf2f601e99e.json create mode 100644 .sqlx/query-a80999e0bed9e7535187b20eb501258adf1c319543da07a8d79de67394b83eeb.json create mode 100644 .sqlx/query-c1308bfe8d0d602e154a7d02df7e229e775326bf554e935b688585d0be253934.json create mode 100644 migrations/20240917082053_cqrs_billing_line_item_query.sql create mode 100644 src/billing/adapters/output/db/postgres/line_item_id_exists.rs create mode 100644 src/billing/adapters/output/db/postgres/line_item_view.rs create mode 100644 src/billing/application/services/add_line_item_service.rs create mode 100644 src/billing/application/services/delete_line_item_service.rs create mode 100644 src/billing/application/services/errors.rs create mode 100644 src/billing/application/services/update_line_item_service.rs create mode 100644 src/billing/domain/add_line_item_command.rs create mode 100644 src/billing/domain/delete_line_item_command.rs create mode 100644 src/billing/domain/line_item_added_event.rs create mode 100644 src/billing/domain/line_item_aggregate.rs create mode 100644 src/billing/domain/line_item_deleted_event.rs create mode 100644 src/billing/domain/line_item_updated_event.rs create mode 100644 src/billing/domain/update_line_item_command.rs diff --git a/.sqlx/query-0268f0c43abe34a3147f0a43f0e11bf302a3750ecadf28b1d6af62da62934f87.json b/.sqlx/query-0268f0c43abe34a3147f0a43f0e11bf302a3750ecadf28b1d6af62da62934f87.json new file mode 100644 index 0000000..7e16644 --- /dev/null +++ b/.sqlx/query-0268f0c43abe34a3147f0a43f0e11bf302a3750ecadf28b1d6af62da62934f87.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n cqrs_billing_line_item_query\n SET\n version = $1,\n product_name = $2,\n product_id = $3,\n line_item_id = $4,\n quantity_minor_unit = $5,\n quantity_minor_number = $6,\n quantity_major_unit = $7,\n quantity_major_number = $8,\n created_time = $9,\n bill_id = $10,\n price_per_unit_minor = $11 ,\n price_per_unit_major = $12,\n price_per_unit_currency = $13,\n deleted = $14;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Uuid", + "Uuid", + "Text", + "Int4", + "Text", + "Int4", + "Timestamptz", + "Uuid", + "Int4", + "Int4", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "0268f0c43abe34a3147f0a43f0e11bf302a3750ecadf28b1d6af62da62934f87" +} diff --git a/.sqlx/query-0d6ba3039f3419dd34a609ae207ac7ca9cf88099aa273972271040852794caa5.json b/.sqlx/query-0d6ba3039f3419dd34a609ae207ac7ca9cf88099aa273972271040852794caa5.json new file mode 100644 index 0000000..db49d27 --- /dev/null +++ b/.sqlx/query-0d6ba3039f3419dd34a609ae207ac7ca9cf88099aa273972271040852794caa5.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n product_name,\n product_id,\n line_item_id,\n quantity_minor_unit,\n quantity_minor_number,\n quantity_major_unit,\n quantity_major_number,\n created_time,\n bill_id,\n price_per_unit_minor,\n price_per_unit_major,\n price_per_unit_currency,\n deleted\n FROM\n cqrs_billing_line_item_query\n WHERE\n line_item_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "product_name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "product_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "line_item_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "quantity_minor_unit", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "quantity_minor_number", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "quantity_major_unit", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "quantity_major_number", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "bill_id", + "type_info": "Uuid" + }, + { + "ordinal": 9, + "name": "price_per_unit_minor", + "type_info": "Int4" + }, + { + "ordinal": 10, + "name": "price_per_unit_major", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "price_per_unit_currency", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "deleted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "0d6ba3039f3419dd34a609ae207ac7ca9cf88099aa273972271040852794caa5" +} diff --git a/.sqlx/query-6ac0c3c64749d1eb4a93f8727b56d6a2c9e9b1e49258b38ed1dbebf2f601e99e.json b/.sqlx/query-6ac0c3c64749d1eb4a93f8727b56d6a2c9e9b1e49258b38ed1dbebf2f601e99e.json new file mode 100644 index 0000000..f22d97a --- /dev/null +++ b/.sqlx/query-6ac0c3c64749d1eb4a93f8727b56d6a2c9e9b1e49258b38ed1dbebf2f601e99e.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO cqrs_billing_line_item_query (\n version,\n product_name,\n product_id,\n line_item_id,\n quantity_minor_unit,\n quantity_minor_number,\n quantity_major_unit,\n quantity_major_number,\n created_time,\n bill_id,\n price_per_unit_minor,\n price_per_unit_major,\n price_per_unit_currency,\n deleted\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14\n );", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Uuid", + "Uuid", + "Text", + "Int4", + "Text", + "Int4", + "Timestamptz", + "Uuid", + "Int4", + "Int4", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "6ac0c3c64749d1eb4a93f8727b56d6a2c9e9b1e49258b38ed1dbebf2f601e99e" +} diff --git a/.sqlx/query-a80999e0bed9e7535187b20eb501258adf1c319543da07a8d79de67394b83eeb.json b/.sqlx/query-a80999e0bed9e7535187b20eb501258adf1c319543da07a8d79de67394b83eeb.json new file mode 100644 index 0000000..5b4c886 --- /dev/null +++ b/.sqlx/query-a80999e0bed9e7535187b20eb501258adf1c319543da07a8d79de67394b83eeb.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n line_item_id, version\n FROM\n cqrs_billing_line_item_query\n WHERE\n line_item_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "line_item_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a80999e0bed9e7535187b20eb501258adf1c319543da07a8d79de67394b83eeb" +} diff --git a/.sqlx/query-c1308bfe8d0d602e154a7d02df7e229e775326bf554e935b688585d0be253934.json b/.sqlx/query-c1308bfe8d0d602e154a7d02df7e229e775326bf554e935b688585d0be253934.json new file mode 100644 index 0000000..fc5f6cd --- /dev/null +++ b/.sqlx/query-c1308bfe8d0d602e154a7d02df7e229e775326bf554e935b688585d0be253934.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_billing_line_item_query\n WHERE\n line_item_id = $1\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c1308bfe8d0d602e154a7d02df7e229e775326bf554e935b688585d0be253934" +} diff --git a/migrations/20240917082053_cqrs_billing_line_item_query.sql b/migrations/20240917082053_cqrs_billing_line_item_query.sql new file mode 100644 index 0000000..05cce30 --- /dev/null +++ b/migrations/20240917082053_cqrs_billing_line_item_query.sql @@ -0,0 +1,29 @@ +-- SPDX-FileCopyrightText: 2024 Aravinth Manivannan +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +CREATE TABLE IF NOT EXISTS cqrs_billing_line_item_query +( + version bigint CHECK (version >= 0) NOT NULL, + + created_time timestamp with time zone DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + line_item_id UUID NOT NULL UNIQUE, + + bill_id UUID NOT NULL, + + product_name TEXT NOT NULL, + product_id UUID NOT NULL, + + quantity_major_number INTEGER NOT NULL, + quantity_minor_number INTEGER NOT NULL, + quantity_major_unit TEXT NOT NULL, + quantity_minor_unit TEXT NOT NULL, + + price_per_unit_minor INTEGER NOT NULL, + price_per_unit_major INTEGER NOT NULL, + price_per_unit_currency TEXT NOT NULL, + + deleted BOOLEAN NOT NULL DEFAULT FALSE, + + PRIMARY KEY (line_item_id) +); diff --git a/src/billing/adapters/output/db/postgres/line_item_id_exists.rs b/src/billing/adapters/output/db/postgres/line_item_id_exists.rs new file mode 100644 index 0000000..9fdac76 --- /dev/null +++ b/src/billing/adapters/output/db/postgres/line_item_id_exists.rs @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use uuid::Uuid; + +use super::BillingDBPostgresAdapter; +use crate::billing::application::port::output::db::{errors::*, line_item_id_exists::*}; + +#[async_trait::async_trait] +impl LineItemIDExistsDBPort for BillingDBPostgresAdapter { + async fn line_item_id_exists(&self, line_item_id: &Uuid) -> BillingDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_billing_line_item_query + WHERE + line_item_id = $1 + );", + line_item_id + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +pub mod tests { + + use super::*; + // use crate::billing::domain::add_product_command::tests::get_customizations; + use crate::billing::domain::line_item_aggregate::*; + + async fn create_dummy_line_item(line_item: &LineItem, db: &BillingDBPostgresAdapter) { + sqlx::query!( + "INSERT INTO cqrs_billing_line_item_query ( + version, + product_name, + product_id, + line_item_id, + quantity_minor_unit, + quantity_minor_number, + quantity_major_unit, + quantity_major_number, + + bill_id, + + price_per_unit_minor, + price_per_unit_major, + price_per_unit_currency, + + + deleted + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + );", + 1, + line_item.product_name(), + line_item.product_id(), + line_item.line_item_id(), + line_item.quantity().major().unit().to_string(), + line_item.quantity().major().number().clone() as i32, + line_item.quantity().minor().unit().to_string(), + line_item.quantity().minor().number().clone() as i32, + line_item.bill_id(), + *line_item.price_per_unit().minor() as i32, + *line_item.price_per_unit().major() as i32, + line_item.price_per_unit().currency().to_string(), + line_item.deleted().clone(), + ) + .execute(&db.pool) + .await + .unwrap(); + } + + #[actix_rt::test] + async fn test_postgres_product_exists() { + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::BillingDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let line_item = LineItem::default(); + + // state doesn't exist + assert!(!db + .line_item_id_exists(line_item.line_item_id()) + .await + .unwrap()); + + create_dummy_line_item(&line_item, &db).await; + + // state exists + assert!(db + .line_item_id_exists(line_item.product_id()) + .await + .unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/billing/adapters/output/db/postgres/line_item_view.rs b/src/billing/adapters/output/db/postgres/line_item_view.rs new file mode 100644 index 0000000..91d91bb --- /dev/null +++ b/src/billing/adapters/output/db/postgres/line_item_view.rs @@ -0,0 +1,347 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::str::FromStr; + +use async_trait::async_trait; +use cqrs_es::persist::{PersistenceError, ViewContext, ViewRepository}; +use cqrs_es::{EventEnvelope, Query, View}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use super::errors::*; +use super::BillingDBPostgresAdapter; +use crate::billing::domain::events::BillingEvent; +use crate::billing::domain::line_item_aggregate::*; +use crate::types::currency::*; +use crate::types::quantity::*; +use crate::utils::parse_aggregate_id::parse_aggregate_id; + +pub const NEW_LINE_ITEM_NON_UUID: &str = "new_line_item_non_uuid-asdfa-billing"; + +// The view for a LineItem query, for a standard http application this should +// be designed to reflect the response dto that will be returned to a user. +#[derive(Debug, Serialize, Deserialize)] +pub struct LineItemView { + product_name: String, + product_id: Uuid, + bill_id: Uuid, + created_time: OffsetDateTime, + + line_item_id: Uuid, + + quantity_major_number: i32, + quantity_minor_number: i32, + quantity_major_unit: String, + quantity_minor_unit: String, + + price_per_unit_major: i32, + price_per_unit_minor: i32, + price_per_unit_currency: String, + + deleted: bool, +} + +impl Default for LineItemView { + fn default() -> Self { + Self { + created_time: OffsetDateTime::now_utc(), + product_name: Default::default(), + product_id: Default::default(), + bill_id: Default::default(), + + line_item_id: Default::default(), + + price_per_unit_minor: Default::default(), + price_per_unit_major: Default::default(), + price_per_unit_currency: Default::default(), + + quantity_major_number: Default::default(), + quantity_minor_number: Default::default(), + quantity_major_unit: Default::default(), + quantity_minor_unit: Default::default(), + deleted: false, + } + } +} + +impl From for LineItem { + fn from(v: LineItemView) -> Self { + let quantity = QuantityBuilder::default() + .minor( + QuantityPartBuilder::default() + .number(v.quantity_minor_number as usize) + .unit(QuantityUnit::from_str(&v.quantity_minor_unit).unwrap()) + .build() + .unwrap(), + ) + .major( + QuantityPartBuilder::default() + .number(v.quantity_major_number as usize) + .unit(QuantityUnit::from_str(&v.quantity_major_unit).unwrap()) + .build() + .unwrap(), + ) + .build() + .unwrap(); + + let price_per_unit = PriceBuilder::default() + .minor(v.price_per_unit_minor as usize) + .major(v.price_per_unit_major as usize) + .currency(Currency::from_str(&v.price_per_unit_currency).unwrap()) + .build() + .unwrap(); + + LineItemBuilder::default() + .product_name(v.product_name) + .line_item_id(v.line_item_id) + .quantity(quantity) + .created_time(v.created_time) + .product_id(v.product_id) + .price_per_unit(price_per_unit) + .bill_id(v.bill_id) + .deleted(v.deleted) + .build() + .unwrap() + } +} + +// This updates the view with events as they are committed. +// The logic should be minimal here, e.g., don't calculate the account balance, +// design the events to carry the balance information instead. +impl View for LineItemView { + fn update(&mut self, event: &EventEnvelope) { + match &event.payload { + BillingEvent::LineItemAdded(val) => { + self.product_name = val.line_item().product_name().into(); + self.product_id = *val.line_item().product_id(); + self.line_item_id = *val.line_item().line_item_id(); + + self.quantity_major_number = *val.line_item().quantity().major().number() as i32; + self.quantity_minor_number = *val.line_item().quantity().minor().number() as i32; + self.quantity_major_unit = val.line_item().quantity().major().unit().to_string(); + self.quantity_minor_unit = val.line_item().quantity().minor().unit().to_string(); + + self.price_per_unit_major = *val.line_item().price_per_unit().major() as i32; + self.price_per_unit_minor = *val.line_item().price_per_unit().minor() as i32; + self.price_per_unit_currency = + val.line_item().price_per_unit().currency().to_string(); + + self.created_time = val.line_item().created_time().clone(); + self.bill_id = *val.line_item().bill_id(); + + self.deleted = false; + } + _ => (), + } + } +} + +#[async_trait] +impl ViewRepository for BillingDBPostgresAdapter { + async fn load(&self, line_item_id: &str) -> Result, PersistenceError> { + let line_item_id = match parse_aggregate_id(line_item_id, NEW_LINE_ITEM_NON_UUID)? { + Some((val, _)) => return Ok(Some(val)), + None => Uuid::parse_str(line_item_id).unwrap(), + }; + + let res = sqlx::query_as!( + LineItemView, + "SELECT + product_name, + product_id, + line_item_id, + quantity_minor_unit, + quantity_minor_number, + quantity_major_unit, + quantity_major_number, + created_time, + bill_id, + price_per_unit_minor, + price_per_unit_major, + price_per_unit_currency, + deleted + FROM + cqrs_billing_line_item_query + WHERE + line_item_id = $1;", + line_item_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + Ok(Some(res)) + } + + async fn load_with_context( + &self, + line_item_id: &str, + ) -> Result, PersistenceError> { + let line_item_id = match parse_aggregate_id(line_item_id, NEW_LINE_ITEM_NON_UUID)? { + Some(val) => return Ok(Some(val)), + None => Uuid::parse_str(line_item_id).unwrap(), + }; + + let res = sqlx::query_as!( + LineItemView, + "SELECT + product_name, + product_id, + line_item_id, + quantity_minor_unit, + quantity_minor_number, + quantity_major_unit, + quantity_major_number, + created_time, + bill_id, + price_per_unit_minor, + price_per_unit_major, + price_per_unit_currency, + deleted + FROM + cqrs_billing_line_item_query + WHERE + line_item_id = $1;", + line_item_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + struct Context { + version: i64, + line_item_id: Uuid, + } + + let ctx = sqlx::query_as!( + Context, + "SELECT + line_item_id, version + FROM + cqrs_billing_line_item_query + WHERE + line_item_id = $1;", + line_item_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + let view_context = ViewContext::new(ctx.line_item_id.to_string(), ctx.version); + Ok(Some((res, view_context))) + } + + async fn update_view( + &self, + view: LineItemView, + context: ViewContext, + ) -> Result<(), PersistenceError> { + match context.version { + 0 => { + let version = context.version + 1; + sqlx::query!( + "INSERT INTO cqrs_billing_line_item_query ( + version, + product_name, + product_id, + line_item_id, + quantity_minor_unit, + quantity_minor_number, + quantity_major_unit, + quantity_major_number, + created_time, + bill_id, + price_per_unit_minor, + price_per_unit_major, + price_per_unit_currency, + deleted + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 + );", + version, + view.product_name, + view.product_id, + view.line_item_id, + view.quantity_minor_unit, + view.quantity_minor_number, + view.quantity_major_unit, + view.quantity_major_number, + view.created_time, + view.bill_id, + view.price_per_unit_minor, + view.price_per_unit_major, + view.price_per_unit_currency, + view.deleted, + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + + _ => { + let version = context.version + 1; + sqlx::query!( + "UPDATE + cqrs_billing_line_item_query + SET + version = $1, + product_name = $2, + product_id = $3, + line_item_id = $4, + quantity_minor_unit = $5, + quantity_minor_number = $6, + quantity_major_unit = $7, + quantity_major_number = $8, + created_time = $9, + bill_id = $10, + price_per_unit_minor = $11 , + price_per_unit_major = $12, + price_per_unit_currency = $13, + deleted = $14;", + version, + view.product_name, + view.product_id, + view.line_item_id, + view.quantity_minor_unit, + view.quantity_minor_number, + view.quantity_major_unit, + view.quantity_major_number, + view.created_time, + view.bill_id, + view.price_per_unit_minor, + view.price_per_unit_major, + view.price_per_unit_currency, + view.deleted, + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + } + + Ok(()) + } +} + +#[async_trait] +impl Query for BillingDBPostgresAdapter { + async fn dispatch(&self, line_item_id: &str, events: &[EventEnvelope]) { + let res = self + .load_with_context(line_item_id) + .await + .unwrap_or_else(|_| { + Some(( + LineItemView::default(), + ViewContext::new(line_item_id.into(), 0), + )) + }); + let (mut view, view_context): (LineItemView, ViewContext) = res.unwrap(); + for event in events { + view.update(event); + } + self.update_view(view, view_context).await.unwrap(); + } +} diff --git a/src/billing/adapters/output/db/postgres/mod.rs b/src/billing/adapters/output/db/postgres/mod.rs index d816ff5..2f5961e 100644 --- a/src/billing/adapters/output/db/postgres/mod.rs +++ b/src/billing/adapters/output/db/postgres/mod.rs @@ -8,6 +8,8 @@ use sqlx::postgres::PgPool; use crate::db::{migrate::RunMigrations, sqlx_postgres::Postgres}; mod errors; +mod line_item_id_exists; +mod line_item_view; mod store_id_exists; mod store_name_exists; mod store_view; diff --git a/src/billing/application/services/add_line_item_service.rs b/src/billing/application/services/add_line_item_service.rs new file mode 100644 index 0000000..4d3e268 --- /dev/null +++ b/src/billing/application/services/add_line_item_service.rs @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; +use time::OffsetDateTime; + +use super::errors::*; +use crate::billing::{ + application::port::output::db::bill_id_exists::*, + application::port::output::db::line_item_id_exists::*, + domain::{add_line_item_command::*, line_item_added_event::*, line_item_aggregate::*}, +}; +use crate::utils::uuid::*; + +#[automock] +#[async_trait::async_trait] +pub trait AddLineItemUseCase: Send + Sync { + async fn add_line_item(&self, cmd: AddLineItemCommand) -> BillingResult; +} + +pub type AddLineItemServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct AddLineItemService { + db_line_item_id_exists: LineItemIDExistsDBPortObj, + db_bill_id_exists: BillIDExistsDBPortObj, + get_uuid: GetUUIDInterfaceObj, +} + +#[async_trait::async_trait] +impl AddLineItemUseCase for AddLineItemService { + async fn add_line_item(&self, cmd: AddLineItemCommand) -> BillingResult { + if !self.db_bill_id_exists.bill_id_exists(cmd.bill_id()).await? { + return Err(BillingError::BillIDNotFound); + } + + let mut line_item_id = self.get_uuid.get_uuid(); + + loop { + if self + .db_line_item_id_exists + .line_item_id_exists(&line_item_id) + .await? + { + line_item_id = self.get_uuid.get_uuid(); + continue; + } else { + break; + } + } + + let line_item = LineItemBuilder::default() + .created_time(cmd.created_time().clone()) + .product_name(cmd.product_name().into()) + .product_id(*cmd.product_id()) + .bill_id(*cmd.bill_id()) + .line_item_id(line_item_id) + .quantity(cmd.quantity().clone()) + .price_per_unit(cmd.price_per_unit().clone()) + .deleted(false) + .build() + .unwrap(); + + Ok(LineItemAddedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .line_item(line_item) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::billing::domain::line_item_added_event::tests::get_added_line_item_event_from_command; + use crate::utils::uuid::tests::UUID; + use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; + + pub fn mock_add_line_item_service( + times: Option, + cmd: AddLineItemCommand, + ) -> AddLineItemServiceObj { + let mut m = MockAddLineItemUseCase::new(); + + let res = get_added_line_item_event_from_command(&cmd); + if let Some(times) = times { + m.expect_add_line_item() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_add_line_item().returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service() { + let cmd = AddLineItemCommand::get_cmd(); + + let s = AddLineItemServiceBuilder::default() + .db_line_item_id_exists(mock_line_item_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_bill_id_exists(mock_bill_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.add_line_item(cmd.clone()).await.unwrap(); + assert_eq!(res.line_item().product_name(), cmd.product_name()); + assert_eq!(res.line_item().product_id(), cmd.product_id()); + assert_eq!(res.line_item().bill_id(), cmd.bill_id()); + assert_eq!(res.line_item().quantity(), cmd.quantity()); + assert_eq!(res.line_item().quantity(), cmd.quantity()); + assert_eq!(res.line_item().created_time(), cmd.created_time()); + assert!(!res.line_item().deleted()); + assert_eq!(res.added_by_user(), cmd.adding_by()); + } + + #[actix_rt::test] + async fn test_service_bill_id_doesnt_exist() { + let cmd = AddLineItemCommand::get_cmd(); + + let s = AddLineItemServiceBuilder::default() + .db_line_item_id_exists(mock_line_item_id_exists_db_port_false(IS_NEVER_CALLED)) + .db_bill_id_exists(mock_bill_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .get_uuid(mock_get_uuid(IS_NEVER_CALLED)) + .build() + .unwrap(); + + assert_eq!( + s.add_line_item(cmd.clone()).await, + Err(BillingError::BillIDNotFound) + ); + } +} diff --git a/src/billing/application/services/delete_line_item_service.rs b/src/billing/application/services/delete_line_item_service.rs new file mode 100644 index 0000000..f0c3bd9 --- /dev/null +++ b/src/billing/application/services/delete_line_item_service.rs @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; +use time::OffsetDateTime; + +use super::errors::*; +use crate::billing::{ + application::port::output::db::line_item_id_exists::*, + domain::{delete_line_item_command::*, line_item_aggregate::*, line_item_deleted_event::*}, +}; +use crate::utils::uuid::*; + +#[automock] +#[async_trait::async_trait] +pub trait DeleteLineItemUseCase: Send + Sync { + async fn delete_line_item( + &self, + cmd: DeleteLineItemCommand, + ) -> BillingResult; +} + +pub type DeleteLineItemServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct DeleteLineItemService { + db_line_item_id_exists: LineItemIDExistsDBPortObj, +} + +#[async_trait::async_trait] +impl DeleteLineItemUseCase for DeleteLineItemService { + async fn delete_line_item( + &self, + cmd: DeleteLineItemCommand, + ) -> BillingResult { + if !self + .db_line_item_id_exists + .line_item_id_exists(cmd.line_item().line_item_id()) + .await? + { + return Err(BillingError::LineItemIDNotFound); + } + + let deleted_line_item = LineItemBuilder::default() + .created_time(cmd.line_item().created_time().clone()) + .product_name(cmd.line_item().product_name().into()) + .product_id(*cmd.line_item().product_id()) + .line_item_id(*cmd.line_item().line_item_id()) + .bill_id(*cmd.line_item().bill_id()) + .quantity(cmd.line_item().quantity().clone()) + .price_per_unit(cmd.line_item().price_per_unit().clone()) + .deleted(true) + .build() + .unwrap(); + + Ok(LineItemDeletedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .line_item(deleted_line_item) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::billing::domain::line_item_deleted_event::tests::get_deleted_line_item_event_from_command; + use crate::tests::bdd::*; + + pub fn mock_delete_line_item_service( + times: Option, + cmd: DeleteLineItemCommand, + ) -> DeleteLineItemServiceObj { + let mut m = MockDeleteLineItemUseCase::new(); + + let res = get_deleted_line_item_event_from_command(&cmd); + if let Some(times) = times { + m.expect_delete_line_item() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_delete_line_item() + .returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service() { + let cmd = DeleteLineItemCommand::get_cmd(); + + let s = DeleteLineItemServiceBuilder::default() + .db_line_item_id_exists(mock_line_item_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.delete_line_item(cmd.clone()).await.unwrap(); + assert_eq!( + res.line_item().product_name(), + cmd.line_item().product_name() + ); + assert_eq!(res.line_item().product_id(), cmd.line_item().product_id()); + assert_eq!(res.line_item().quantity(), cmd.line_item().quantity()); + assert_eq!( + res.line_item().line_item_id(), + cmd.line_item().line_item_id() + ); + assert_eq!(res.line_item().bill_id(), cmd.line_item().bill_id()); + assert!(res.line_item().deleted()); + + assert_eq!(res.added_by_user(), cmd.adding_by()); + } + + #[actix_rt::test] + async fn test_service_line_item_id_doesnt_exist() { + let cmd = DeleteLineItemCommand::get_cmd(); + + let s = DeleteLineItemServiceBuilder::default() + .db_line_item_id_exists(mock_line_item_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.delete_line_item(cmd.clone()).await, + Err(BillingError::LineItemIDNotFound) + ); + } +} diff --git a/src/billing/application/services/errors.rs b/src/billing/application/services/errors.rs new file mode 100644 index 0000000..ea767a5 --- /dev/null +++ b/src/billing/application/services/errors.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_more::{Display, Error}; +use log::error; +use serde::{Deserialize, Serialize}; + +use crate::billing::application::port::output::db::errors::BillingDBError; + +pub type BillingResult = Result; + +#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum BillingError { + BillIDNotFound, + InternalError, + DuplicateStoreName, + StoreIDNotFound, + LineItemIDNotFound, +} + +impl From for BillingError { + fn from(value: BillingDBError) -> Self { + match value { + BillingDBError::DuplicateBillID => { + error!("DuplicateBillID"); + Self::InternalError + } + BillingDBError::DuplicateStoreName => Self::DuplicateStoreName, + BillingDBError::DuplicateStoreID => { + error!("DuplicateStoreID"); + Self::InternalError + } + BillingDBError::StoreIDNotFound => BillingError::StoreIDNotFound, + BillingDBError::InternalError => BillingError::InternalError, + BillingDBError::DuplicateLineItemID => { + error!("DuplicateLineItemID"); + Self::InternalError + } + BillingDBError::LineItemIDNotFound => BillingError::LineItemIDNotFound, + } + } +} diff --git a/src/billing/application/services/update_line_item_service.rs b/src/billing/application/services/update_line_item_service.rs new file mode 100644 index 0000000..e885835 --- /dev/null +++ b/src/billing/application/services/update_line_item_service.rs @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; +use time::OffsetDateTime; + +use super::errors::*; +use crate::billing::{ + application::port::output::db::bill_id_exists::*, + application::port::output::db::line_item_id_exists::*, + domain::{line_item_aggregate::*, line_item_updated_event::*, update_line_item_command::*}, +}; +use crate::utils::uuid::*; + +#[automock] +#[async_trait::async_trait] +pub trait UpdateLineItemUseCase: Send + Sync { + async fn update_line_item( + &self, + cmd: UpdateLineItemCommand, + ) -> BillingResult; +} + +pub type UpdateLineItemServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct UpdateLineItemService { + db_line_item_id_exists: LineItemIDExistsDBPortObj, + db_bill_id_exists: BillIDExistsDBPortObj, +} + +#[async_trait::async_trait] +impl UpdateLineItemUseCase for UpdateLineItemService { + async fn update_line_item( + &self, + cmd: UpdateLineItemCommand, + ) -> BillingResult { + if !self + .db_line_item_id_exists + .line_item_id_exists(cmd.old_line_item().line_item_id()) + .await? + { + return Err(BillingError::LineItemIDNotFound); + } + + if !self.db_bill_id_exists.bill_id_exists(cmd.bill_id()).await? { + return Err(BillingError::BillIDNotFound); + } + + let new_line_item = LineItemBuilder::default() + .created_time(cmd.created_time().clone()) + .product_name(cmd.product_name().into()) + .product_id(*cmd.product_id()) + .line_item_id(*cmd.old_line_item().line_item_id()) + .bill_id(*cmd.bill_id()) + .quantity(cmd.quantity().clone()) + .price_per_unit(cmd.price_per_unit().clone()) + .deleted(false) + .build() + .unwrap(); + + Ok(LineItemUpdatedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .new_line_item(new_line_item) + .old_line_item(cmd.old_line_item().clone()) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::billing::domain::line_item_updated_event::tests::get_updated_line_item_event_from_command; + use crate::utils::uuid::tests::UUID; + use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; + + pub fn mock_update_line_item_service( + times: Option, + cmd: UpdateLineItemCommand, + ) -> UpdateLineItemServiceObj { + let mut m = MockUpdateLineItemUseCase::new(); + + let res = get_updated_line_item_event_from_command(&cmd); + if let Some(times) = times { + m.expect_update_line_item() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_update_line_item() + .returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service() { + let cmd = UpdateLineItemCommand::get_cmd(); + + let s = UpdateLineItemServiceBuilder::default() + .db_line_item_id_exists(mock_line_item_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_bill_id_exists(mock_bill_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.update_line_item(cmd.clone()).await.unwrap(); + assert_eq!(res.new_line_item().product_name(), cmd.product_name()); + assert_eq!(res.new_line_item().product_id(), cmd.product_id()); + assert_eq!(res.new_line_item().quantity(), cmd.quantity()); + assert_eq!( + res.new_line_item().line_item_id(), + cmd.old_line_item().line_item_id() + ); + assert_eq!(res.new_line_item().bill_id(), cmd.old_line_item().bill_id()); + + assert!(!res.new_line_item().deleted()); + + assert_eq!(res.old_line_item(), cmd.old_line_item()); + + assert_eq!(res.added_by_user(), cmd.adding_by()); + } + + #[actix_rt::test] + async fn test_service_line_item_id_doesnt_exist() { + let cmd = UpdateLineItemCommand::get_cmd(); + + let s = UpdateLineItemServiceBuilder::default() + .db_line_item_id_exists(mock_line_item_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_bill_id_exists(mock_bill_id_exists_db_port_true(IS_NEVER_CALLED)) + .build() + .unwrap(); + + assert_eq!( + s.update_line_item(cmd.clone()).await, + Err(BillingError::LineItemIDNotFound) + ); + } + + #[actix_rt::test] + async fn test_service_bill_id_doesnt_exist() { + let cmd = UpdateLineItemCommand::get_cmd(); + + let s = UpdateLineItemServiceBuilder::default() + .db_line_item_id_exists(mock_line_item_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_bill_id_exists(mock_bill_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.update_line_item(cmd.clone()).await, + Err(BillingError::BillIDNotFound) + ); + } +} diff --git a/src/billing/domain/add_line_item_command.rs b/src/billing/domain/add_line_item_command.rs new file mode 100644 index 0000000..910950c --- /dev/null +++ b/src/billing/domain/add_line_item_command.rs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::types::{currency::*, quantity::*}; +use crate::utils::string::empty_string_err; + +#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum AddLineItemCommandError { + QuantityIsEmpty, + ProductNameIsEmpty, +} + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct UnvalidatedAddLineItemCommand { + adding_by: Uuid, + + #[builder(default = "OffsetDateTime::now_utc()")] + created_time: OffsetDateTime, + product_name: String, + product_id: Uuid, + bill_id: Uuid, + quantity: Quantity, + price_per_unit: Price, +} + +impl UnvalidatedAddLineItemCommand { + pub fn validate(self) -> Result { + let product_name = empty_string_err( + self.product_name, + AddLineItemCommandError::ProductNameIsEmpty, + )?; + + if self.quantity.is_empty() { + return Err(AddLineItemCommandError::QuantityIsEmpty); + } + + Ok(AddLineItemCommand { + created_time: self.created_time, + product_name, + product_id: self.product_id, + bill_id: self.bill_id, + quantity: self.quantity, + adding_by: self.adding_by, + price_per_unit: self.price_per_unit, + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] +pub struct AddLineItemCommand { + created_time: OffsetDateTime, + product_name: String, + product_id: Uuid, + bill_id: Uuid, + quantity: Quantity, + price_per_unit: Price, + + adding_by: Uuid, +} + +#[cfg(test)] +mod tests { + use time::macros::datetime; + + use crate::utils::uuid::tests::UUID; + + use super::*; + + impl AddLineItemCommand { + pub fn get_cmd() -> Self { + let product_name = "foo"; + let product_id = UUID; + let bill_id = UUID; + let adding_by = UUID; + let quantity = Quantity::get_quantity(); + + UnvalidatedAddLineItemCommandBuilder::default() + .product_name(product_name.into()) + .adding_by(adding_by) + .created_time(datetime!(1970-01-01 0:00 UTC)) + .quantity(quantity.clone()) + .price_per_unit(Price::default()) + .product_id(product_id) + .bill_id(bill_id) + .build() + .unwrap() + .validate() + .unwrap() + } + } + + #[test] + fn test_cmd() { + let product_name = "foo"; + let product_id = UUID; + let bill_id = UUID; + let adding_by = UUID; + let quantity = Quantity::get_quantity(); + + let cmd = UnvalidatedAddLineItemCommandBuilder::default() + .product_name(product_name.into()) + .adding_by(adding_by) + .price_per_unit(Price::default()) + .quantity(quantity.clone()) + .product_id(product_id) + .bill_id(bill_id) + .build() + .unwrap() + .validate() + .unwrap(); + + assert_eq!(cmd.quantity(), &quantity); + assert_eq!(*cmd.product_id(), product_id); + assert_eq!(*cmd.adding_by(), adding_by); + assert_eq!(cmd.product_name(), product_name); + } + + #[test] + fn test_cmd_product_name_empty() { + let product_name = ""; + let product_id = UUID; + let bill_id = UUID; + let adding_by = UUID; + let quantity = Quantity::get_quantity(); + + assert_eq!( + UnvalidatedAddLineItemCommandBuilder::default() + .product_name(product_name.into()) + .adding_by(adding_by) + .quantity(quantity.clone()) + .price_per_unit(Price::default()) + .product_id(product_id) + .bill_id(bill_id) + .build() + .unwrap() + .validate(), + Err(AddLineItemCommandError::ProductNameIsEmpty) + ); + } + + #[test] + fn test_cmd_quantity_empty() { + let product_name = "foo"; + let product_id = UUID; + let bill_id = UUID; + let adding_by = UUID; + // minor = 0; major = 0; + let quantity = Quantity::default(); + + assert_eq!( + UnvalidatedAddLineItemCommandBuilder::default() + .product_name(product_name.into()) + .adding_by(adding_by) + .quantity(quantity.clone()) + .product_id(product_id) + .bill_id(bill_id) + .price_per_unit(Price::default()) + .build() + .unwrap() + .validate(), + Err(AddLineItemCommandError::QuantityIsEmpty) + ); + } +} diff --git a/src/billing/domain/delete_line_item_command.rs b/src/billing/domain/delete_line_item_command.rs new file mode 100644 index 0000000..9587e39 --- /dev/null +++ b/src/billing/domain/delete_line_item_command.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::line_item_aggregate::LineItem; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Builder, Getters, +)] +pub struct DeleteLineItemCommand { + adding_by: Uuid, + line_item: LineItem, +} + +#[cfg(test)] +mod tests { + use crate::utils::uuid::tests::UUID; + + use super::*; + + impl DeleteLineItemCommand { + pub fn get_cmd() -> Self { + let adding_by = UUID; + + DeleteLineItemCommandBuilder::default() + .adding_by(adding_by) + .line_item(LineItem::get_line_item()) + .build() + .unwrap() + } + } +} diff --git a/src/billing/domain/line_item_added_event.rs b/src/billing/domain/line_item_added_event.rs new file mode 100644 index 0000000..511f8ca --- /dev/null +++ b/src/billing/domain/line_item_added_event.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::line_item_aggregate::LineItem; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct LineItemAddedEvent { + added_by_user: Uuid, + + line_item: LineItem, +} + +#[cfg(test)] +pub mod tests { + use crate::billing::domain::add_line_item_command::AddLineItemCommand; + + use super::*; + + pub fn get_added_line_item_event_from_command(cmd: &AddLineItemCommand) -> LineItemAddedEvent { + let line_item = LineItem::get_line_item(); + + LineItemAddedEventBuilder::default() + .added_by_user(cmd.adding_by().clone()) + .line_item(line_item) + .build() + .unwrap() + } + + #[test] + fn test_event() { + get_added_line_item_event_from_command(&AddLineItemCommand::get_cmd()); + } +} diff --git a/src/billing/domain/line_item_aggregate.rs b/src/billing/domain/line_item_aggregate.rs new file mode 100644 index 0000000..e2a956c --- /dev/null +++ b/src/billing/domain/line_item_aggregate.rs @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::Aggregate; +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::billing::{ + application::services::{errors::*, *}, + domain::{commands::*, events::*}, +}; +use crate::types::{currency::Price, quantity::Quantity}; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Builder, Getters, +)] +pub struct LineItem { + #[builder(default = "OffsetDateTime::now_utc()")] + created_time: OffsetDateTime, + product_name: String, + product_id: Uuid, + bill_id: Uuid, + line_item_id: Uuid, + quantity: Quantity, + price_per_unit: Price, + #[builder(default = "false")] + deleted: bool, +} + +impl Default for LineItem { + fn default() -> Self { + Self { + created_time: OffsetDateTime::now_utc(), + product_name: String::default(), + product_id: Default::default(), + line_item_id: Default::default(), + bill_id: Default::default(), + quantity: Default::default(), + price_per_unit: Price::default(), + deleted: false, + } + } +} + +impl LineItem { + pub fn total_price(&self) -> Price { + let total_price_as_minor = (self.quantity().major_as_minor().unwrap() // TODO: handle err + + self.quantity().minor().number()) + * (self.price_per_unit().major_as_minor() + self.price_per_unit().minor()); + + Price::from_minor( + total_price_as_minor, + self.price_per_unit().currency().clone(), + ) + } +} + +#[cfg(test)] +pub mod tests { + use crate::{ + billing::domain::add_line_item_command::AddLineItemCommand, utils::uuid::tests::UUID, + }; + + use super::*; + + impl LineItem { + pub fn get_line_item() -> Self { + let cmd = AddLineItemCommand::get_cmd(); + + LineItemBuilder::default() + .created_time(cmd.created_time().clone()) + .product_name("test_product".into()) + .product_id(*cmd.product_id()) + .quantity(cmd.quantity().clone()) + .bill_id(*cmd.bill_id()) + .price_per_unit(cmd.price_per_unit().clone()) + .line_item_id(UUID) + .build() + .unwrap() + } + } +} + +#[async_trait] +impl Aggregate for LineItem { + type Command = BillingCommand; + type Event = BillingEvent; + type Error = BillingError; + type Services = std::sync::Arc; + + // This identifier should be unique to the system. + fn aggregate_type() -> String { + "billing.line_item".to_string() + } + + // The aggregate logic goes here. Note that this will be the _bulk_ of a CQRS system + // so expect to use helper functions elsewhere to keep the code clean. + async fn handle( + &self, + command: Self::Command, + services: &Self::Services, + ) -> Result, Self::Error> { + match command { + BillingCommand::AddLineItem(cmd) => { + let res = services.add_line_item().add_line_item(cmd).await?; + Ok(vec![BillingEvent::LineItemAdded(res)]) + } + BillingCommand::UpdateLineItem(cmd) => { + let res = services.update_line_item().update_line_item(cmd).await?; + Ok(vec![BillingEvent::LineItemUpdated(res)]) + } + BillingCommand::DeleteLineItem(cmd) => { + let res = services.delete_line_item().delete_line_item(cmd).await?; + Ok(vec![BillingEvent::LineItemDeleted(res)]) + } + _ => Ok(Vec::default()), + } + } + + fn apply(&mut self, event: Self::Event) { + match event { + BillingEvent::LineItemAdded(e) => *self = e.line_item().clone(), + BillingEvent::LineItemUpdated(e) => *self = e.new_line_item().clone(), + BillingEvent::LineItemDeleted(e) => *self = e.line_item().clone(), + _ => (), + } + } +} + +#[cfg(test)] +mod aggregate_tests { + use std::sync::Arc; + + use add_line_item_service::tests::mock_add_line_item_service; + use cqrs_es::test::TestFramework; + use delete_line_item_service::tests::mock_delete_line_item_service; + use update_line_item_service::tests::mock_update_line_item_service; + + use super::*; + + use crate::billing::application::services::*; + use crate::billing::domain::delete_line_item_command::DeleteLineItemCommand; + use crate::billing::domain::line_item_deleted_event::tests::get_deleted_line_item_event_from_command; + use crate::billing::domain::line_item_updated_event::tests::get_updated_line_item_event_from_command; + use crate::billing::domain::update_line_item_command::UpdateLineItemCommand; + use crate::tests::bdd::*; + + use crate::billing::domain::{ + add_line_item_command::*, + line_item_added_event::tests::get_added_line_item_event_from_command, + }; + + type LineItemTestFramework = TestFramework; + + #[test] + fn test_add_line_item() { + let cmd = AddLineItemCommand::get_cmd(); + let expected = get_added_line_item_event_from_command(&cmd); + let expected = BillingEvent::LineItemAdded(expected); + + let mut services = MockBillingServicesInterface::new(); + services + .expect_add_line_item() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_add_line_item_service(IS_CALLED_ONLY_ONCE, cmd.clone())); + + LineItemTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::AddLineItem(cmd)) + .then_expect_events(vec![expected]); + } + + #[test] + fn test_update_line_item() { + let cmd = UpdateLineItemCommand::get_cmd(); + let expected = get_updated_line_item_event_from_command(&cmd); + let expected = BillingEvent::LineItemUpdated(expected); + + let mut services = MockBillingServicesInterface::new(); + services + .expect_update_line_item() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_update_line_item_service( + IS_CALLED_ONLY_ONCE, + cmd.clone(), + )); + + LineItemTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::UpdateLineItem(cmd)) + .then_expect_events(vec![expected]); + } + + #[test] + fn test_delete_line_item() { + let cmd = DeleteLineItemCommand::get_cmd(); + let expected = get_deleted_line_item_event_from_command(&cmd); + let expected = BillingEvent::LineItemDeleted(expected); + + let mut services = MockBillingServicesInterface::new(); + services + .expect_delete_line_item() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_delete_line_item_service( + IS_CALLED_ONLY_ONCE, + cmd.clone(), + )); + + LineItemTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::DeleteLineItem(cmd)) + .then_expect_events(vec![expected]); + } +} diff --git a/src/billing/domain/line_item_deleted_event.rs b/src/billing/domain/line_item_deleted_event.rs new file mode 100644 index 0000000..b20daf3 --- /dev/null +++ b/src/billing/domain/line_item_deleted_event.rs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::line_item_aggregate::*; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct LineItemDeletedEvent { + added_by_user: Uuid, + + line_item: LineItem, +} + +#[cfg(test)] +pub mod tests { + use crate::billing::domain::delete_line_item_command::DeleteLineItemCommand; + + use super::*; + + pub fn get_deleted_line_item_event_from_command( + cmd: &DeleteLineItemCommand, + ) -> LineItemDeletedEvent { + let deleted_line_item = LineItemBuilder::default() + .created_time(cmd.line_item().created_time().clone()) + .product_name(cmd.line_item().product_name().into()) + .product_id(*cmd.line_item().product_id()) + .bill_id(*cmd.line_item().bill_id()) + .line_item_id(*cmd.line_item().line_item_id()) + .price_per_unit(cmd.line_item().price_per_unit().clone()) + .quantity(cmd.line_item().quantity().clone()) + .deleted(true) + .build() + .unwrap(); + + LineItemDeletedEventBuilder::default() + .added_by_user(cmd.adding_by().clone()) + .line_item(deleted_line_item) + .build() + .unwrap() + } + + #[test] + fn test_event() { + let event = get_deleted_line_item_event_from_command(&DeleteLineItemCommand::get_cmd()); + assert!(event.line_item().deleted()); + } +} diff --git a/src/billing/domain/line_item_updated_event.rs b/src/billing/domain/line_item_updated_event.rs new file mode 100644 index 0000000..4b3bbb1 --- /dev/null +++ b/src/billing/domain/line_item_updated_event.rs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::line_item_aggregate::*; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct LineItemUpdatedEvent { + added_by_user: Uuid, + + new_line_item: LineItem, + old_line_item: LineItem, +} + +#[cfg(test)] +pub mod tests { + use crate::billing::domain::update_line_item_command::UpdateLineItemCommand; + + use super::*; + + pub fn get_updated_line_item_event_from_command( + cmd: &UpdateLineItemCommand, + ) -> LineItemUpdatedEvent { + let new_line_item = LineItemBuilder::default() + .created_time(cmd.created_time().clone()) + .product_name(cmd.product_name().clone()) + .product_id(*cmd.product_id()) + .bill_id(*cmd.bill_id()) + .price_per_unit(cmd.price_per_unit().clone()) + .quantity(cmd.quantity().clone()) + .line_item_id(*cmd.old_line_item().line_item_id()) + .build() + .unwrap(); + + LineItemUpdatedEventBuilder::default() + .added_by_user(cmd.adding_by().clone()) + .old_line_item(cmd.old_line_item().clone()) + .new_line_item(new_line_item) + .build() + .unwrap() + } + + #[test] + fn test_event() { + get_updated_line_item_event_from_command(&UpdateLineItemCommand::get_cmd()); + } +} diff --git a/src/billing/domain/update_line_item_command.rs b/src/billing/domain/update_line_item_command.rs new file mode 100644 index 0000000..079f4cf --- /dev/null +++ b/src/billing/domain/update_line_item_command.rs @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::types::{currency::*, quantity::*}; +use crate::utils::string::empty_string_err; + +use super::line_item_aggregate::LineItem; + +#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum UpdateLineItemCommandError { + QuantityIsEmpty, + ProductNameIsEmpty, +} + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct UnvalidatedUpdateLineItemCommand { + adding_by: Uuid, + + #[builder(default = "OffsetDateTime::now_utc()")] + created_time: OffsetDateTime, + product_name: String, + product_id: Uuid, + bill_id: Uuid, + quantity: Quantity, + price_per_unit: Price, + + old_line_item: LineItem, +} + +impl UnvalidatedUpdateLineItemCommand { + pub fn validate(self) -> Result { + let product_name = empty_string_err( + self.product_name, + UpdateLineItemCommandError::ProductNameIsEmpty, + )?; + + if self.quantity.is_empty() { + return Err(UpdateLineItemCommandError::QuantityIsEmpty); + } + + Ok(UpdateLineItemCommand { + created_time: self.created_time, + product_name, + product_id: self.product_id, + bill_id: self.bill_id, + quantity: self.quantity, + adding_by: self.adding_by, + old_line_item: self.old_line_item, + price_per_unit: self.price_per_unit, + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] +pub struct UpdateLineItemCommand { + created_time: OffsetDateTime, + product_name: String, + product_id: Uuid, + bill_id: Uuid, + quantity: Quantity, + price_per_unit: Price, + + old_line_item: LineItem, + + adding_by: Uuid, +} +// +#[cfg(test)] +mod tests { + use crate::{billing::domain::bill_aggregate::Bill, utils::uuid::tests::UUID}; + + use super::*; + + impl UpdateLineItemCommand { + pub fn get_cmd() -> Self { + let product_name = "foo"; + let product_id = UUID; + let bill_id = UUID; + let adding_by = UUID; + let quantity = Quantity::get_quantity(); + + UnvalidatedUpdateLineItemCommandBuilder::default() + .product_name(product_name.into()) + .adding_by(adding_by) + .quantity(quantity.clone()) + .product_id(product_id) + .bill_id(product_id) + .old_line_item(LineItem::get_line_item()) + .price_per_unit(Price::default()) + .build() + .unwrap() + .validate() + .unwrap() + } + } + + #[test] + fn test_cmd() { + let product_name = "foo"; + let product_id = UUID; + let bill_id = UUID; + let adding_by = UUID; + let quantity = Quantity::get_quantity(); + let old_line_item = LineItem::get_line_item(); + + let cmd = UnvalidatedUpdateLineItemCommandBuilder::default() + .product_name(product_name.into()) + .adding_by(adding_by) + .quantity(quantity.clone()) + .product_id(product_id) + .bill_id(bill_id) + .old_line_item(old_line_item.clone()) + .price_per_unit(Price::default()) + .build() + .unwrap() + .validate() + .unwrap(); + + assert_eq!(cmd.quantity(), &quantity); + assert_eq!(*cmd.product_id(), product_id); + assert_eq!(*cmd.bill_id(), bill_id); + assert_eq!(*cmd.adding_by(), adding_by); + assert_eq!(cmd.product_name(), product_name); + assert_eq!(cmd.old_line_item(), &old_line_item); + } + + #[test] + fn test_cmd_product_name_empty() { + let product_name = ""; + let product_id = UUID; + let bill_id = UUID; + let adding_by = UUID; + let quantity = Quantity::get_quantity(); + + assert_eq!( + UnvalidatedUpdateLineItemCommandBuilder::default() + .product_name(product_name.into()) + .adding_by(adding_by) + .quantity(quantity.clone()) + .product_id(product_id) + .bill_id(bill_id) + .price_per_unit(Price::default()) + .old_line_item(LineItem::get_line_item()) + .build() + .unwrap() + .validate(), + Err(UpdateLineItemCommandError::ProductNameIsEmpty) + ); + } + + #[test] + fn test_cmd_quantity_empty() { + let product_name = "foo"; + let product_id = UUID; + let bill_id = UUID; + let adding_by = UUID; + // minor = 0; major = 0; + let quantity = Quantity::default(); + + assert_eq!( + UnvalidatedUpdateLineItemCommandBuilder::default() + .product_name(product_name.into()) + .adding_by(adding_by) + .quantity(quantity.clone()) + .product_id(product_id) + .bill_id(bill_id) + .old_line_item(LineItem::get_line_item()) + .price_per_unit(Price::default()) + .build() + .unwrap() + .validate(), + Err(UpdateLineItemCommandError::QuantityIsEmpty) + ); + } +} From dbbbb86a8c23b8683d4e19116911fc848d903bfc Mon Sep 17 00:00:00 2001 From: Aravinth Manivannan Date: Tue, 17 Sep 2024 16:13:34 +0530 Subject: [PATCH 3/3] feat: CRUD bill aggregate --- ...de8e3c0e33e22f2d2cd67fdd671b1f94ab8ea.json | 15 + ...cbdaa293f1d363f06f5926adfd038783171c4.json | 22 ++ ...31c44587f54c3583849d07d81e87e1f773c2b.json | 22 ++ ...b8c9ce62cf39e4a516511f436cba04520fddd.json | 28 ++ ...0d382b164f80a255dfe805abcd12cd43e17f3.json | 14 + ...4da078ac5fe27b022d41948ceb5d554b7f0fa.json | 22 ++ ...06cba772713dfdc11e30e92ad4842cbbb8a62.json | 64 +++ ...35433d3f8bd1e798772c05fc156494c036ef5.json | 22 ++ ...e07612061f183c20028ea4995807de6e6fae0.json | 22 ++ ...d35cbdcf0ae01437b469f1acc93ed11702ce7.json | 15 + ...20240917094208_cqrs_billing_bill_query.sql | 31 ++ .../output/db/postgres/bill_id_exists.rs | 83 ++++ .../adapters/output/db/postgres/bill_view.rs | 371 ++++++++++++++++++ .../adapters/output/db/postgres/mod.rs | 3 + .../output/db/postgres/next_token_id.rs | 111 ++++++ src/billing/application/mod.rs | 4 +- src/billing/application/port/mod.rs | 4 +- .../port/output/db/bill_id_exists.rs | 53 +++ .../application/port/output/db/errors.rs | 19 + .../port/output/db/line_item_id_exists.rs | 57 +++ src/billing/application/port/output/db/mod.rs | 9 + .../port/output/db/next_token_id.rs | 43 ++ src/billing/application/port/output/mod.rs | 1 + .../application/services/add_bill_service.rs | 125 ++++++ .../application/services/add_store_service.rs | 8 +- .../services/delete_bill_service.rs | 103 +++++ src/billing/application/services/mod.rs | 68 ++++ .../services/update_bill_service.rs | 116 ++++++ .../services/update_store_service.rs | 12 +- src/billing/domain/add_bill_command.rs | 63 +++ src/billing/domain/bill_added_event.rs | 41 ++ src/billing/domain/bill_aggregate.rs | 193 +++++++++ src/billing/domain/bill_deleted_event.rs | 49 +++ src/billing/domain/bill_updated_event.rs | 49 +++ src/billing/domain/commands.rs | 25 ++ src/billing/domain/delete_bill_command.rs | 37 ++ src/billing/domain/events.rs | 46 +++ src/billing/domain/mod.rs | 27 ++ src/billing/domain/store_aggregate.rs | 12 +- src/billing/domain/update_bill_command.rs | 71 ++++ 40 files changed, 2060 insertions(+), 20 deletions(-) create mode 100644 .sqlx/query-0e4316d20c96a7fcfcb68a98865de8e3c0e33e22f2d2cd67fdd671b1f94ab8ea.json create mode 100644 .sqlx/query-2af9418499c040df68031e79f7bcbdaa293f1d363f06f5926adfd038783171c4.json create mode 100644 .sqlx/query-6d9b6f386f4425d45d6aa8f7c2331c44587f54c3583849d07d81e87e1f773c2b.json create mode 100644 .sqlx/query-835a3afa5e16c762d021c4ca889b8c9ce62cf39e4a516511f436cba04520fddd.json create mode 100644 .sqlx/query-859920462d8e008b34bfc7467140d382b164f80a255dfe805abcd12cd43e17f3.json create mode 100644 .sqlx/query-91a8472f079a5221a94cdf5bf1a4da078ac5fe27b022d41948ceb5d554b7f0fa.json create mode 100644 .sqlx/query-b0c2747901658847d7c765401de06cba772713dfdc11e30e92ad4842cbbb8a62.json create mode 100644 .sqlx/query-b335fc519289a42c707855b620a35433d3f8bd1e798772c05fc156494c036ef5.json create mode 100644 .sqlx/query-dbd434995778eab99e753a8fcdce07612061f183c20028ea4995807de6e6fae0.json create mode 100644 .sqlx/query-df0fde518fdde267e2513fdd2c8d35cbdcf0ae01437b469f1acc93ed11702ce7.json create mode 100644 migrations/20240917094208_cqrs_billing_bill_query.sql create mode 100644 src/billing/adapters/output/db/postgres/bill_id_exists.rs create mode 100644 src/billing/adapters/output/db/postgres/bill_view.rs create mode 100644 src/billing/adapters/output/db/postgres/next_token_id.rs create mode 100644 src/billing/application/port/output/db/bill_id_exists.rs create mode 100644 src/billing/application/port/output/db/errors.rs create mode 100644 src/billing/application/port/output/db/line_item_id_exists.rs create mode 100644 src/billing/application/port/output/db/mod.rs create mode 100644 src/billing/application/port/output/db/next_token_id.rs create mode 100644 src/billing/application/services/add_bill_service.rs create mode 100644 src/billing/application/services/delete_bill_service.rs create mode 100644 src/billing/application/services/update_bill_service.rs create mode 100644 src/billing/domain/add_bill_command.rs create mode 100644 src/billing/domain/bill_added_event.rs create mode 100644 src/billing/domain/bill_aggregate.rs create mode 100644 src/billing/domain/bill_deleted_event.rs create mode 100644 src/billing/domain/bill_updated_event.rs create mode 100644 src/billing/domain/commands.rs create mode 100644 src/billing/domain/delete_bill_command.rs create mode 100644 src/billing/domain/events.rs create mode 100644 src/billing/domain/update_bill_command.rs diff --git a/.sqlx/query-0e4316d20c96a7fcfcb68a98865de8e3c0e33e22f2d2cd67fdd671b1f94ab8ea.json b/.sqlx/query-0e4316d20c96a7fcfcb68a98865de8e3c0e33e22f2d2cd67fdd671b1f94ab8ea.json new file mode 100644 index 0000000..1ddf35f --- /dev/null +++ b/.sqlx/query-0e4316d20c96a7fcfcb68a98865de8e3c0e33e22f2d2cd67fdd671b1f94ab8ea.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO cqrs_billing_bill_next_token_id\n (token_number, store_id)\n VALUES\n ($1, $2);", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "0e4316d20c96a7fcfcb68a98865de8e3c0e33e22f2d2cd67fdd671b1f94ab8ea" +} diff --git a/.sqlx/query-2af9418499c040df68031e79f7bcbdaa293f1d363f06f5926adfd038783171c4.json b/.sqlx/query-2af9418499c040df68031e79f7bcbdaa293f1d363f06f5926adfd038783171c4.json new file mode 100644 index 0000000..d961fa1 --- /dev/null +++ b/.sqlx/query-2af9418499c040df68031e79f7bcbdaa293f1d363f06f5926adfd038783171c4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_billing_bill_next_token_id\n WHERE\n store_id = $1\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2af9418499c040df68031e79f7bcbdaa293f1d363f06f5926adfd038783171c4" +} diff --git a/.sqlx/query-6d9b6f386f4425d45d6aa8f7c2331c44587f54c3583849d07d81e87e1f773c2b.json b/.sqlx/query-6d9b6f386f4425d45d6aa8f7c2331c44587f54c3583849d07d81e87e1f773c2b.json new file mode 100644 index 0000000..9ce5483 --- /dev/null +++ b/.sqlx/query-6d9b6f386f4425d45d6aa8f7c2331c44587f54c3583849d07d81e87e1f773c2b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO cqrs_billing_bill_query (\n version,\n created_time,\n store_id,\n bill_id,\n token_number,\n total_price_major,\n total_price_minor,\n total_price_currency,\n deleted\n\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9\n );", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Timestamptz", + "Uuid", + "Uuid", + "Int4", + "Int4", + "Int4", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "6d9b6f386f4425d45d6aa8f7c2331c44587f54c3583849d07d81e87e1f773c2b" +} diff --git a/.sqlx/query-835a3afa5e16c762d021c4ca889b8c9ce62cf39e4a516511f436cba04520fddd.json b/.sqlx/query-835a3afa5e16c762d021c4ca889b8c9ce62cf39e4a516511f436cba04520fddd.json new file mode 100644 index 0000000..6b96215 --- /dev/null +++ b/.sqlx/query-835a3afa5e16c762d021c4ca889b8c9ce62cf39e4a516511f436cba04520fddd.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n bill_id, version\n FROM\n cqrs_billing_bill_query\n WHERE\n bill_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bill_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "835a3afa5e16c762d021c4ca889b8c9ce62cf39e4a516511f436cba04520fddd" +} diff --git a/.sqlx/query-859920462d8e008b34bfc7467140d382b164f80a255dfe805abcd12cd43e17f3.json b/.sqlx/query-859920462d8e008b34bfc7467140d382b164f80a255dfe805abcd12cd43e17f3.json new file mode 100644 index 0000000..3959b3a --- /dev/null +++ b/.sqlx/query-859920462d8e008b34bfc7467140d382b164f80a255dfe805abcd12cd43e17f3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM cqrs_billing_bill_next_token_id WHERE store_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "859920462d8e008b34bfc7467140d382b164f80a255dfe805abcd12cd43e17f3" +} diff --git a/.sqlx/query-91a8472f079a5221a94cdf5bf1a4da078ac5fe27b022d41948ceb5d554b7f0fa.json b/.sqlx/query-91a8472f079a5221a94cdf5bf1a4da078ac5fe27b022d41948ceb5d554b7f0fa.json new file mode 100644 index 0000000..9c461be --- /dev/null +++ b/.sqlx/query-91a8472f079a5221a94cdf5bf1a4da078ac5fe27b022d41948ceb5d554b7f0fa.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_billing_bill_query\n WHERE\n bill_id = $1\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "91a8472f079a5221a94cdf5bf1a4da078ac5fe27b022d41948ceb5d554b7f0fa" +} diff --git a/.sqlx/query-b0c2747901658847d7c765401de06cba772713dfdc11e30e92ad4842cbbb8a62.json b/.sqlx/query-b0c2747901658847d7c765401de06cba772713dfdc11e30e92ad4842cbbb8a62.json new file mode 100644 index 0000000..b6bf392 --- /dev/null +++ b/.sqlx/query-b0c2747901658847d7c765401de06cba772713dfdc11e30e92ad4842cbbb8a62.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n created_time,\n store_id,\n bill_id,\n token_number,\n total_price_major,\n total_price_minor,\n total_price_currency,\n deleted\n FROM\n cqrs_billing_bill_query\n WHERE\n bill_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_time", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "store_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "bill_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "token_number", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "total_price_major", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "total_price_minor", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "total_price_currency", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "deleted", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "b0c2747901658847d7c765401de06cba772713dfdc11e30e92ad4842cbbb8a62" +} diff --git a/.sqlx/query-b335fc519289a42c707855b620a35433d3f8bd1e798772c05fc156494c036ef5.json b/.sqlx/query-b335fc519289a42c707855b620a35433d3f8bd1e798772c05fc156494c036ef5.json new file mode 100644 index 0000000..e9e4784 --- /dev/null +++ b/.sqlx/query-b335fc519289a42c707855b620a35433d3f8bd1e798772c05fc156494c036ef5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n cqrs_billing_bill_query\n SET\n version = $1,\n\n created_time = $2,\n store_id = $3,\n bill_id = $4,\n token_number = $5,\n total_price_major = $6,\n total_price_minor = $7,\n total_price_currency = $8,\n\n deleted = $9;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Timestamptz", + "Uuid", + "Uuid", + "Int4", + "Int4", + "Int4", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "b335fc519289a42c707855b620a35433d3f8bd1e798772c05fc156494c036ef5" +} diff --git a/.sqlx/query-dbd434995778eab99e753a8fcdce07612061f183c20028ea4995807de6e6fae0.json b/.sqlx/query-dbd434995778eab99e753a8fcdce07612061f183c20028ea4995807de6e6fae0.json new file mode 100644 index 0000000..f45dba8 --- /dev/null +++ b/.sqlx/query-dbd434995778eab99e753a8fcdce07612061f183c20028ea4995807de6e6fae0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n token_number\n FROM\n cqrs_billing_bill_next_token_id\n WHERE\n store_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_number", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dbd434995778eab99e753a8fcdce07612061f183c20028ea4995807de6e6fae0" +} diff --git a/.sqlx/query-df0fde518fdde267e2513fdd2c8d35cbdcf0ae01437b469f1acc93ed11702ce7.json b/.sqlx/query-df0fde518fdde267e2513fdd2c8d35cbdcf0ae01437b469f1acc93ed11702ce7.json new file mode 100644 index 0000000..5c11ae6 --- /dev/null +++ b/.sqlx/query-df0fde518fdde267e2513fdd2c8d35cbdcf0ae01437b469f1acc93ed11702ce7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE cqrs_billing_bill_next_token_id\n SET\n token_number = $1\n WHERE\n store_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "df0fde518fdde267e2513fdd2c8d35cbdcf0ae01437b469f1acc93ed11702ce7" +} diff --git a/migrations/20240917094208_cqrs_billing_bill_query.sql b/migrations/20240917094208_cqrs_billing_bill_query.sql new file mode 100644 index 0000000..00b960c --- /dev/null +++ b/migrations/20240917094208_cqrs_billing_bill_query.sql @@ -0,0 +1,31 @@ +-- SPDX-FileCopyrightText: 2024 Aravinth Manivannan +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +CREATE TABLE IF NOT EXISTS cqrs_billing_bill_query +( + version bigint CHECK (version >= 0) NOT NULL, + + created_time timestamp with time zone DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + bill_id UUID NOT NULL UNIQUE, + + + store_id UUID NOT NULL, + token_number INTEGER NOT NULL, + + total_price_minor INTEGER DEFAULT NULL, + total_price_major INTEGER DEFAULT NULL, + total_price_currency TEXT DEFAULT NULL, + + deleted BOOLEAN NOT NULL DEFAULT FALSE, + + PRIMARY KEY (bill_id) +); + +CREATE TABLE IF NOT EXISTS cqrs_billing_bill_next_token_id +( + store_id UUID NOT NULL, + token_number INTEGER NOT NULL DEFAULT 1, + + PRIMARY KEY (store_id) +); diff --git a/src/billing/adapters/output/db/postgres/bill_id_exists.rs b/src/billing/adapters/output/db/postgres/bill_id_exists.rs new file mode 100644 index 0000000..74e2170 --- /dev/null +++ b/src/billing/adapters/output/db/postgres/bill_id_exists.rs @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use uuid::Uuid; + +use super::BillingDBPostgresAdapter; +use crate::billing::application::port::output::db::{bill_id_exists::*, errors::*}; + +#[async_trait::async_trait] +impl BillIDExistsDBPort for BillingDBPostgresAdapter { + async fn bill_id_exists(&self, bill_id: &Uuid) -> BillingDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_billing_bill_query + WHERE + bill_id = $1 + );", + bill_id + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +pub mod tests { + + use super::*; + // use crate::billing::domain::add_product_command::tests::get_customizations; + use crate::billing::domain::bill_aggregate::*; + + async fn create_dummy_bill(bill: &Bill, db: &BillingDBPostgresAdapter) { + sqlx::query!( + "INSERT INTO cqrs_billing_bill_query ( + version, + store_id, + bill_id, + token_number, + deleted + ) VALUES ( + $1, $2, $3, $4, $5 + );", + 1, + *bill.store_id(), + *bill.bill_id(), + *bill.token_number() as i32, + bill.deleted().clone(), + ) + .execute(&db.pool) + .await + .unwrap(); + } + + #[actix_rt::test] + async fn test_postgres_product_exists() { + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::BillingDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let bill = Bill::default(); + + // state doesn't exist + assert!(!db.bill_id_exists(bill.bill_id()).await.unwrap()); + + create_dummy_bill(&bill, &db).await; + + // state exists + assert!(db.bill_id_exists(bill.bill_id()).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/billing/adapters/output/db/postgres/bill_view.rs b/src/billing/adapters/output/db/postgres/bill_view.rs new file mode 100644 index 0000000..462b8a4 --- /dev/null +++ b/src/billing/adapters/output/db/postgres/bill_view.rs @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::persist::{PersistenceError, ViewContext, ViewRepository}; +use cqrs_es::{EventEnvelope, Query, View}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use super::errors::*; +use super::BillingDBPostgresAdapter; +use crate::billing::domain::bill_aggregate::Bill; +use crate::billing::domain::events::BillingEvent; +use crate::utils::parse_aggregate_id::parse_aggregate_id; + +pub const NEW_BILL_NON_UUID: &str = "billing_new_bill_non_uuid-asdfa"; + +// The view for a Bill query, for a standard http application this should +// be designed to reflect the response dto that will be returned to a user. +#[derive(Debug, Serialize, Deserialize)] +pub struct BillView { + created_time: OffsetDateTime, + store_id: Uuid, + bill_id: Uuid, + + token_number: i32, + + total_price_minor: Option, + total_price_major: Option, + total_price_currency: Option, + + deleted: bool, +} + +impl Default for BillView { + fn default() -> Self { + Self { + created_time: OffsetDateTime::now_utc(), + store_id: Default::default(), + bill_id: Default::default(), + + token_number: Default::default(), + + total_price_minor: Default::default(), + total_price_major: Default::default(), + total_price_currency: Default::default(), + + deleted: false, + } + } +} + +// This updates the view with events as they are committed. +// The logic should be minimal here, e.g., don't calculate the account balance, +// design the events to carry the balance information instead. +impl View for BillView { + fn update(&mut self, event: &EventEnvelope) { + if let BillingEvent::BillAdded(val) = &event.payload { + self.created_time = val.bill().created_time().clone(); + self.store_id = *val.bill().store_id(); + self.bill_id = *val.bill().bill_id(); + + self.token_number = *val.bill().token_number() as i32; + + self.total_price_minor = val.bill().total_price().as_ref().map(|t| *t.minor() as i32); + self.total_price_major = val.bill().total_price().as_ref().map(|t| *t.major() as i32); + self.total_price_currency = val + .bill() + .total_price() + .as_ref() + .map(|t| t.currency().to_string()); + + self.deleted = false; + } + } +} + +#[async_trait] +impl ViewRepository for BillingDBPostgresAdapter { + async fn load(&self, bill_id: &str) -> Result, PersistenceError> { + let bill_id = match parse_aggregate_id(bill_id, NEW_BILL_NON_UUID)? { + Some((val, _)) => return Ok(Some(val)), + None => Uuid::parse_str(bill_id).unwrap(), + }; + + let res = sqlx::query_as!( + BillView, + "SELECT + created_time, + store_id, + bill_id, + token_number, + total_price_major, + total_price_minor, + total_price_currency, + deleted + FROM + cqrs_billing_bill_query + WHERE + bill_id = $1;", + bill_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + Ok(Some(res)) + } + + async fn load_with_context( + &self, + bill_id: &str, + ) -> Result, PersistenceError> { + let bill_id = match parse_aggregate_id(bill_id, NEW_BILL_NON_UUID)? { + Some(val) => return Ok(Some(val)), + None => Uuid::parse_str(bill_id).unwrap(), + }; + + let res = sqlx::query_as!( + BillView, + "SELECT + created_time, + store_id, + bill_id, + token_number, + total_price_major, + total_price_minor, + total_price_currency, + deleted + FROM + cqrs_billing_bill_query + WHERE + bill_id = $1;", + &bill_id, + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + struct Context { + version: i64, + bill_id: Uuid, + } + + let ctx = sqlx::query_as!( + Context, + "SELECT + bill_id, version + FROM + cqrs_billing_bill_query + WHERE + bill_id = $1;", + bill_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + let view_context = ViewContext::new(ctx.bill_id.to_string(), ctx.version); + Ok(Some((res, view_context))) + } + + async fn update_view( + &self, + view: BillView, + context: ViewContext, + ) -> Result<(), PersistenceError> { + match context.version { + 0 => { + let version = context.version + 1; + sqlx::query!( + "INSERT INTO cqrs_billing_bill_query ( + version, + created_time, + store_id, + bill_id, + token_number, + total_price_major, + total_price_minor, + total_price_currency, + deleted + + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 + );", + version, + view.created_time, + view.store_id, + view.bill_id, + view.token_number, + view.total_price_major, + view.total_price_minor, + view.total_price_currency, + view.deleted, + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + _ => { + let version = context.version + 1; + sqlx::query!( + "UPDATE + cqrs_billing_bill_query + SET + version = $1, + + created_time = $2, + store_id = $3, + bill_id = $4, + token_number = $5, + total_price_major = $6, + total_price_minor = $7, + total_price_currency = $8, + + deleted = $9;", + version, + view.created_time, + view.store_id, + view.bill_id, + view.token_number, + view.total_price_major, + view.total_price_minor, + view.total_price_currency, + view.deleted, + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + } + + Ok(()) + } +} + +pub struct SimpleLoggingQuery {} + +// Our simplest query, this is great for debugging but absolutely useless in production. +// This query just pretty prints the events as they are processed. +#[async_trait] +impl Query for SimpleLoggingQuery { + async fn dispatch(&self, aggregate_id: &str, events: &[EventEnvelope]) { + for event in events { + let payload = serde_json::to_string_pretty(&event.payload).unwrap(); + println!("{}-{}\n{}", aggregate_id, event.sequence, payload); + } + } +} + +#[async_trait] +impl Query for BillingDBPostgresAdapter { + async fn dispatch(&self, bill_id: &str, events: &[EventEnvelope]) { + let res = self + .load_with_context(bill_id) + .await + .unwrap_or_else(|_| Some((BillView::default(), ViewContext::new(bill_id.into(), 0)))); + let (mut view, view_context): (BillView, ViewContext) = res.unwrap(); + for event in events { + view.update(event); + } + self.update_view(view, view_context).await.unwrap(); + } +} + +// Our second query, this one will be handled with Postgres `GenericQuery` +// which will serialize and persist our view after it is updated. It also +// provides a `load` method to deserialize the view on request. +//pub type BillQuery = GenericQuery; +//pub type BillQuery = Query; + +//#[cfg(test)] +//mod tests { +// use super::*; +// +// use postgres_es::PostgresCqrs; +// +// use crate::{ +// db::migrate::*, +// billing::{ +// application::services::{ +// add_category_service::tests::mock_add_category_service, add_customization_service::tests::mock_add_customization_service, add_line_item_service::tests::mock_add_line_item_service, add_product_service::tests::mock_add_product_service, add_bill_service::AddBillServiceBuilder, update_category_service::tests::mock_update_category_service, update_customization_service::tests::mock_update_customization_service, update_product_service::tests::mock_update_product_service, update_bill_service::tests::mock_update_bill_service, BillingServicesBuilder +// }, +// domain::{ +// add_category_command::AddCategoryCommand, add_customization_command, +// add_product_command::tests::get_command, add_bill_command::AddBillCommand, +// commands::BillingCommand, +// update_category_command::tests::get_update_category_command, +// update_customization_command::tests::get_update_customization_command, +// update_product_command, update_bill_command::tests::get_update_bill_cmd, +// }, +// }, +// tests::bdd::IS_NEVER_CALLED, +// utils::{random_string::GenerateRandomStringInterface, uuid::tests::UUID}, +// }; +// use std::sync::Arc; +// +// #[actix_rt::test] +// async fn pg_query() { +// let settings = crate::settings::tests::get_settings().await; +// //let settings = crate::settings::Settings::new().unwrap(); +// settings.create_db().await; +// +// let db = crate::db::sqlx_postgres::Postgres::init(&settings.database.url).await; +// db.migrate().await; +// let db = BillingDBPostgresAdapter::new(db.pool.clone()); +// +// let simple_query = SimpleLoggingQuery {}; +// +// let queries: Vec>> = +// vec![Box::new(simple_query), Box::new(db.clone())]; +// +// let services = BillingServicesBuilder::default() +// .add_bill(Arc::new( +// AddBillServiceBuilder::default() +// .db_bill_id_exists(Arc::new(db.clone())) +// .db_bill_name_exists(Arc::new(db.clone())) +// .get_uuid(Arc::new(crate::utils::uuid::GenerateUUID {})) +// .build() +// .unwrap(), +// )) +// .add_category(mock_add_category_service( +// IS_NEVER_CALLED, +// AddCategoryCommand::new("foo".into(), None, UUID, UUID).unwrap(), +// )) +// .add_product(mock_add_product_service(IS_NEVER_CALLED, get_command())) +// .add_customization(mock_add_customization_service( +// IS_NEVER_CALLED, +// add_customization_command::tests::get_command(), +// )) +// .update_product(mock_update_product_service( +// IS_NEVER_CALLED, +// update_product_command::tests::get_command(), +// )) +// .update_customization(mock_update_customization_service( +// IS_NEVER_CALLED, +// get_update_customization_command(), +// )) +// .update_category(mock_update_category_service( +// IS_NEVER_CALLED, +// get_update_category_command(), +// )) +// .update_bill(mock_update_bill_service( +// IS_NEVER_CALLED, +// get_update_bill_cmd(), +// )) +// .build() +// .unwrap(); +// +// let (cqrs, _bill_query): ( +// Arc>, +// Arc>, +// ) = ( +// Arc::new(postgres_es::postgres_cqrs( +// db.pool.clone(), +// queries, +// Arc::new(services), +// )), +// Arc::new(db.clone()), +// ); +// +// let rand = crate::utils::random_string::GenerateRandomString {}; +// let cmd = AddBillCommand::new(rand.get_random(10), None, UUID).unwrap(); +// cqrs.execute("", BillingCommand::AddBill(cmd.clone())) +// .await +// .unwrap(); +// +// settings.drop_db().await; +// } +//} diff --git a/src/billing/adapters/output/db/postgres/mod.rs b/src/billing/adapters/output/db/postgres/mod.rs index 2f5961e..a605999 100644 --- a/src/billing/adapters/output/db/postgres/mod.rs +++ b/src/billing/adapters/output/db/postgres/mod.rs @@ -7,9 +7,12 @@ use sqlx::postgres::PgPool; use crate::db::{migrate::RunMigrations, sqlx_postgres::Postgres}; +mod bill_id_exists; +mod bill_view; mod errors; mod line_item_id_exists; mod line_item_view; +mod next_token_id; mod store_id_exists; mod store_name_exists; mod store_view; diff --git a/src/billing/adapters/output/db/postgres/next_token_id.rs b/src/billing/adapters/output/db/postgres/next_token_id.rs new file mode 100644 index 0000000..fbad278 --- /dev/null +++ b/src/billing/adapters/output/db/postgres/next_token_id.rs @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use uuid::Uuid; + +use super::BillingDBPostgresAdapter; +use crate::billing::application::port::output::db::{errors::*, next_token_id::*}; + +struct TokenNumber { + token_number: i32, +} + +#[async_trait::async_trait] +impl NextTokenIDDBPort for BillingDBPostgresAdapter { + /// delete record for store_id, so that when next_token_id is called, it'll create and set it + /// to 1 + async fn reset(&self, store_id: &Uuid) -> BillingDBResult<()> { + sqlx::query!( + "DELETE FROM cqrs_billing_bill_next_token_id WHERE store_id = $1", + store_id + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// increment token ID and return pre-increment value + async fn next_token_id(&self, store_id: &Uuid) -> BillingDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_billing_bill_next_token_id + WHERE + store_id = $1 + );", + store_id + ) + .fetch_one(&self.pool) + .await?; + if let Some(true) = res.exists { + let res = sqlx::query_as!( + TokenNumber, + "SELECT + token_number + FROM + cqrs_billing_bill_next_token_id + WHERE + store_id = $1;", + store_id + ) + .fetch_one(&self.pool) + .await?; + + sqlx::query!( + "UPDATE cqrs_billing_bill_next_token_id + SET + token_number = $1 + WHERE + store_id = $2", + res.token_number + 1, + store_id + ) + .execute(&self.pool) + .await?; + + Ok(res.token_number as usize + 1) + } else { + sqlx::query!( + "INSERT INTO cqrs_billing_bill_next_token_id + (token_number, store_id) + VALUES + ($1, $2);", + 1, + store_id + ) + .execute(&self.pool) + .await?; + + Ok(1) + } + } +} + +#[cfg(test)] +pub mod tests { + + use super::*; + // use crate::billing::domain::add_product_command::tests::get_customizations; + use crate::{billing::domain::bill_aggregate::*, utils::uuid::tests::UUID}; + + #[actix_rt::test] + async fn test_postgres_next_token_id() { + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::BillingDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let store_id = UUID; + + assert_eq!(db.next_token_id(&store_id).await.unwrap(), 1); + assert_eq!(db.next_token_id(&store_id).await.unwrap(), 2); + assert!(db.reset(&store_id).await.is_ok()); + assert_eq!(db.next_token_id(&store_id).await.unwrap(), 1); + + settings.drop_db().await; + } +} diff --git a/src/billing/application/mod.rs b/src/billing/application/mod.rs index 357da8f..2f75b72 100644 --- a/src/billing/application/mod.rs +++ b/src/billing/application/mod.rs @@ -2,5 +2,5 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -mod port; -mod services; +pub mod port; +pub mod services; diff --git a/src/billing/application/port/mod.rs b/src/billing/application/port/mod.rs index 9b25f58..f571c4e 100644 --- a/src/billing/application/port/mod.rs +++ b/src/billing/application/port/mod.rs @@ -2,5 +2,5 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -mod input; -mod output; +pub mod input; +pub mod output; diff --git a/src/billing/application/port/output/db/bill_id_exists.rs b/src/billing/application/port/output/db/bill_id_exists.rs new file mode 100644 index 0000000..6e2323e --- /dev/null +++ b/src/billing/application/port/output/db/bill_id_exists.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; +use uuid::Uuid; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait BillIDExistsDBPort: Send + Sync { + async fn bill_id_exists(&self, c: &Uuid) -> BillingDBResult; +} + +pub type BillIDExistsDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_bill_id_exists_db_port_false(times: Option) -> BillIDExistsDBPortObj { + let mut m = MockBillIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_bill_id_exists() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_bill_id_exists().returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_bill_id_exists_db_port_true(times: Option) -> BillIDExistsDBPortObj { + let mut m = MockBillIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_bill_id_exists() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_bill_id_exists().returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/billing/application/port/output/db/errors.rs b/src/billing/application/port/output/db/errors.rs new file mode 100644 index 0000000..1debdad --- /dev/null +++ b/src/billing/application/port/output/db/errors.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_more::Display; +use serde::{Deserialize, Serialize}; + +pub type BillingDBResult = Result; + +#[derive(Debug, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum BillingDBError { + DuplicateBillID, + DuplicateStoreName, + DuplicateStoreID, + StoreIDNotFound, + DuplicateLineItemID, + LineItemIDNotFound, + InternalError, +} diff --git a/src/billing/application/port/output/db/line_item_id_exists.rs b/src/billing/application/port/output/db/line_item_id_exists.rs new file mode 100644 index 0000000..f012eb2 --- /dev/null +++ b/src/billing/application/port/output/db/line_item_id_exists.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; +use uuid::Uuid; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait LineItemIDExistsDBPort: Send + Sync { + async fn line_item_id_exists(&self, line_item_id: &Uuid) -> BillingDBResult; +} + +pub type LineItemIDExistsDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_line_item_id_exists_db_port_false( + times: Option, + ) -> LineItemIDExistsDBPortObj { + let mut m = MockLineItemIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_line_item_id_exists() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_line_item_id_exists().returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_line_item_id_exists_db_port_true( + times: Option, + ) -> LineItemIDExistsDBPortObj { + let mut m = MockLineItemIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_line_item_id_exists() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_line_item_id_exists().returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/billing/application/port/output/db/mod.rs b/src/billing/application/port/output/db/mod.rs new file mode 100644 index 0000000..a044ac0 --- /dev/null +++ b/src/billing/application/port/output/db/mod.rs @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later +pub mod bill_id_exists; +pub mod errors; +pub mod line_item_id_exists; +pub mod next_token_id; +pub mod store_id_exists; +pub mod store_name_exists; diff --git a/src/billing/application/port/output/db/next_token_id.rs b/src/billing/application/port/output/db/next_token_id.rs new file mode 100644 index 0000000..3bd3ba1 --- /dev/null +++ b/src/billing/application/port/output/db/next_token_id.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; +use uuid::Uuid; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait NextTokenIDDBPort: Send + Sync { + /// delete record for store_id, so that when next_token_id is called, it'll create and set it + /// to 1 + async fn reset(&self, store_id: &Uuid) -> BillingDBResult<()>; + + /// increment token ID and return pre-increment value + async fn next_token_id(&self, store_id: &Uuid) -> BillingDBResult; +} + +pub type NextTokenIDDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_next_token_id_db_port(times: Option) -> NextTokenIDDBPortObj { + let mut m = MockNextTokenIDDBPort::new(); + if let Some(times) = times { + m.expect_next_token_id().times(times).returning(|_| Ok(1)); + } else { + m.expect_next_token_id().returning(|_| Ok(1)); + } + + Arc::new(m) + } +} diff --git a/src/billing/application/port/output/mod.rs b/src/billing/application/port/output/mod.rs index 56f60de..59db170 100644 --- a/src/billing/application/port/output/mod.rs +++ b/src/billing/application/port/output/mod.rs @@ -1,3 +1,4 @@ // SPDX-FileCopyrightText: 2024 Aravinth Manivannan // // SPDX-License-Identifier: AGPL-3.0-or-later +pub mod db; diff --git a/src/billing/application/services/add_bill_service.rs b/src/billing/application/services/add_bill_service.rs new file mode 100644 index 0000000..c8f05fe --- /dev/null +++ b/src/billing/application/services/add_bill_service.rs @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; + +use super::errors::*; +use crate::billing::{ + application::port::output::db::{bill_id_exists::*, next_token_id::*}, + domain::{ + add_bill_command::AddBillCommand, + bill_added_event::{BillAddedEvent, BillAddedEventBuilder}, + bill_aggregate::*, + }, +}; +use crate::utils::uuid::*; + +#[automock] +#[async_trait::async_trait] +pub trait AddBillUseCase: Send + Sync { + async fn add_bill(&self, cmd: AddBillCommand) -> BillingResult; +} + +pub type AddBillServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct AddBillService { + db_bill_id_exists: BillIDExistsDBPortObj, + db_next_token_id: NextTokenIDDBPortObj, + get_uuid: GetUUIDInterfaceObj, +} + +#[async_trait::async_trait] +impl AddBillUseCase for AddBillService { + async fn add_bill(&self, cmd: AddBillCommand) -> BillingResult { + let mut bill_id = self.get_uuid.get_uuid(); + + loop { + if self.db_bill_id_exists.bill_id_exists(&bill_id).await? { + bill_id = self.get_uuid.get_uuid(); + continue; + } else { + break; + } + } + + let token_number = self.db_next_token_id.next_token_id(cmd.store_id()).await?; + + let bill = BillBuilder::default() + .bill_id(bill_id) + .token_number(token_number) + .created_time(cmd.created_time().clone()) + .store_id(*cmd.store_id()) + .total_price(None) + .deleted(false) + .build() + .unwrap(); + + Ok(BillAddedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .bill(bill) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + // use crate::billing::domain::add_bill_command::tests::get_command; + use crate::utils::uuid::tests::UUID; + use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; + + pub fn mock_add_bill_service(times: Option, cmd: AddBillCommand) -> AddBillServiceObj { + let mut m = MockAddBillUseCase::new(); + + let bill = BillBuilder::default() + .bill_id(UUID) + .token_number(1) + .total_price(None) + .created_time(cmd.created_time().clone()) + .store_id(*cmd.store_id()) + .deleted(false) + .build() + .unwrap(); + + let res = BillAddedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .bill(bill) + .build() + .unwrap(); + + if let Some(times) = times { + m.expect_add_bill() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_add_bill().returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service_bill_doesnt_exist() { + let cmd = AddBillCommand::get_cmd(); + + let s = AddBillServiceBuilder::default() + .db_bill_id_exists(mock_bill_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_next_token_id(mock_next_token_id_db_port(IS_CALLED_ONLY_ONCE)) + .get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.add_bill(cmd.clone()).await.unwrap(); + assert_eq!(res.bill().created_time(), cmd.created_time()); + assert_eq!(res.bill().store_id(), cmd.store_id()); + assert_eq!(res.added_by_user(), cmd.adding_by()); + } +} diff --git a/src/billing/application/services/add_store_service.rs b/src/billing/application/services/add_store_service.rs index 6fd2c53..678662a 100644 --- a/src/billing/application/services/add_store_service.rs +++ b/src/billing/application/services/add_store_service.rs @@ -22,7 +22,7 @@ use crate::utils::uuid::*; #[automock] #[async_trait::async_trait] pub trait AddStoreUseCase: Send + Sync { - async fn add_store(&self, cmd: AddStoreCommand) -> BillResult; + async fn add_store(&self, cmd: AddStoreCommand) -> BillingResult; } pub type AddStoreServiceObj = Arc; @@ -36,7 +36,7 @@ pub struct AddStoreService { #[async_trait::async_trait] impl AddStoreUseCase for AddStoreService { - async fn add_store(&self, cmd: AddStoreCommand) -> BillResult { + async fn add_store(&self, cmd: AddStoreCommand) -> BillingResult { let mut store_id = self.get_uuid.get_uuid(); loop { @@ -57,7 +57,7 @@ impl AddStoreUseCase for AddStoreService { .unwrap(); if self.db_store_name_exists.store_name_exists(&store).await? { - return Err(BillError::DuplicateStoreName); + return Err(BillingError::DuplicateStoreName); } Ok(StoreAddedEventBuilder::default() @@ -143,7 +143,7 @@ pub mod tests { assert_eq!( s.add_store(cmd.clone()).await, - Err(BillError::DuplicateStoreName) + Err(BillingError::DuplicateStoreName) ); } } diff --git a/src/billing/application/services/delete_bill_service.rs b/src/billing/application/services/delete_bill_service.rs new file mode 100644 index 0000000..25a4cdc --- /dev/null +++ b/src/billing/application/services/delete_bill_service.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; + +use super::errors::*; +use crate::billing::{ + application::port::output::db::bill_id_exists::*, + domain::{bill_deleted_event::*, delete_bill_command::DeleteBillCommand}, +}; + +#[automock] +#[async_trait::async_trait] +pub trait DeleteBillUseCase: Send + Sync { + async fn delete_bill(&self, cmd: DeleteBillCommand) -> BillingResult; +} + +pub type DeleteBillServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct DeleteBillService { + db_bill_id_exists: BillIDExistsDBPortObj, +} + +#[async_trait::async_trait] +impl DeleteBillUseCase for DeleteBillService { + async fn delete_bill(&self, cmd: DeleteBillCommand) -> BillingResult { + if !self + .db_bill_id_exists + .bill_id_exists(cmd.bill().bill_id()) + .await? + { + return Err(BillingError::BillIDNotFound); + } + + Ok(BillDeletedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .bill(cmd.bill().clone()) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::billing::domain::bill_deleted_event::tests::get_deleted_bill_event_from_command; + use crate::tests::bdd::*; + + pub fn mock_delete_bill_service( + times: Option, + cmd: DeleteBillCommand, + ) -> DeleteBillServiceObj { + let mut m = MockDeleteBillUseCase::new(); + + let res = get_deleted_bill_event_from_command(&cmd); + + if let Some(times) = times { + m.expect_delete_bill() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_delete_bill().returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service() { + let cmd = DeleteBillCommand::get_cmd(); + + let s = DeleteBillServiceBuilder::default() + .db_bill_id_exists(mock_bill_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.delete_bill(cmd.clone()).await.unwrap(); + assert_eq!(res.bill(), cmd.bill()); + assert_eq!(res.added_by_user(), cmd.adding_by()); + } + + #[actix_rt::test] + async fn test_service_bill_id_doesnt_exist() { + let cmd = DeleteBillCommand::get_cmd(); + + let s = DeleteBillServiceBuilder::default() + .db_bill_id_exists(mock_bill_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.delete_bill(cmd.clone()).await, + Err(BillingError::BillIDNotFound) + ); + } +} diff --git a/src/billing/application/services/mod.rs b/src/billing/application/services/mod.rs index 56f60de..595f3c5 100644 --- a/src/billing/application/services/mod.rs +++ b/src/billing/application/services/mod.rs @@ -1,3 +1,71 @@ // SPDX-FileCopyrightText: 2024 Aravinth Manivannan // // SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; + +pub mod errors; + +// services +pub mod add_bill_service; +pub mod add_line_item_service; +pub mod add_store_service; +pub mod delete_bill_service; +pub mod delete_line_item_service; +pub mod update_bill_service; +pub mod update_line_item_service; +pub mod update_store_service; + +#[automock] +pub trait BillingServicesInterface: Send + Sync { + fn add_bill(&self) -> add_bill_service::AddBillServiceObj; + fn update_bill(&self) -> update_bill_service::UpdateBillServiceObj; + fn delete_bill(&self) -> delete_bill_service::DeleteBillServiceObj; + fn add_store(&self) -> add_store_service::AddStoreServiceObj; + fn update_store(&self) -> update_store_service::UpdateStoreServiceObj; + fn add_line_item(&self) -> add_line_item_service::AddLineItemServiceObj; + fn update_line_item(&self) -> update_line_item_service::UpdateLineItemServiceObj; + fn delete_line_item(&self) -> delete_line_item_service::DeleteLineItemServiceObj; +} + +#[derive(Clone, Builder)] +pub struct BillingServices { + add_bill: add_bill_service::AddBillServiceObj, + add_store: add_store_service::AddStoreServiceObj, + update_store: update_store_service::UpdateStoreServiceObj, + add_line_item: add_line_item_service::AddLineItemServiceObj, + update_line_item: update_line_item_service::UpdateLineItemServiceObj, + delete_line_item: delete_line_item_service::DeleteLineItemServiceObj, + update_bill: update_bill_service::UpdateBillServiceObj, + delete_bill: delete_bill_service::DeleteBillServiceObj, +} + +impl BillingServicesInterface for BillingServices { + fn add_bill(&self) -> add_bill_service::AddBillServiceObj { + self.add_bill.clone() + } + fn update_bill(&self) -> update_bill_service::UpdateBillServiceObj { + self.update_bill.clone() + } + fn delete_bill(&self) -> delete_bill_service::DeleteBillServiceObj { + self.delete_bill.clone() + } + + fn add_store(&self) -> add_store_service::AddStoreServiceObj { + self.add_store.clone() + } + fn update_store(&self) -> update_store_service::UpdateStoreServiceObj { + self.update_store.clone() + } + fn add_line_item(&self) -> add_line_item_service::AddLineItemServiceObj { + self.add_line_item.clone() + } + fn update_line_item(&self) -> update_line_item_service::UpdateLineItemServiceObj { + self.update_line_item.clone() + } + fn delete_line_item(&self) -> delete_line_item_service::DeleteLineItemServiceObj { + self.delete_line_item.clone() + } +} diff --git a/src/billing/application/services/update_bill_service.rs b/src/billing/application/services/update_bill_service.rs new file mode 100644 index 0000000..96a95f0 --- /dev/null +++ b/src/billing/application/services/update_bill_service.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; + +use super::errors::*; +use crate::billing::{ + application::port::output::db::bill_id_exists::*, + domain::{bill_aggregate::*, bill_updated_event::*, update_bill_command::UpdateBillCommand}, +}; + +#[automock] +#[async_trait::async_trait] +pub trait UpdateBillUseCase: Send + Sync { + async fn update_bill(&self, cmd: UpdateBillCommand) -> BillingResult; +} + +pub type UpdateBillServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct UpdateBillService { + db_bill_id_exists: BillIDExistsDBPortObj, +} + +#[async_trait::async_trait] +impl UpdateBillUseCase for UpdateBillService { + async fn update_bill(&self, cmd: UpdateBillCommand) -> BillingResult { + if !self + .db_bill_id_exists + .bill_id_exists(cmd.old_bill().bill_id()) + .await? + { + return Err(BillingError::BillIDNotFound); + } + + let bill = BillBuilder::default() + .bill_id(*cmd.old_bill().bill_id()) + .token_number(*cmd.old_bill().token_number()) + .total_price(cmd.total_price().clone()) + .store_id(*cmd.store_id()) + .deleted(false) + .build() + .unwrap(); + + Ok(BillUpdatedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .new_bill(bill) + .old_bill(cmd.old_bill().clone()) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::billing::domain::bill_updated_event::tests::get_updated_bill_event_from_command; + use crate::tests::bdd::*; + use crate::utils::uuid::tests::*; + + pub fn mock_update_bill_service( + times: Option, + cmd: UpdateBillCommand, + ) -> UpdateBillServiceObj { + let mut m = MockUpdateBillUseCase::new(); + + let res = get_updated_bill_event_from_command(&cmd); + + if let Some(times) = times { + m.expect_update_bill() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_update_bill().returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service() { + let cmd = UpdateBillCommand::get_cmd(); + + let s = UpdateBillServiceBuilder::default() + .db_bill_id_exists(mock_bill_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.update_bill(cmd.clone()).await.unwrap(); + assert_eq!(res.new_bill().total_price(), cmd.total_price()); + assert_eq!(res.new_bill().bill_id(), cmd.old_bill().bill_id()); + assert_eq!(res.old_bill(), cmd.old_bill()); + assert_eq!(res.added_by_user(), cmd.adding_by()); + } + + #[actix_rt::test] + async fn test_service_bill_id_doesnt_exist() { + let cmd = UpdateBillCommand::get_cmd(); + + let s = UpdateBillServiceBuilder::default() + .db_bill_id_exists(mock_bill_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.update_bill(cmd.clone()).await, + Err(BillingError::BillIDNotFound) + ); + } +} diff --git a/src/billing/application/services/update_store_service.rs b/src/billing/application/services/update_store_service.rs index 05d9efa..4d3f923 100644 --- a/src/billing/application/services/update_store_service.rs +++ b/src/billing/application/services/update_store_service.rs @@ -20,7 +20,7 @@ use crate::utils::uuid::*; #[automock] #[async_trait::async_trait] pub trait UpdateStoreUseCase: Send + Sync { - async fn update_store(&self, cmd: UpdateStoreCommand) -> BillResult; + async fn update_store(&self, cmd: UpdateStoreCommand) -> BillingResult; } pub type UpdateStoreServiceObj = Arc; @@ -33,13 +33,13 @@ pub struct UpdateStoreService { #[async_trait::async_trait] impl UpdateStoreUseCase for UpdateStoreService { - async fn update_store(&self, cmd: UpdateStoreCommand) -> BillResult { + async fn update_store(&self, cmd: UpdateStoreCommand) -> BillingResult { if !self .db_store_id_exists .store_id_exists(cmd.old_store().store_id()) .await? { - return Err(BillError::StoreIDNotFound); + return Err(BillingError::StoreIDNotFound); } let store = StoreBuilder::default() @@ -52,7 +52,7 @@ impl UpdateStoreUseCase for UpdateStoreService { if cmd.name() != cmd.old_store().name() { if self.db_store_name_exists.store_name_exists(&store).await? { - return Err(BillError::DuplicateStoreName); + return Err(BillingError::DuplicateStoreName); } } @@ -124,7 +124,7 @@ pub mod tests { assert_eq!( s.update_store(cmd.clone()).await, - Err(BillError::DuplicateStoreName) + Err(BillingError::DuplicateStoreName) ); } @@ -140,7 +140,7 @@ pub mod tests { assert_eq!( s.update_store(cmd.clone()).await, - Err(BillError::StoreIDNotFound) + Err(BillingError::StoreIDNotFound) ); } } diff --git a/src/billing/domain/add_bill_command.rs b/src/billing/domain/add_bill_command.rs new file mode 100644 index 0000000..21aabb5 --- /dev/null +++ b/src/billing/domain/add_bill_command.rs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::types::{currency::*, quantity::*}; +use crate::utils::string::empty_string_err; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct AddBillCommand { + adding_by: Uuid, + + #[builder(default = "OffsetDateTime::now_utc()")] + created_time: OffsetDateTime, + + store_id: Uuid, +} + +#[cfg(test)] +mod tests { + use time::macros::datetime; + + use crate::utils::uuid::tests::UUID; + + use super::*; + + impl AddBillCommand { + pub fn get_cmd() -> Self { + let store_id = UUID; + let adding_by = UUID; + + AddBillCommandBuilder::default() + .adding_by(adding_by) + .created_time(datetime!(1970-01-01 0:00 UTC)) + .store_id(store_id) + .build() + .unwrap() + } + } + + #[test] + fn test_cmd() { + let store_id = UUID; + let adding_by = UUID; + + let cmd = AddBillCommandBuilder::default() + .adding_by(adding_by) + .store_id(store_id) + .build() + .unwrap(); + + assert_eq!(*cmd.store_id(), store_id); + assert_eq!(*cmd.adding_by(), adding_by); + } +} diff --git a/src/billing/domain/bill_added_event.rs b/src/billing/domain/bill_added_event.rs new file mode 100644 index 0000000..9bf3a46 --- /dev/null +++ b/src/billing/domain/bill_added_event.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::bill_aggregate::Bill; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct BillAddedEvent { + added_by_user: Uuid, + + bill: Bill, +} + +#[cfg(test)] +pub mod tests { + use crate::billing::domain::add_bill_command::AddBillCommand; + + use super::*; + + pub fn get_added_bill_event_from_command(cmd: &AddBillCommand) -> BillAddedEvent { + let bill = Bill::get_bill(); + + BillAddedEventBuilder::default() + .added_by_user(cmd.adding_by().clone()) + .bill(bill) + .build() + .unwrap() + } + + #[test] + fn test_event() { + get_added_bill_event_from_command(&AddBillCommand::get_cmd()); + } +} diff --git a/src/billing/domain/bill_aggregate.rs b/src/billing/domain/bill_aggregate.rs new file mode 100644 index 0000000..ec8c347 --- /dev/null +++ b/src/billing/domain/bill_aggregate.rs @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use async_trait::async_trait; +use cqrs_es::Aggregate; +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::types::currency::*; + +use crate::billing::{ + application::services::{errors::*, *}, + domain::{commands::*, events::*}, +}; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Builder, Getters, +)] +pub struct Bill { + #[builder(default = "OffsetDateTime::now_utc()")] + created_time: OffsetDateTime, + // bills: Vec, + bill_id: Uuid, + token_number: usize, + #[builder(default = "None")] + total_price: Option, + store_id: Uuid, + + #[builder(default = "false")] + deleted: bool, +} + +impl Default for Bill { + fn default() -> Self { + Self { + created_time: OffsetDateTime::now_utc(), + bill_id: Default::default(), + token_number: 1, + total_price: None, + store_id: Uuid::new_v4(), + deleted: false, + } + } +} + +#[cfg(test)] +pub mod tests { + use crate::{billing::domain::add_bill_command::AddBillCommand, utils::uuid::tests::UUID}; + + use super::*; + + impl Bill { + pub fn get_bill() -> Self { + let cmd = AddBillCommand::get_cmd(); + + BillBuilder::default() + .created_time(cmd.created_time().clone()) + .bill_id(UUID) + .store_id(UUID) + .token_number(1) + .build() + .unwrap() + } + } +} +// +#[async_trait] +impl Aggregate for Bill { + type Command = BillingCommand; + type Event = BillingEvent; + type Error = BillingError; + type Services = std::sync::Arc; + + // This identifier should be unique to the system. + fn aggregate_type() -> String { + "billing.bill".to_string() + } + + // The aggregate logic goes here. Note that this will be the _bulk_ of a CQRS system + // so expect to use helper functions elsewhere to keep the code clean. + async fn handle( + &self, + command: Self::Command, + services: &Self::Services, + ) -> Result, Self::Error> { + match command { + BillingCommand::AddBill(cmd) => { + let res = services.add_bill().add_bill(cmd).await?; + Ok(vec![BillingEvent::BillAdded(res)]) + } + BillingCommand::UpdateBill(cmd) => { + let res = services.update_bill().update_bill(cmd).await?; + Ok(vec![BillingEvent::BillUpdated(res)]) + } + BillingCommand::DeleteBill(cmd) => { + let res = services.delete_bill().delete_bill(cmd).await?; + Ok(vec![BillingEvent::BillDeleted(res)]) + } + _ => Ok(Vec::default()), + } + } + + fn apply(&mut self, event: Self::Event) { + match event { + BillingEvent::BillAdded(e) => *self = e.bill().clone(), + BillingEvent::BillUpdated(e) => *self = e.new_bill().clone(), + BillingEvent::BillDeleted(e) => *self = e.bill().clone(), + _ => (), + } + } +} + +#[cfg(test)] +mod aggregate_tests { + use std::sync::Arc; + + use add_bill_service::tests::mock_add_bill_service; + use cqrs_es::test::TestFramework; + use delete_bill_service::tests::mock_delete_bill_service; + use update_bill_service::tests::mock_update_bill_service; + + use super::*; + + use crate::billing::domain::bill_deleted_event::tests::get_deleted_bill_event_from_command; + use crate::billing::domain::bill_updated_event::tests::get_updated_bill_event_from_command; + use crate::billing::domain::delete_bill_command::DeleteBillCommand; + use crate::billing::domain::update_bill_command::UpdateBillCommand; + use crate::tests::bdd::*; + + use crate::billing::domain::{ + add_bill_command::*, bill_added_event::tests::get_added_bill_event_from_command, + }; + + type BillTestFramework = TestFramework; + + #[test] + fn test_add_bill() { + let cmd = AddBillCommand::get_cmd(); + let expected = get_added_bill_event_from_command(&cmd); + let expected = BillingEvent::BillAdded(expected); + + let mut services = MockBillingServicesInterface::new(); + services + .expect_add_bill() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_add_bill_service(IS_CALLED_ONLY_ONCE, cmd.clone())); + + BillTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::AddBill(cmd)) + .then_expect_events(vec![expected]); + } + + #[test] + fn test_update_bill() { + let cmd = UpdateBillCommand::get_cmd(); + let expected = get_updated_bill_event_from_command(&cmd); + let expected = BillingEvent::BillUpdated(expected); + + let mut services = MockBillingServicesInterface::new(); + services + .expect_update_bill() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_update_bill_service(IS_CALLED_ONLY_ONCE, cmd.clone())); + + BillTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::UpdateBill(cmd)) + .then_expect_events(vec![expected]); + } + + #[test] + fn test_delete_bill() { + let cmd = DeleteBillCommand::get_cmd(); + let expected = get_deleted_bill_event_from_command(&cmd); + let expected = BillingEvent::BillDeleted(expected); + + let mut services = MockBillingServicesInterface::new(); + services + .expect_delete_bill() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_delete_bill_service(IS_CALLED_ONLY_ONCE, cmd.clone())); + + BillTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::DeleteBill(cmd)) + .then_expect_events(vec![expected]); + } +} diff --git a/src/billing/domain/bill_deleted_event.rs b/src/billing/domain/bill_deleted_event.rs new file mode 100644 index 0000000..5686258 --- /dev/null +++ b/src/billing/domain/bill_deleted_event.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::bill_aggregate::*; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct BillDeletedEvent { + added_by_user: Uuid, + + bill: Bill, +} + +#[cfg(test)] +pub mod tests { + use crate::billing::domain::delete_bill_command::DeleteBillCommand; + + use super::*; + + pub fn get_deleted_bill_event_from_command(cmd: &DeleteBillCommand) -> BillDeletedEvent { + let deleted_bill = BillBuilder::default() + .created_time(cmd.bill().created_time().clone()) + .store_id(*cmd.bill().store_id()) + .bill_id(*cmd.bill().bill_id()) + .token_number(1) + .deleted(true) + .build() + .unwrap(); + + BillDeletedEventBuilder::default() + .added_by_user(cmd.adding_by().clone()) + .bill(deleted_bill) + .build() + .unwrap() + } + + #[test] + fn test_event() { + let event = get_deleted_bill_event_from_command(&DeleteBillCommand::get_cmd()); + assert!(event.bill().deleted()); + } +} diff --git a/src/billing/domain/bill_updated_event.rs b/src/billing/domain/bill_updated_event.rs new file mode 100644 index 0000000..5622045 --- /dev/null +++ b/src/billing/domain/bill_updated_event.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::bill_aggregate::*; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct BillUpdatedEvent { + added_by_user: Uuid, + + new_bill: Bill, + old_bill: Bill, +} + +#[cfg(test)] +pub mod tests { + use crate::billing::domain::update_bill_command::UpdateBillCommand; + + use super::*; + + pub fn get_updated_bill_event_from_command(cmd: &UpdateBillCommand) -> BillUpdatedEvent { + let new_bill = BillBuilder::default() + .created_time(cmd.created_time().clone()) + .token_number(1) + .store_id(*cmd.store_id()) + .bill_id(*cmd.old_bill().bill_id()) + .build() + .unwrap(); + + BillUpdatedEventBuilder::default() + .added_by_user(cmd.adding_by().clone()) + .old_bill(cmd.old_bill().clone()) + .new_bill(new_bill) + .build() + .unwrap() + } + + #[test] + fn test_event() { + get_updated_bill_event_from_command(&UpdateBillCommand::get_cmd()); + } +} diff --git a/src/billing/domain/commands.rs b/src/billing/domain/commands.rs new file mode 100644 index 0000000..3d92164 --- /dev/null +++ b/src/billing/domain/commands.rs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use serde::{Deserialize, Serialize}; + +use super::{ + add_bill_command::AddBillCommand, add_line_item_command::AddLineItemCommand, + add_store_command::AddStoreCommand, delete_bill_command::DeleteBillCommand, + delete_line_item_command::DeleteLineItemCommand, update_bill_command::UpdateBillCommand, + update_line_item_command::UpdateLineItemCommand, update_store_command::UpdateStoreCommand, +}; + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +pub enum BillingCommand { + AddLineItem(AddLineItemCommand), + UpdateLineItem(UpdateLineItemCommand), + DeleteLineItem(DeleteLineItemCommand), + AddBill(AddBillCommand), + UpdateBill(UpdateBillCommand), + DeleteBill(DeleteBillCommand), + AddStore(AddStoreCommand), + UpdateStore(UpdateStoreCommand), +} diff --git a/src/billing/domain/delete_bill_command.rs b/src/billing/domain/delete_bill_command.rs new file mode 100644 index 0000000..88a6d89 --- /dev/null +++ b/src/billing/domain/delete_bill_command.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::bill_aggregate::Bill; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Builder, Getters, +)] +pub struct DeleteBillCommand { + adding_by: Uuid, + bill: Bill, +} + +#[cfg(test)] +mod tests { + use crate::utils::uuid::tests::UUID; + + use super::*; + + impl DeleteBillCommand { + pub fn get_cmd() -> Self { + let adding_by = UUID; + + DeleteBillCommandBuilder::default() + .adding_by(adding_by) + .bill(Bill::get_bill()) + .build() + .unwrap() + } + } +} diff --git a/src/billing/domain/events.rs b/src/billing/domain/events.rs new file mode 100644 index 0000000..f947d4c --- /dev/null +++ b/src/billing/domain/events.rs @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use cqrs_es::DomainEvent; +use serde::{Deserialize, Serialize}; + +use super::{ + bill_added_event::BillAddedEvent, bill_deleted_event::BillDeletedEvent, + bill_updated_event::BillUpdatedEvent, line_item_added_event::LineItemAddedEvent, + line_item_deleted_event::LineItemDeletedEvent, line_item_updated_event::LineItemUpdatedEvent, + store_added_event::StoreAddedEvent, store_updated_event::StoreUpdatedEvent, +}; + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +pub enum BillingEvent { + LineItemAdded(LineItemAddedEvent), + LineItemUpdated(LineItemUpdatedEvent), + LineItemDeleted(LineItemDeletedEvent), + BillAdded(BillAddedEvent), + BillUpdated(BillUpdatedEvent), + BillDeleted(BillDeletedEvent), + StoreAdded(StoreAddedEvent), + StoreUpdated(StoreUpdatedEvent), +} + +impl DomainEvent for BillingEvent { + fn event_version(&self) -> String { + "1.0".to_string() + } + + fn event_type(&self) -> String { + let e: &str = match self { + BillingEvent::LineItemAdded { .. } => "BillingLineItemAdded", + BillingEvent::LineItemUpdated { .. } => "BillingLineItemUpdated", + BillingEvent::LineItemDeleted { .. } => "BillingLineItemDeleted", + BillingEvent::BillAdded { .. } => "BillingBilAdded", + BillingEvent::BillUpdated { .. } => "BillingBilUpdated", + BillingEvent::BillDeleted { .. } => "BillingBilDeleted", + BillingEvent::StoreAdded { .. } => "BillingStoreAdded", + BillingEvent::StoreUpdated { .. } => "BillingStoreUpdated", + }; + + e.to_string() + } +} diff --git a/src/billing/domain/mod.rs b/src/billing/domain/mod.rs index 56f60de..cd8aa4d 100644 --- a/src/billing/domain/mod.rs +++ b/src/billing/domain/mod.rs @@ -1,3 +1,30 @@ // SPDX-FileCopyrightText: 2024 Aravinth Manivannan // // SPDX-License-Identifier: AGPL-3.0-or-later + +// aggregates +pub mod bill_aggregate; +pub mod line_item_aggregate; +pub mod store_aggregate; + +// commands +pub mod add_bill_command; +pub mod add_line_item_command; +pub mod add_store_command; +pub mod commands; +pub mod delete_bill_command; +pub mod delete_line_item_command; +pub mod update_bill_command; +pub mod update_line_item_command; +pub mod update_store_command; + +// events; +pub mod bill_added_event; +pub mod bill_deleted_event; +pub mod bill_updated_event; +pub mod events; +pub mod line_item_added_event; +pub mod line_item_deleted_event; +pub mod line_item_updated_event; +pub mod store_added_event; +pub mod store_updated_event; diff --git a/src/billing/domain/store_aggregate.rs b/src/billing/domain/store_aggregate.rs index cb4d7dd..c73e0cb 100644 --- a/src/billing/domain/store_aggregate.rs +++ b/src/billing/domain/store_aggregate.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::billing::application::services::errors::*; -use crate::billing::application::services::BillServicesInterface; +use crate::billing::application::services::*; use super::{commands::BillingCommand, events::BillingEvent}; @@ -30,8 +30,8 @@ pub struct Store { impl Aggregate for Store { type Command = BillingCommand; type Event = BillingEvent; - type Error = BillError; - type Services = std::sync::Arc; + type Error = BillingError; + type Services = std::sync::Arc; // This identifier should be unique to the system. fn aggregate_type() -> String { @@ -83,7 +83,7 @@ mod tests { use super::*; use crate::billing::{ - application::services::{add_store_service::tests::*, *}, + application::services::add_store_service::tests::*, domain::{ add_store_command::*, commands::BillingCommand, events::BillingEvent, store_added_event::*, store_updated_event::tests::get_store_updated_event_from_command, @@ -115,7 +115,7 @@ mod tests { let cmd = AddStoreCommand::new(name.into(), address.clone(), owner).unwrap(); - let mut services = MockBillServicesInterface::new(); + let mut services = MockBillingServicesInterface::new(); services .expect_add_store() .times(IS_CALLED_ONLY_ONCE.unwrap()) @@ -132,7 +132,7 @@ mod tests { let cmd = get_update_store_cmd(); let expected = BillingEvent::StoreUpdated(get_store_updated_event_from_command(&cmd)); - let mut services = MockBillServicesInterface::new(); + let mut services = MockBillingServicesInterface::new(); services .expect_update_store() .times(IS_CALLED_ONLY_ONCE.unwrap()) diff --git a/src/billing/domain/update_bill_command.rs b/src/billing/domain/update_bill_command.rs new file mode 100644 index 0000000..d8d1ff0 --- /dev/null +++ b/src/billing/domain/update_bill_command.rs @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use super::bill_aggregate::Bill; +use crate::types::currency::*; + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct UpdateBillCommand { + adding_by: Uuid, + + #[builder(default = "OffsetDateTime::now_utc()")] + created_time: OffsetDateTime, + + store_id: Uuid, + token_number: usize, + total_price: Option, + + old_bill: Bill, +} + +#[cfg(test)] +mod tests { + use crate::{billing::domain::bill_aggregate::*, utils::uuid::tests::UUID}; + + use super::*; + + impl UpdateBillCommand { + pub fn get_cmd() -> Self { + let store_id = UUID; + let adding_by = UUID; + + UpdateBillCommandBuilder::default() + .adding_by(adding_by) + .store_id(store_id) + .total_price(None) + .token_number(1) + .old_bill(Bill::get_bill()) + .build() + .unwrap() + } + } + + #[test] + fn test_cmd() { + let store_id = UUID; + let adding_by = UUID; + let old_bill = Bill::get_bill(); + + let cmd = UpdateBillCommandBuilder::default() + .adding_by(adding_by) + .store_id(store_id) + .total_price(None) + .token_number(1) + .old_bill(old_bill.clone()) + .build() + .unwrap(); + + assert_eq!(*cmd.store_id(), store_id); + assert_eq!(*cmd.adding_by(), adding_by); + assert_eq!(cmd.old_bill(), &old_bill); + } +}