diff --git a/.sqlx/query-538d43c832702b03da4a51e0b0794785adfb14b4b8ff0ed7c4a7079e711b8ce7.json b/.sqlx/query-538d43c832702b03da4a51e0b0794785adfb14b4b8ff0ed7c4a7079e711b8ce7.json new file mode 100644 index 0000000..5cf8d97 --- /dev/null +++ b/.sqlx/query-538d43c832702b03da4a51e0b0794785adfb14b4b8ff0ed7c4a7079e711b8ce7.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 cqrs_billing_line_item_query\n WHERE\n bill_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": "538d43c832702b03da4a51e0b0794785adfb14b4b8ff0ed7c4a7079e711b8ce7" +} diff --git a/src/billing/adapters/output/db/postgres/get_line_items_for_bill_id.rs b/src/billing/adapters/output/db/postgres/get_line_items_for_bill_id.rs new file mode 100644 index 0000000..cecc86b --- /dev/null +++ b/src/billing/adapters/output/db/postgres/get_line_items_for_bill_id.rs @@ -0,0 +1,144 @@ +// 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::*, get_line_items_for_bill_id::*}, + domain::line_item_aggregate::LineItem, +}; + +#[async_trait::async_trait] +impl GetLineItemsForBillIDDBPort for BillingDBPostgresAdapter { + async fn get_line_items_for_bill_id(&self, bill_id: Uuid) -> BillingDBResult> { + let mut res = sqlx::query_as!( + super::line_item_view::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 + bill_id = $1;", + bill_id + ) + .fetch_all(&self.pool) + .await?; + println!("Got len: {}", res.len()); + let mut output = Vec::with_capacity(res.len()); + res.drain(0..).for_each(|r| output.push(r.into())); + Ok(output) + } +} + +#[cfg(test)] +pub mod tests { + + use super::*; + // use crate::billing::domain::add_product_command::tests::get_customizations; + use crate::{ + billing::{ + adapters::output::db::postgres::line_item_view::LineItemView, domain::bill_aggregate::*, + }, + types::currency::*, + types::quantity::*, + utils::uuid::{tests::*, *}, + }; + + async fn create_dummy_line_item( + db: &BillingDBPostgresAdapter, + bill_id: Uuid, + line_item_id: Uuid, + ) { + let view = LineItemView::default(); + let version = 0; + 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, + line_item_id, + QuantityUnit::DiscreteNumber.to_string(), + view.quantity_minor_number, + QuantityUnit::DiscreteNumber.to_string(), + view.quantity_major_number, + view.created_time, + bill_id, + view.price_per_unit_minor, + view.price_per_unit_major, + Currency::INR.to_string(), + view.deleted, + ) + .execute(&db.pool) + .await + .unwrap(); + } + + #[actix_rt::test] + async fn test_postgres_get_line_items_for_bill_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 bill_id = UUID; + + // state doesn't exist + assert!(db + .get_line_items_for_bill_id(bill_id) + .await + .unwrap() + .is_empty()); + + let u = GenerateUUID; + let li_id_1 = u.get_uuid(); + let li_id_2 = u.get_uuid(); + create_dummy_line_item(&db, bill_id, li_id_1).await; + create_dummy_line_item(&db, bill_id, li_id_2).await; + + // state exists + let res = db.get_line_items_for_bill_id(bill_id).await.unwrap(); + + assert_eq!(res.len(), 2); + assert!(res + .iter() + .any(|li| *li.bill_id() == bill_id && *li.line_item_id() == li_id_1)); + assert!(res + .iter() + .any(|li| *li.bill_id() == bill_id && *li.line_item_id() == li_id_2)); + + 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 index 91d91bb..fd7484c 100644 --- a/src/billing/adapters/output/db/postgres/line_item_view.rs +++ b/src/billing/adapters/output/db/postgres/line_item_view.rs @@ -25,23 +25,23 @@ pub const NEW_LINE_ITEM_NON_UUID: &str = "new_line_item_non_uuid-asdfa-billing"; // 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, + pub product_name: String, + pub product_id: Uuid, + pub bill_id: Uuid, + pub created_time: OffsetDateTime, - line_item_id: Uuid, + pub line_item_id: Uuid, - quantity_major_number: i32, - quantity_minor_number: i32, - quantity_major_unit: String, - quantity_minor_unit: String, + pub quantity_major_number: i32, + pub quantity_minor_number: i32, + pub quantity_major_unit: String, + pub quantity_minor_unit: String, - price_per_unit_major: i32, - price_per_unit_minor: i32, - price_per_unit_currency: String, + pub price_per_unit_major: i32, + pub price_per_unit_minor: i32, + pub price_per_unit_currency: String, - deleted: bool, + pub deleted: bool, } impl Default for LineItemView { diff --git a/src/billing/adapters/output/db/postgres/mod.rs b/src/billing/adapters/output/db/postgres/mod.rs index a605999..c8c8104 100644 --- a/src/billing/adapters/output/db/postgres/mod.rs +++ b/src/billing/adapters/output/db/postgres/mod.rs @@ -10,6 +10,7 @@ use crate::db::{migrate::RunMigrations, sqlx_postgres::Postgres}; mod bill_id_exists; mod bill_view; mod errors; +mod get_line_items_for_bill_id; mod line_item_id_exists; mod line_item_view; mod next_token_id; 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) diff --git a/src/types/currency.rs b/src/types/currency.rs index c476512..46a3b1b 100644 --- a/src/types/currency.rs +++ b/src/types/currency.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use std::str::FromStr; +use std::{ops::Add, str::FromStr}; use async_trait::async_trait; use cqrs_es::Aggregate; @@ -53,7 +53,12 @@ impl Price { pub fn from_minor(minor_only: usize, currency: Currency) -> Price { let minor = minor_only % 100; - let major = (minor_only - minor) / 100; + let major_only = minor_only - minor; + let major = if major_only == 0 { + 0 + } else { + (minor_only - minor) / 100 + }; Price { minor, major, @@ -62,6 +67,16 @@ impl Price { } } +impl Add for Price { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + let self_minor = self.major_as_minor() + self.minor; + let rhs_minor = rhs.major_as_minor() + rhs.minor; + + Self::from_minor(self_minor + rhs_minor, self.currency) + } +} + #[cfg(test)] mod tests { use super::*; @@ -110,4 +125,70 @@ mod tests { } ); } + + #[test] + fn test_price_add() { + let a = Price { + minor: 10, + major: 100, + currency: Currency::INR, + }; + + let b = Price { + minor: 1, + major: 200, + currency: Currency::INR, + }; + + assert_eq!( + a + b, + Price { + minor: 11, + major: 300, + currency: Currency::INR + } + ); + } + + #[test] + fn test_price_add_zero() { + let a = Price { + minor: 0, + major: 0, + currency: Currency::INR, + }; + + assert_eq!( + a.clone() + a, + Price { + minor: 0, + major: 0, + currency: Currency::INR + } + ); + } + + #[test] + fn test_price_add_overflow() { + let a = Price { + minor: 80, + major: 100, + currency: Currency::INR, + }; + + let b = Price { + minor: 80, + major: 200, + currency: Currency::INR, + }; + + assert_eq!( + a + b, + Price { + minor: 60, + major: 301, + currency: Currency::INR + } + ); + } } diff --git a/src/types/quantity.rs b/src/types/quantity.rs index 14dea3b..b24c9e0 100644 --- a/src/types/quantity.rs +++ b/src/types/quantity.rs @@ -46,7 +46,7 @@ impl FromStr for QuantityUnit { MILLI_LITER => Ok(Self::MilliLiter), LITER => Ok(Self::Liter), MILLI_GRAM => Ok(Self::Milligram), - _ => Err("Currency unsupported".into()), + _ => Err(format!("Quantity unsupported: {s}")), } } } @@ -74,6 +74,15 @@ impl QuantityPart { } } } + + pub fn is_dividable(&self) -> bool { + match self.unit { + QuantityUnit::Kilogram | QuantityUnit::Liter | QuantityUnit::Gram => true, + QuantityUnit::Milligram | QuantityUnit::MilliLiter | QuantityUnit::DiscreteNumber => { + false + } + } + } } #[derive(