diff --git a/src/billing/application/port/output/db/get_line_items_for_bill_id.rs b/src/billing/application/port/output/db/get_line_items_for_bill_id.rs new file mode 100644 index 0000000..2d1682d --- /dev/null +++ b/src/billing/application/port/output/db/get_line_items_for_bill_id.rs @@ -0,0 +1,61 @@ +// 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::line_item_aggregate::LineItem; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait GetLineItemsForBillIDDBPort: Send + Sync { + async fn get_line_items_for_bill_id(&self, bill_id: Uuid) -> BillingDBResult>; +} + +pub type GetLineItemsForBillIDDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_get_line_items_for_bill_id_db_port_no_line_items( + times: Option, + ) -> GetLineItemsForBillIDDBPortObj { + let mut m = MockGetLineItemsForBillIDDBPort::new(); + if let Some(times) = times { + m.expect_get_line_items_for_bill_id() + .times(times) + .returning(|_| Ok(Vec::default())); + } else { + m.expect_get_line_items_for_bill_id() + .returning(|_| Ok(Vec::default())); + } + + Arc::new(m) + } + + pub fn mock_get_line_items_for_bill_id_db_port_true( + times: Option, + ) -> GetLineItemsForBillIDDBPortObj { + let mut m = MockGetLineItemsForBillIDDBPort::new(); + if let Some(times) = times { + m.expect_get_line_items_for_bill_id() + .times(times) + .returning(|_| Ok(vec![LineItem::default()])); + } else { + m.expect_get_line_items_for_bill_id() + .returning(|_| Ok(vec![LineItem::default()])); + } + + Arc::new(m) + } +} diff --git a/src/billing/application/port/output/db/mod.rs b/src/billing/application/port/output/db/mod.rs index a044ac0..744db6c 100644 --- a/src/billing/application/port/output/db/mod.rs +++ b/src/billing/application/port/output/db/mod.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pub mod bill_id_exists; pub mod errors; +pub mod get_line_items_for_bill_id; pub mod line_item_id_exists; pub mod next_token_id; pub mod store_id_exists; diff --git a/src/billing/application/services/compute_bill_total_price_service.rs b/src/billing/application/services/compute_bill_total_price_service.rs new file mode 100644 index 0000000..94c669a --- /dev/null +++ b/src/billing/application/services/compute_bill_total_price_service.rs @@ -0,0 +1,210 @@ +// 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::*, + application::port::output::db::get_line_items_for_bill_id::*, + domain::{ + bill_aggregate::*, bill_total_price_computed_event::*, + compute_bill_total_price_command::*, + }, + }, + types::currency::*, +}; + +#[automock] +#[async_trait::async_trait] +pub trait ComputeBillTotalPriceBillUseCase: Send + Sync { + async fn compute_total_price_for_bill( + &self, + cmd: ComputeBillTotalPriceBillCommand, + ) -> BillingResult; +} + +pub type ComputeBillTotalPriceBillServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct ComputeBillTotalPriceBillService { + db_bill_id_exists: BillIDExistsDBPortObj, + db_get_line_items_for_bill_id: GetLineItemsForBillIDDBPortObj, +} + +#[async_trait::async_trait] +impl ComputeBillTotalPriceBillUseCase for ComputeBillTotalPriceBillService { + async fn compute_total_price_for_bill( + &self, + cmd: ComputeBillTotalPriceBillCommand, + ) -> BillingResult { + if !self.db_bill_id_exists.bill_id_exists(cmd.bill_id()).await? { + return Err(BillingError::BillIDNotFound); + } + + let line_items = self + .db_get_line_items_for_bill_id + .get_line_items_for_bill_id(*cmd.bill_id()) + .await?; + if line_items.is_empty() { + todo!("Can't compute bill"); + } + + let mut total_price = PriceBuilder::default() + .major(0) + .minor(0) + .currency(Currency::INR) + .build() + .unwrap(); + + for li in line_items.iter() { + total_price = total_price.clone() + li.total_price(); + } + // TODO: 3. Tax? + + Ok(BillTotalPriceComputedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .bill_id(*cmd.bill_id()) + .total_price(total_price) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use time::macros::datetime; + + use super::*; + + use crate::billing::domain::bill_total_price_computed_event::tests::get_bill_total_computed_event_from_command; + use crate::billing::domain::line_item_aggregate::LineItemBuilder; + use crate::tests::bdd::*; + use crate::types::quantity::*; + use crate::utils::uuid::tests::*; + + pub fn mock_compute_bill_total_price_service( + times: Option, + cmd: ComputeBillTotalPriceBillCommand, + ) -> ComputeBillTotalPriceBillServiceObj { + let mut m = MockComputeBillTotalPriceBillUseCase::new(); + + let res = get_bill_total_computed_event_from_command(&cmd); + + if let Some(times) = times { + m.expect_compute_total_price_for_bill() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_compute_total_price_for_bill() + .returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service() { + let cmd = ComputeBillTotalPriceBillCommand::get_cmd(); + + let s = ComputeBillTotalPriceBillServiceBuilder::default() + .db_bill_id_exists(mock_bill_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_get_line_items_for_bill_id(mock_get_line_items_for_bill_id_db_port_true( + IS_CALLED_ONLY_ONCE, + )) + .build() + .unwrap(); + + let res = s.compute_total_price_for_bill(cmd.clone()).await.unwrap(); + assert_eq!(res.total_price().clone(), Price::default()); + assert_eq!(res.bill_id(), cmd.bill_id()); + assert_eq!(res.added_by_user(), cmd.adding_by()); + } + + #[actix_rt::test] + async fn test_service_sum() { + let cmd = ComputeBillTotalPriceBillCommand::get_cmd(); + + let li = LineItemBuilder::default() + .created_time(datetime!(1970-01-01 0:00 UTC)) + .product_name("test_product".into()) + .product_id(UUID) + .quantity( + QuantityBuilder::default() + .major( + QuantityPartBuilder::default() + .number(2) + .unit(QuantityUnit::DiscreteNumber) + .build() + .unwrap(), + ) + .minor( + QuantityPartBuilder::default() + .number(1) + .unit(QuantityUnit::DiscreteNumber) + .build() + .unwrap(), + ) + .build() + .unwrap(), + ) + .bill_id(UUID) + .price_per_unit( + PriceBuilder::default() + .major(100) + .minor(20) + .currency(Currency::INR) + .build() + .unwrap(), + ) + .line_item_id(UUID) + .build() + .unwrap(); + + let mut m = MockGetLineItemsForBillIDDBPort::new(); + m.expect_get_line_items_for_bill_id() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .returning(move |_| Ok(vec![li.clone(), li.clone()])); + + let s = ComputeBillTotalPriceBillServiceBuilder::default() + .db_bill_id_exists(mock_bill_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_get_line_items_for_bill_id(std::sync::Arc::new(m)) + .build() + .unwrap(); + + let res = s.compute_total_price_for_bill(cmd.clone()).await.unwrap(); + assert_eq!( + res.total_price().clone(), + PriceBuilder::default() + .major(100 * 2 * 2) + .minor(20 * 2 * 2) + .currency(Currency::INR) + .build() + .unwrap() + ); + } + + #[actix_rt::test] + async fn test_service_bill_id_doesnt_exist() { + let cmd = ComputeBillTotalPriceBillCommand::get_cmd(); + + let s = ComputeBillTotalPriceBillServiceBuilder::default() + .db_bill_id_exists(mock_bill_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_get_line_items_for_bill_id(mock_get_line_items_for_bill_id_db_port_true( + IS_NEVER_CALLED, + )) + .build() + .unwrap(); + + assert_eq!( + s.compute_total_price_for_bill(cmd.clone()).await, + Err(BillingError::BillIDNotFound) + ); + } +} diff --git a/src/billing/application/services/mod.rs b/src/billing/application/services/mod.rs index 595f3c5..4b04e5a 100644 --- a/src/billing/application/services/mod.rs +++ b/src/billing/application/services/mod.rs @@ -12,11 +12,13 @@ pub mod errors; pub mod add_bill_service; pub mod add_line_item_service; pub mod add_store_service; +pub mod compute_bill_total_price_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; +// TODO: 2. reset token number for store_id cronjob #[automock] pub trait BillingServicesInterface: Send + Sync { @@ -28,6 +30,9 @@ pub trait BillingServicesInterface: Send + Sync { 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; + fn compute_total_price_for_bill( + &self, + ) -> compute_bill_total_price_service::ComputeBillTotalPriceBillServiceObj; } #[derive(Clone, Builder)] @@ -40,6 +45,8 @@ pub struct BillingServices { delete_line_item: delete_line_item_service::DeleteLineItemServiceObj, update_bill: update_bill_service::UpdateBillServiceObj, delete_bill: delete_bill_service::DeleteBillServiceObj, + compute_total_price_for_bill: + compute_bill_total_price_service::ComputeBillTotalPriceBillServiceObj, } impl BillingServicesInterface for BillingServices { @@ -68,4 +75,9 @@ impl BillingServicesInterface for BillingServices { fn delete_line_item(&self) -> delete_line_item_service::DeleteLineItemServiceObj { self.delete_line_item.clone() } + fn compute_total_price_for_bill( + &self, + ) -> compute_bill_total_price_service::ComputeBillTotalPriceBillServiceObj { + self.compute_total_price_for_bill.clone() + } } diff --git a/src/billing/domain/bill_aggregate.rs b/src/billing/domain/bill_aggregate.rs index ec8c347..a286d4c 100644 --- a/src/billing/domain/bill_aggregate.rs +++ b/src/billing/domain/bill_aggregate.rs @@ -100,6 +100,13 @@ impl Aggregate for Bill { let res = services.delete_bill().delete_bill(cmd).await?; Ok(vec![BillingEvent::BillDeleted(res)]) } + BillingCommand::ComputeBillTotalPriceBill(cmd) => { + let res = services + .compute_total_price_for_bill() + .compute_total_price_for_bill(cmd) + .await?; + Ok(vec![BillingEvent::BillTotalPriceComputed(res)]) + } _ => Ok(Vec::default()), } } @@ -108,6 +115,9 @@ impl Aggregate for Bill { match event { BillingEvent::BillAdded(e) => *self = e.bill().clone(), BillingEvent::BillUpdated(e) => *self = e.new_bill().clone(), + BillingEvent::BillTotalPriceComputed(e) => { + self.total_price = Some(e.total_price().clone()); + } BillingEvent::BillDeleted(e) => *self = e.bill().clone(), _ => (), } @@ -119,6 +129,8 @@ mod aggregate_tests { use std::sync::Arc; use add_bill_service::tests::mock_add_bill_service; + use compute_bill_total_price_service::tests::mock_compute_bill_total_price_service; + use compute_bill_total_price_service::*; use cqrs_es::test::TestFramework; use delete_bill_service::tests::mock_delete_bill_service; use update_bill_service::tests::mock_update_bill_service; @@ -126,6 +138,8 @@ mod aggregate_tests { use super::*; use crate::billing::domain::bill_deleted_event::tests::get_deleted_bill_event_from_command; + use crate::billing::domain::bill_total_price_computed_event::tests::get_bill_total_computed_event_from_command; + use crate::billing::domain::bill_total_price_computed_event::BillTotalPriceComputedEvent; 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; @@ -133,6 +147,7 @@ mod aggregate_tests { use crate::billing::domain::{ add_bill_command::*, bill_added_event::tests::get_added_bill_event_from_command, + bill_total_price_computed_event::tests::*, compute_bill_total_price_command::*, }; type BillTestFramework = TestFramework; @@ -173,6 +188,27 @@ mod aggregate_tests { .then_expect_events(vec![expected]); } + #[test] + fn test_bill_total_price_computed() { + let cmd = ComputeBillTotalPriceBillCommand::get_cmd(); + let expected = get_bill_total_computed_event_from_command(&cmd); + let expected = BillingEvent::BillTotalPriceComputed(expected); + + let mut services = MockBillingServicesInterface::new(); + services + .expect_compute_total_price_for_bill() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_compute_bill_total_price_service( + IS_CALLED_ONLY_ONCE, + cmd.clone(), + )); + + BillTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(BillingCommand::ComputeBillTotalPriceBill(cmd)) + .then_expect_events(vec![expected]); + } + #[test] fn test_delete_bill() { let cmd = DeleteBillCommand::get_cmd(); diff --git a/src/billing/domain/bill_total_price_computed_event.rs b/src/billing/domain/bill_total_price_computed_event.rs new file mode 100644 index 0000000..100babd --- /dev/null +++ b/src/billing/domain/bill_total_price_computed_event.rs @@ -0,0 +1,44 @@ +// 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::*; +use crate::types::currency::*; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct BillTotalPriceComputedEvent { + added_by_user: Uuid, + bill_id: Uuid, + + total_price: Price, +} + +#[cfg(test)] +pub mod tests { + use crate::billing::domain::compute_bill_total_price_command::*; + + use super::*; + + pub fn get_bill_total_computed_event_from_command( + cmd: &ComputeBillTotalPriceBillCommand, + ) -> BillTotalPriceComputedEvent { + BillTotalPriceComputedEventBuilder::default() + .added_by_user(cmd.adding_by().clone()) + .bill_id(*cmd.bill_id()) + .total_price(Price::default()) + .build() + .unwrap() + } + + #[test] + fn test_event() { + get_bill_total_computed_event_from_command(&ComputeBillTotalPriceBillCommand::get_cmd()); + } +} diff --git a/src/billing/domain/commands.rs b/src/billing/domain/commands.rs index 3d92164..8290b9e 100644 --- a/src/billing/domain/commands.rs +++ b/src/billing/domain/commands.rs @@ -7,9 +7,11 @@ 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, + add_store_command::AddStoreCommand, + compute_bill_total_price_command::ComputeBillTotalPriceBillCommand, + 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)] @@ -20,6 +22,7 @@ pub enum BillingCommand { AddBill(AddBillCommand), UpdateBill(UpdateBillCommand), DeleteBill(DeleteBillCommand), + ComputeBillTotalPriceBill(ComputeBillTotalPriceBillCommand), AddStore(AddStoreCommand), UpdateStore(UpdateStoreCommand), } diff --git a/src/billing/domain/compute_bill_total_price_command.rs b/src/billing/domain/compute_bill_total_price_command.rs new file mode 100644 index 0000000..122370c --- /dev/null +++ b/src/billing/domain/compute_bill_total_price_command.rs @@ -0,0 +1,66 @@ +// 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 ComputeBillTotalPriceBillCommand { + adding_by: Uuid, + + #[builder(default = "OffsetDateTime::now_utc()")] + created_time: OffsetDateTime, + + bill_id: Uuid, +} + +#[cfg(test)] +mod tests { + use time::macros::datetime; + + use crate::{ + billing::{self, domain::bill_aggregate::*}, + utils::uuid::tests::UUID, + }; + + use super::*; + + impl ComputeBillTotalPriceBillCommand { + pub fn get_cmd() -> Self { + let bill_id = UUID; + let adding_by = UUID; + + ComputeBillTotalPriceBillCommandBuilder::default() + .adding_by(adding_by) + .bill_id(bill_id) + .created_time(datetime!(1970-01-01 0:00 UTC)) + .build() + .unwrap() + } + } + + #[test] + fn test_cmd() { + let bill_id = UUID; + let adding_by = UUID; + + let cmd = ComputeBillTotalPriceBillCommandBuilder::default() + .adding_by(adding_by) + .bill_id(bill_id) + .created_time(datetime!(1970-01-01 0:00 UTC)) + .build() + .unwrap(); + + assert_eq!(*cmd.bill_id(), bill_id); + assert_eq!(*cmd.adding_by(), adding_by); + } +} diff --git a/src/billing/domain/events.rs b/src/billing/domain/events.rs index f947d4c..5a670d9 100644 --- a/src/billing/domain/events.rs +++ b/src/billing/domain/events.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use super::{ bill_added_event::BillAddedEvent, bill_deleted_event::BillDeletedEvent, + bill_total_price_computed_event::BillTotalPriceComputedEvent, 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, @@ -20,6 +21,7 @@ pub enum BillingEvent { BillAdded(BillAddedEvent), BillUpdated(BillUpdatedEvent), BillDeleted(BillDeletedEvent), + BillTotalPriceComputed(BillTotalPriceComputedEvent), StoreAdded(StoreAddedEvent), StoreUpdated(StoreUpdatedEvent), } @@ -34,9 +36,10 @@ impl DomainEvent for BillingEvent { BillingEvent::LineItemAdded { .. } => "BillingLineItemAdded", BillingEvent::LineItemUpdated { .. } => "BillingLineItemUpdated", BillingEvent::LineItemDeleted { .. } => "BillingLineItemDeleted", - BillingEvent::BillAdded { .. } => "BillingBilAdded", - BillingEvent::BillUpdated { .. } => "BillingBilUpdated", - BillingEvent::BillDeleted { .. } => "BillingBilDeleted", + BillingEvent::BillAdded { .. } => "BillingBillAdded", + BillingEvent::BillUpdated { .. } => "BillingBillUpdated", + BillingEvent::BillDeleted { .. } => "BillingBillDeleted", + BillingEvent::BillTotalPriceComputed { .. } => "BillingBillTotalPriceComputed", BillingEvent::StoreAdded { .. } => "BillingStoreAdded", BillingEvent::StoreUpdated { .. } => "BillingStoreUpdated", }; diff --git a/src/billing/domain/line_item_aggregate.rs b/src/billing/domain/line_item_aggregate.rs index e2a956c..b19c9e1 100644 --- a/src/billing/domain/line_item_aggregate.rs +++ b/src/billing/domain/line_item_aggregate.rs @@ -49,9 +49,15 @@ impl Default for LineItem { impl LineItem { pub fn total_price(&self) -> Price { - let total_price_as_minor = (self.quantity().major_as_minor().unwrap() // TODO: handle err + let price_per_unit_as_minor = + self.price_per_unit().major_as_minor() + self.price_per_unit().minor(); + let total_price_as_minor = if self.quantity().major().is_dividable() { + (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_per_unit_as_minor + } else { + self.quantity().major().number() * price_per_unit_as_minor + }; Price::from_minor( total_price_as_minor, diff --git a/src/billing/domain/mod.rs b/src/billing/domain/mod.rs index cd8aa4d..8f72a65 100644 --- a/src/billing/domain/mod.rs +++ b/src/billing/domain/mod.rs @@ -12,6 +12,7 @@ pub mod add_bill_command; pub mod add_line_item_command; pub mod add_store_command; pub mod commands; +pub mod compute_bill_total_price_command; pub mod delete_bill_command; pub mod delete_line_item_command; pub mod update_bill_command; @@ -21,6 +22,7 @@ pub mod update_store_command; // events; pub mod bill_added_event; pub mod bill_deleted_event; +pub mod bill_total_price_computed_event; pub mod bill_updated_event; pub mod events; pub mod line_item_added_event; diff --git a/src/billing/domain/update_bill_command.rs b/src/billing/domain/update_bill_command.rs index d8d1ff0..7120853 100644 --- a/src/billing/domain/update_bill_command.rs +++ b/src/billing/domain/update_bill_command.rs @@ -29,6 +29,8 @@ pub struct UpdateBillCommand { #[cfg(test)] mod tests { + use time::macros::datetime; + use crate::{billing::domain::bill_aggregate::*, utils::uuid::tests::UUID}; use super::*; @@ -39,6 +41,7 @@ mod tests { let adding_by = UUID; UpdateBillCommandBuilder::default() + .created_time(datetime!(1970-01-01 0:00 UTC)) .adding_by(adding_by) .store_id(store_id) .total_price(None)