From e9a1b30610c068be74a0c603defd2ddc041a7f16 Mon Sep 17 00:00:00 2001 From: tompro Date: Wed, 29 Apr 2026 10:01:20 +0200 Subject: [PATCH 1/7] Actionalbe notification protection --- .../src/protocol/event/bill_events.rs | 36 +++ .../src/handler/bill_action_event_handler.rs | 84 ++++--- crates/bcr-ebill-transport/src/lib.rs | 4 +- .../src/notification_transport.rs | 221 +++++++++--------- 4 files changed, 186 insertions(+), 159 deletions(-) diff --git a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs index bd59e3f1..51351572 100644 --- a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs +++ b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs @@ -233,6 +233,38 @@ pub enum ActionType { CheckQuote, } +impl BillEventType { + /// Returns a human-readable description key for the event type. + pub fn description(&self) -> String { + match self { + BillEventType::BillSigned => "bill_signed".to_string(), + BillEventType::BillAccepted => "bill_accepted".to_string(), + BillEventType::BillAcceptanceRequested => "bill_should_be_accepted".to_string(), + BillEventType::BillAcceptanceRejected => "bill_acceptance_rejected".to_string(), + BillEventType::BillAcceptanceTimeout => "bill_acceptance_timed_out".to_string(), + BillEventType::BillAcceptanceRecourse => { + "bill_recourse_acceptance_required".to_string() + } + BillEventType::BillPaymentRequested => "bill_payment_required".to_string(), + BillEventType::BillPaymentRejected => "bill_payment_rejected".to_string(), + BillEventType::BillPaymentTimeout => "bill_payment_timed_out".to_string(), + BillEventType::BillPaymentRecourse => "bill_recourse_payment_required".to_string(), + BillEventType::BillRecourseRejected => "Bill_recourse_rejected".to_string(), + BillEventType::BillRecourseTimeout => "Bill_recourse_timed_out".to_string(), + BillEventType::BillSellOffered => "bill_request_to_buy".to_string(), + BillEventType::BillBuyingRejected => "bill_buying_rejected".to_string(), + BillEventType::BillPaid => "bill_paid".to_string(), + BillEventType::BillRecoursePaid => "bill_recourse_paid".to_string(), + BillEventType::BillEndorsed => "bill_endorsed".to_string(), + BillEventType::BillSold => "bill_sold".to_string(), + BillEventType::BillMintingRequested => "requested_to_mint".to_string(), + BillEventType::BillNewQuote => "new_quote".to_string(), + BillEventType::BillQuoteApproved => "quote_approved".to_string(), + BillEventType::BillBlock => "".to_string(), + } + } +} + impl fmt::Display for BillEventType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self) @@ -272,4 +304,8 @@ impl ActionType { _ => None, } } + + pub fn is_actionable(&self) -> bool { + !matches!(self, Self::CheckBill) + } } diff --git a/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs b/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs index 0c11b4de..340648e7 100644 --- a/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs +++ b/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs @@ -67,6 +67,36 @@ impl BillActionEventHandler { return Ok(()); } + let is_new_actionable = event + .action_type + .as_ref() + .map(|a| a.is_actionable()) + .unwrap_or(false); + + let current_active = self + .notification_store + .get_latest_by_reference(&event.bill_id.to_string(), NotificationType::Bill) + .await; + + let current_is_actionable = match ¤t_active { + Ok(Some(currently_active)) => currently_active + .payload + .as_ref() + .and_then(|p| serde_json::from_value::(p.clone()).ok()) + .and_then(|p| p.action_type) + .map(|a| a.is_actionable()) + .unwrap_or(false), + _ => false, + }; + + if !is_new_actionable && current_is_actionable { + trace!( + "Skipping non-actionable notification for bill {} because an actionable notification is already active", + event.bill_id + ); + return Ok(()); + } + // create notification let mut notification = Notification::new_bill_notification( &event.bill_id, @@ -77,22 +107,13 @@ impl BillActionEventHandler { notification.event_id = event_id; // mark Bill event as done if any active one exists - match self - .notification_store - .get_latest_by_reference(&event.bill_id.to_string(), NotificationType::Bill) - .await + if let Ok(Some(currently_active)) = current_active + && let Err(e) = self + .notification_store + .mark_as_done(¤tly_active.id) + .await { - Ok(Some(currently_active)) => { - if let Err(e) = self - .notification_store - .mark_as_done(¤tly_active.id) - .await - { - error!("Failed to mark currently active notification as done: {e}"); - } - } - Err(e) => error!("Failed to get latest notification by reference: {e}"), - Ok(None) => {} + error!("Failed to mark currently active notification as done: {e}"); } // save new notification to database self.notification_store @@ -173,30 +194,7 @@ impl NotificationHandlerApi for BillActionEventHandler { // generates a human readable description for an event fn event_description(event_type: &BillEventType) -> String { - match event_type { - BillEventType::BillSigned => "bill_signed".to_string(), - BillEventType::BillAccepted => "bill_accepted".to_string(), - BillEventType::BillAcceptanceRequested => "bill_should_be_accepted".to_string(), - BillEventType::BillAcceptanceRejected => "bill_acceptance_rejected".to_string(), - BillEventType::BillAcceptanceTimeout => "bill_acceptance_timed_out".to_string(), - BillEventType::BillAcceptanceRecourse => "bill_recourse_acceptance_required".to_string(), - BillEventType::BillPaymentRequested => "bill_payment_required".to_string(), - BillEventType::BillPaymentRejected => "bill_payment_rejected".to_string(), - BillEventType::BillPaymentTimeout => "bill_payment_timed_out".to_string(), - BillEventType::BillPaymentRecourse => "bill_recourse_payment_required".to_string(), - BillEventType::BillRecourseRejected => "Bill_recourse_rejected".to_string(), - BillEventType::BillRecourseTimeout => "Bill_recourse_timed_out".to_string(), - BillEventType::BillSellOffered => "bill_request_to_buy".to_string(), - BillEventType::BillBuyingRejected => "bill_buying_rejected".to_string(), - BillEventType::BillPaid => "bill_paid".to_string(), - BillEventType::BillRecoursePaid => "bill_recourse_paid".to_string(), - BillEventType::BillEndorsed => "bill_endorsed".to_string(), - BillEventType::BillSold => "bill_sold".to_string(), - BillEventType::BillMintingRequested => "requested_to_mint".to_string(), - BillEventType::BillNewQuote => "new_quote".to_string(), - BillEventType::BillQuoteApproved => "quote_approved".to_string(), - BillEventType::BillBlock => "".to_string(), - } + event_type.description() } #[cfg(test)] @@ -354,9 +352,9 @@ mod tests { EventType::Bill, BillChainEventPayload { bill_id: bill_id_test(), - event_type: BillEventType::BillSigned, + event_type: BillEventType::BillAcceptanceRequested, sum: Some(Sum::new_sat(500).expect("sat works")), - action_type: Some(ActionType::CheckBill), + action_type: Some(ActionType::AcceptBill), }, ); @@ -434,9 +432,9 @@ mod tests { EventType::Bill, BillChainEventPayload { bill_id: bill_id_test(), - event_type: BillEventType::BillSigned, + event_type: BillEventType::BillAcceptanceRequested, sum: Some(Sum::new_sat(500).expect("sat works")), - action_type: Some(ActionType::CheckBill), + action_type: Some(ActionType::AcceptBill), }, ); diff --git a/crates/bcr-ebill-transport/src/lib.rs b/crates/bcr-ebill-transport/src/lib.rs index c8705691..a26453da 100644 --- a/crates/bcr-ebill-transport/src/lib.rs +++ b/crates/bcr-ebill-transport/src/lib.rs @@ -161,7 +161,7 @@ pub async fn create_transport_service( db_context.notification_store.clone(), nostr_contact_processor.clone(), bill_invite_handler.clone(), - push_service, + push_service.clone(), db_context.nostr_chain_event_store.clone(), transport.clone(), get_config().bitcoin_network(), @@ -206,10 +206,10 @@ pub async fn create_transport_service( )); let notification_transport = Arc::new(NotificationTransportService::new( - nostr_transport.clone(), db_context.notification_store.clone(), db_context.email_notification_store.clone(), email_client, + push_service.clone(), )); #[allow(clippy::arc_with_non_send_sync)] diff --git a/crates/bcr-ebill-transport/src/notification_transport.rs b/crates/bcr-ebill-transport/src/notification_transport.rs index dfd19011..440b0215 100644 --- a/crates/bcr-ebill-transport/src/notification_transport.rs +++ b/crates/bcr-ebill-transport/src/notification_transport.rs @@ -14,36 +14,89 @@ use bcr_ebill_core::application::notification::{Notification, NotificationType}; use bcr_ebill_core::{ protocol::Sum, protocol::blockchain::bill::participant::BillParticipant, - protocol::event::{ActionType, BillChainEventPayload, Event}, + protocol::event::{ActionType, BillChainEventPayload, BillEventType, Event}, }; use bcr_ebill_persistence::NotificationStoreApi; use bcr_ebill_persistence::notification::{EmailNotificationStoreApi, NotificationFilter}; use log::error; -use crate::NostrTransportService; +use crate::PushApi; pub struct NotificationTransportService { - nostr_transport: Arc, notification_store: Arc, email_notification_store: Arc, #[allow(unused)] email_client: Arc, + push_service: Arc, } impl NotificationTransportService { pub fn new( - nostr_transport: Arc, notification_store: Arc, email_notification_store: Arc, email_client: Arc, + push_service: Arc, ) -> Self { Self { - nostr_transport, notification_store, email_notification_store, email_client, + push_service, } } + + async fn create_bill_notification( + &self, + node_id: &NodeId, + bill_id: &BillId, + event_type: BillEventType, + action_type: Option, + sum: Option, + ) -> Result<()> { + let payload = BillChainEventPayload { + event_type: event_type.clone(), + bill_id: bill_id.to_owned(), + action_type, + sum, + }; + + let notification = Notification::new_bill_notification( + bill_id, + node_id, + &event_type.description(), + Some( + serde_json::to_value(&payload) + .map_err(|e| Error::Message(format!("Failed to serialize payload: {e}")))?, + ), + ); + + if let Ok(Some(currently_active)) = self + .notification_store + .get_latest_by_reference(&bill_id.to_string(), NotificationType::Bill) + .await + { + let _ = self + .notification_store + .mark_as_done(¤tly_active.id) + .await; + } + + match self.notification_store.add(notification.clone()).await { + Ok(_) => { + if let Ok(notification_value) = serde_json::to_value(notification) { + self.push_service.send(notification_value).await; + } + } + Err(e) => { + error!("Failed to save bill notification: {e}"); + return Err(Error::Persistence( + "Failed to save bill notification".to_string(), + )); + } + } + + Ok(()) + } } impl ServiceTraitBounds for NotificationTransportService {} @@ -125,12 +178,22 @@ impl NotificationTransportServiceApi for NotificationTransportService { drawee: &NodeId, recoursee: &Option, ) -> Result<()> { - let node = self.nostr_transport.get_node_transport(sender_node_id); - if let Some(event_type) = timed_out_action.get_timeout_event_type() { - // only send to a recipient once - let unique: HashMap = - HashMap::from_iter(recipients.iter().map(|r| (r.node_id().clone(), r.clone()))); + let recipient_ids: Vec = + recipients.iter().map(|r| r.node_id().clone()).collect(); + + if !recipient_ids.contains(sender_node_id) { + return Ok(()); + } + + self.create_bill_notification( + sender_node_id, + bill_id, + event_type.clone(), + Some(ActionType::CheckBill), + sum.clone(), + ) + .await?; let payload = BillChainEventPayload { event_type, @@ -138,21 +201,15 @@ impl NotificationTransportServiceApi for NotificationTransportService { action_type: Some(ActionType::CheckBill), sum, }; - for (_, recipient) in unique { - let event = Event::new_bill(payload.clone()); - node.send_private_event(sender_node_id, &recipient, event.clone().try_into()?) - .await?; - - // Only send email to holder, and only if we are drawee, or recoursee - if let Some(r) = recoursee { - if sender_node_id == r { - self.send_email_notification(sender_node_id, holder, &event) - .await; - } - } else if sender_node_id == drawee { + let event = Event::new_bill(payload); + if let Some(r) = recoursee { + if sender_node_id == r { self.send_email_notification(sender_node_id, holder, &event) .await; } + } else if sender_node_id == drawee { + self.send_email_notification(sender_node_id, holder, &event) + .await; } } Ok(()) @@ -228,26 +285,20 @@ mod tests { use bcr_common::core::{BillId, NodeId}; use bcr_ebill_api::service::transport_service::NotificationTransportServiceApi; use bcr_ebill_core::{ - application::notification::Notification, - protocol::Email, - protocol::blockchain::bill::participant::BillParticipant, - protocol::crypto::BcrKeys, - protocol::event::{ - ActionType, BillChainEventPayload, BillEventType, Event, EventEnvelope, EventType, - }, - protocol::{Result, Sum}, + application::notification::Notification, protocol::Email, protocol::Sum, + protocol::blockchain::bill::participant::BillParticipant, protocol::crypto::BcrKeys, + protocol::event::ActionType, }; use bcr_ebill_persistence::notification::NotificationFilter; use mockall::predicate::eq; use crate::{ notification_transport::NotificationTransportService, + push_notification::MockPushApi, test_utils::{ - MockContactStore, MockEmailClient, MockEmailNotificationStore, - MockNostrChainEventStore, MockNostrContactStore, MockNostrQueuedMessageStore, - MockNotificationJsonTransport, MockNotificationStore, bill_id_test, - get_identity_public_data, get_nostr_transport, init_test_cfg, node_id_test, - node_id_test_other, node_id_test_other2, + MockEmailClient, MockEmailNotificationStore, MockNotificationStore, bill_id_test, + get_identity_public_data, init_test_cfg, node_id_test, node_id_test_other, + node_id_test_other2, }, }; @@ -274,18 +325,14 @@ mod tests { )), ]; - let service = expect_service(|mock, _, _, _, _, _, _, email_client| { - // expect to send payment timeout event to all recipients - mock.expect_send_private_event() - .withf(|_, _, e| check_chain_payload(e, BillEventType::BillPaymentTimeout)) - .returning(|_, _, _| Ok(())) - .times(3); + let service = expect_service(|mock_store, _, email_client, mock_push| { + mock_store.expect_add().returning(Ok).times(2); + mock_store + .expect_get_latest_by_reference() + .returning(|_, _| Ok(None)) + .times(2); - // expect to send acceptance timeout event to all recipients - mock.expect_send_private_event() - .withf(|_, _, e| check_chain_payload(e, BillEventType::BillAcceptanceTimeout)) - .returning(|_, _, _| Ok(())) - .times(3); + mock_push.expect_send().returning(|_| ()).times(2); email_client .expect_send_bill_notification() @@ -342,10 +389,7 @@ mod tests { )), ]; - let service = expect_service(|mock, _, _, _, _, _, _, _| { - // expect to never send timeout event on non expiring events - mock.expect_send_private_event().never(); - }); + let service = expect_service(|_, _, _, _| {}); service .send_request_to_action_timed_out_event( @@ -372,7 +416,7 @@ mod tests { ..Default::default() }; - let service = expect_service(|_, _, _, _, _, mock_store, _, _| { + let service = expect_service(|mock_store, _, _, _| { let returning = result.clone(); mock_store .expect_list() @@ -398,7 +442,7 @@ mod tests { ..Default::default() }; - let service = expect_service(|_, _, _, _, _, _, _, _| {}); + let service = expect_service(|_, _, _, _| {}); assert!(service.get_client_notifications(filter).await.is_err()); assert!( @@ -425,7 +469,7 @@ mod tests { async fn get_mark_notification_done() { init_test_cfg(); - let service = expect_service(|_, _, _, _, _, mock_store, _, _| { + let service = expect_service(|mock_store, _, _, _| { mock_store .expect_mark_as_done() .with(eq("notification_id")) @@ -442,7 +486,7 @@ mod tests { async fn test_get_email_notifications_preferences_link() { init_test_cfg(); - let service = expect_service(|_, _, _, _, _, _, email_store, _| { + let service = expect_service(|_, email_store, _, _| { email_store .expect_get_email_preferences_link_for_node_id() .returning(|_| Ok(Some(url::Url::parse("http://bit.cr/").unwrap()))) @@ -462,7 +506,7 @@ mod tests { #[tokio::test] async fn test_get_email_notifications_preferences_link_no_entry() { init_test_cfg(); - let service = expect_service(|_, _, _, _, _, _, email_store, _| { + let service = expect_service(|_, email_store, _, _| { email_store .expect_get_email_preferences_link_for_node_id() .returning(|_| Ok(None)) @@ -476,108 +520,57 @@ mod tests { } fn get_mocks() -> ( - MockNotificationJsonTransport, - MockContactStore, - MockNostrContactStore, - MockNostrQueuedMessageStore, - MockNostrChainEventStore, MockNotificationStore, MockEmailNotificationStore, MockEmailClient, + MockPushApi, ) { - let mut mock_transport = MockNotificationJsonTransport::new(); - // Set default expectation for has_local_signer to return false for any node_id - // Tests can override this expectation as needed - mock_transport - .expect_has_local_signer() - .returning(|_| false); - ( - mock_transport, - MockContactStore::new(), - MockNostrContactStore::new(), - MockNostrQueuedMessageStore::new(), - MockNostrChainEventStore::new(), MockNotificationStore::new(), MockEmailNotificationStore::new(), MockEmailClient::new(), + MockPushApi::new(), ) } fn get_transport( - mock_transport: MockNotificationJsonTransport, - contact_store: MockContactStore, - nostr_contact_store: MockNostrContactStore, - queued_message_store: MockNostrQueuedMessageStore, - chain_events: MockNostrChainEventStore, notification_store: MockNotificationStore, email_notification_store: MockEmailNotificationStore, email_client: MockEmailClient, + push_service: MockPushApi, ) -> NotificationTransportService { NotificationTransportService::new( - Arc::new(get_nostr_transport( - mock_transport, - contact_store, - nostr_contact_store, - queued_message_store, - chain_events, - )), Arc::new(notification_store), Arc::new(email_notification_store), Arc::new(email_client), + Arc::new(push_service), ) } fn expect_service( expect: impl Fn( - &mut MockNotificationJsonTransport, - &mut MockContactStore, - &mut MockNostrContactStore, - &mut MockNostrQueuedMessageStore, - &mut MockNostrChainEventStore, &mut MockNotificationStore, &mut MockEmailNotificationStore, &mut MockEmailClient, + &mut MockPushApi, ), ) -> NotificationTransportService { let ( - mut transport, - mut contact_store, - mut nostr_contact_store, - mut queued_message_store, - mut chain_events, mut notification_store, mut email_notification_store, mut email_client, + mut push_service, ) = get_mocks(); expect( - &mut transport, - &mut contact_store, - &mut nostr_contact_store, - &mut queued_message_store, - &mut chain_events, &mut notification_store, &mut email_notification_store, &mut email_client, + &mut push_service, ); get_transport( - transport, - contact_store, - nostr_contact_store, - queued_message_store, - chain_events, notification_store, email_notification_store, email_client, + push_service, ) } - - fn check_chain_payload(event: &EventEnvelope, bill_event_type: BillEventType) -> bool { - let valid_event_type = event.event_type == EventType::Bill; - let event: Result> = event.clone().try_into(); - if let Ok(event) = event { - valid_event_type && event.data.event_type == bill_event_type - } else { - false - } - } } From 089bc6372b691ac86b7e92c616a9a38ffc15e07d Mon Sep 17 00:00:00 2001 From: tompro Date: Tue, 5 May 2026 08:16:47 +0200 Subject: [PATCH 2/7] Targeted message generation and delivery --- .../src/application/notification.rs | 18 + .../src/protocol/blockchain/bill/block.rs | 8 + .../src/protocol/blockchain/bill/chain.rs | 163 ++++++++- .../src/protocol/event/bill_events.rs | 334 +++++++++++++++++- .../src/db/notification.rs | 17 +- .../bcr-ebill-persistence/src/notification.rs | 13 +- .../src/handler/bill_action_event_handler.rs | 33 +- .../handler/company_chain_event_processor.rs | 11 +- .../src/handler/contact_share_handler.rs | 3 +- .../src/notification_transport.rs | 25 +- .../src/transport_service.rs | 271 ++++---------- crates/bcr-ebill-wasm/src/data/mod.rs | 2 + .../bcr-ebill-wasm/src/data/notification.rs | 22 +- 13 files changed, 705 insertions(+), 215 deletions(-) diff --git a/crates/bcr-ebill-core/src/application/notification.rs b/crates/bcr-ebill-core/src/application/notification.rs index fedaa2d7..dd25ecd6 100644 --- a/crates/bcr-ebill-core/src/application/notification.rs +++ b/crates/bcr-ebill-core/src/application/notification.rs @@ -28,6 +28,8 @@ pub struct Notification { /// Whether the notification is active or not. If active the user should still perform /// some action to dismiss the notification. pub active: bool, + /// The urgency/attention level of the notification + pub level: NotificationLevel, /// Additional data to be used for notification specific logic pub payload: Option, /// Optional origin event id for deduplication @@ -40,6 +42,7 @@ impl Notification { node_id: &NodeId, description: &str, payload: Option, + level: NotificationLevel, ) -> Self { Self { id: Uuid::new_v4().to_string(), @@ -49,6 +52,7 @@ impl Notification { description: description.to_string(), datetime: Timestamp::now().to_datetime(), active: true, + level, payload, event_id: None, } @@ -59,6 +63,7 @@ impl Notification { node_id: &NodeId, description: &str, payload: Option, + level: NotificationLevel, ) -> Self { Self { id: Uuid::new_v4().to_string(), @@ -68,6 +73,7 @@ impl Notification { description: description.to_string(), datetime: Timestamp::now().to_datetime(), active: true, + level, payload, event_id: None, } @@ -78,6 +84,7 @@ impl Notification { node_id: &NodeId, description: &str, payload: Option, + level: NotificationLevel, ) -> Self { Self { id: Uuid::new_v4().to_string(), @@ -87,6 +94,7 @@ impl Notification { description: description.to_string(), datetime: Timestamp::now().to_datetime(), active: true, + level, payload, event_id: None, } @@ -107,3 +115,13 @@ impl Display for NotificationType { f.write_str(format!("{self:?}").as_str()) } } + +/// Indicates the urgency/attention level of a notification. +/// ActionRequired means the user needs to take action. +/// Informational means no immediate action is needed. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum NotificationLevel { + #[default] + Informational, + ActionRequired, +} diff --git a/crates/bcr-ebill-core/src/protocol/blockchain/bill/block.rs b/crates/bcr-ebill-core/src/protocol/blockchain/bill/block.rs index 1f25903d..365f8a4c 100644 --- a/crates/bcr-ebill-core/src/protocol/blockchain/bill/block.rs +++ b/crates/bcr-ebill-core/src/protocol/blockchain/bill/block.rs @@ -377,6 +377,14 @@ impl BillParticipantBlockData { BillParticipantBlockData::Ident(data) => data.node_id.clone(), } } + + pub fn is_anon(&self) -> bool { + matches!(self, BillParticipantBlockData::Anon(_)) + } + + pub fn is_ident(&self) -> bool { + matches!(self, BillParticipantBlockData::Ident(_)) + } } /// Anon bill participany data diff --git a/crates/bcr-ebill-core/src/protocol/blockchain/bill/chain.rs b/crates/bcr-ebill-core/src/protocol/blockchain/bill/chain.rs index 6d0e6fbb..bd8638b8 100644 --- a/crates/bcr-ebill-core/src/protocol/blockchain/bill/chain.rs +++ b/crates/bcr-ebill-core/src/protocol/blockchain/bill/chain.rs @@ -24,7 +24,7 @@ use bcr_common::core::NodeId; use borsh_derive::{BorshDeserialize, BorshSerialize}; use log::error; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct BillParties { @@ -835,6 +835,107 @@ impl BillBlockchain { Ok(nodes) } + /// Returns all Ident (non-Anon) nodes of the bill with the block height they were added in. + /// BEARER (Anon) participants are excluded. + pub fn get_all_ident_nodes_with_added_block_height( + &self, + bill_keys: &BcrKeys, + ) -> Result> { + let all_nodes = self.get_all_nodes_with_added_block_height(bill_keys)?; + let anon_nodes = self.get_anon_node_ids(bill_keys)?; + Ok(all_nodes + .into_iter() + .filter(|(node_id, _)| !anon_nodes.contains(node_id)) + .collect()) + } + + /// Returns a set of all Anon (BEARER) node IDs that have participated in the bill chain. + fn get_anon_node_ids(&self, bill_keys: &BcrKeys) -> Result> { + use super::block::BillParticipantBlockData; + let mut anon_nodes: HashSet = HashSet::new(); + for block in self.blocks.iter() { + match block.op_code { + BillOpCode::Issue => { + let bill: BillIssueBlockData = block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = bill.payee { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::Endorse => { + let block: BillEndorseBlockData = block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.endorsee { + anon_nodes.insert(data.node_id.clone()); + } + if let BillParticipantBlockData::Anon(ref data) = block.endorser { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::Mint => { + let block: BillMintBlockData = block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.endorsee { + anon_nodes.insert(data.node_id.clone()); + } + if let BillParticipantBlockData::Anon(ref data) = block.endorser { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::RequestToAccept => { + let block: BillRequestToAcceptBlockData = + block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.requester { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::RequestToPay => { + let block: BillRequestToPayBlockData = block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.requester { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::OfferToSell => { + let block: BillOfferToSellBlockData = block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.buyer { + anon_nodes.insert(data.node_id.clone()); + } + if let BillParticipantBlockData::Anon(ref data) = block.seller { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::Sell => { + let block: BillSellBlockData = block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.buyer { + anon_nodes.insert(data.node_id.clone()); + } + if let BillParticipantBlockData::Anon(ref data) = block.seller { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::RejectToBuy => { + let block: BillRejectToBuyBlockData = block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.rejecter { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::RequestRecourse => { + let block: BillRequestRecourseBlockData = + block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.recourser { + anon_nodes.insert(data.node_id.clone()); + } + } + BillOpCode::Recourse => { + let block: BillRecourseBlockData = block.get_decrypted_block(bill_keys)?; + if let BillParticipantBlockData::Anon(ref data) = block.recourser { + anon_nodes.insert(data.node_id.clone()); + } + } + // Accept, RejectToAccept, RejectToPay, RejectToPayRecourse only have Ident participants + _ => {} + } + } + Ok(anon_nodes) + } + pub fn get_bill_history(&self, bill_keys: &BcrKeys) -> Result { let result: Result> = self .blocks @@ -1126,7 +1227,7 @@ mod tests { use crate::protocol::{ Sum, blockchain::bill::{ - block::{BillOfferToSellBlockData, BillPaymentBlockData}, + block::{BillAnonParticipantBlockData, BillOfferToSellBlockData, BillPaymentBlockData}, tests::get_baseline_identity, }, constants::DAY_IN_SECS, @@ -1650,7 +1751,63 @@ mod tests { assert!(chain.try_add_block(endorse.clone())); let him = chain.holder_is_mint(&bill_keys).expect("call works"); - // endorse block after mint assert!(him.is_none()); } + + #[test] + fn test_get_all_ident_nodes_with_added_block_height_filters_anon() { + let bill = empty_bitcredit_bill(); + let bill_id = bill.id.clone(); + let bill_keys = get_bill_keys(); + let identity = get_baseline_identity(); + let mut chain = BillBlockchain::new( + &BillIssueBlockData::from(bill, None, test_ts(), signed_identity_proof_test()), + identity.1.clone(), + None, + BcrKeys::from_private_key(&bill_keys.get_private_key()), + test_ts(), + ) + .unwrap(); + + let signer = bill_identified_participant_only_node_id(NodeId::new( + identity.1.pub_key(), + bitcoin::Network::Testnet, + )); + let other_party = bill_identified_participant_only_node_id(NodeId::new( + BcrKeys::new().pub_key(), + bitcoin::Network::Testnet, + )); + + let endorse = BillBlock::create_block_for_endorse( + bill_id.clone(), + chain.get_latest_block(), + &BillEndorseBlockData { + endorser: BillParticipant::Ident(signer.clone()).into(), + endorsee: BillParticipantBlockData::Anon(BillAnonParticipantBlockData { + node_id: other_party.node_id.clone(), + }), + signatory: None, + signing_timestamp: test_ts() + 1, + signing_address: Some(signer.postal_address.clone()), + signer_identity_proof: Some(signed_identity_proof_test().into()), + }, + &identity.1, + None, + &BcrKeys::from_private_key(&bill_keys.get_private_key()), + test_ts() + 1, + ) + .unwrap(); + assert!(chain.try_add_block(endorse.clone())); + + let all_nodes = chain + .get_all_nodes_with_added_block_height(&bill_keys) + .unwrap(); + let ident_nodes = chain + .get_all_ident_nodes_with_added_block_height(&bill_keys) + .unwrap(); + + assert!(all_nodes.contains_key(&other_party.node_id)); + assert!(!ident_nodes.contains_key(&other_party.node_id)); + assert!(ident_nodes.contains_key(&signer.node_id)); + } } diff --git a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs index 51351572..c47d1684 100644 --- a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs +++ b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs @@ -2,7 +2,10 @@ use crate::protocol::{ Sum, blockchain::{ Blockchain, - bill::{BillBlock, BillBlockchain, BitcreditBill}, + bill::{ + BillBlock, BillBlockchain, BitcreditBill, + participant::{BillIdentParticipant, BillParticipant}, + }, }, crypto::BcrKeys, }; @@ -89,12 +92,34 @@ impl BillChainEvent { /// If `event_type` and `action` are provided, participants without an override receive that /// event. Participants without an override and where `event_type` is `None` will not receive /// any event. The recipient `node_id` is the key in the map. + fn sender_name(&self) -> Option { + if self.bill.drawer.node_id == self.sender_node_id { + return Some(self.bill.drawer.name.to_string()); + } + if self.bill.drawee.node_id == self.sender_node_id { + return Some(self.bill.drawee.name.to_string()); + } + if let BillParticipant::Ident(ref payee) = self.bill.payee + && payee.node_id == self.sender_node_id + { + return Some(payee.name.to_string()); + } + if let Some(BillParticipant::Ident(ref ident)) = self.bill.endorsee + && ident.node_id == self.sender_node_id + { + return Some(ident.name.to_string()); + } + None + } + pub fn generate_action_messages( &self, event_overrides: HashMap, event_type: Option, action: Option, ) -> HashMap> { + let sender_node_id = self.sender_node_id.clone(); + let sender_name = self.sender_name(); self.participants .keys() .filter_map(|node_id| { @@ -113,6 +138,8 @@ impl BillChainEvent { bill_id: self.bill.id.to_owned(), action_type: override_action, sum: Some(self.bill.sum.clone()), + sender_node_id: Some(sender_node_id.clone()), + sender_name: sender_name.clone(), }, ), ) @@ -140,6 +167,307 @@ impl BillChainEvent { .map(|node_id| (node_id.to_owned(), Event::new_bill_invite(invite.clone()))) .collect() } + + pub fn generate_messages( + &self, + event_type: BillEventType, + ) -> HashMap> { + match event_type { + BillEventType::BillSigned => self.generate_bill_signed_internal(), + BillEventType::BillAccepted => self.generate_bill_accepted_internal(), + BillEventType::BillPaymentRequested => self.generate_request_to_pay_internal(), + BillEventType::BillAcceptanceRequested => self.generate_request_to_accept_internal(), + BillEventType::BillEndorsed => self.generate_bill_endorsed_internal(), + BillEventType::BillPaid => self.generate_bill_paid_internal(), + _ => HashMap::new(), + } + } + + fn generate_bill_signed_internal(&self) -> HashMap> { + let mut overrides: HashMap = HashMap::new(); + + overrides.insert( + self.bill.drawee.node_id.clone(), + (BillEventType::BillSigned, ActionType::AcceptBill), + ); + + overrides.insert( + self.bill.payee.node_id(), + (BillEventType::BillSigned, ActionType::CheckBill), + ); + + self.generate_action_messages(overrides, None, None) + } + + fn generate_bill_accepted_internal(&self) -> HashMap> { + let mut overrides: HashMap = HashMap::new(); + + let holder_node_id = self + .bill + .endorsee + .as_ref() + .map(|e| e.node_id()) + .unwrap_or_else(|| self.bill.payee.node_id()); + + if holder_node_id != self.sender_node_id { + overrides.insert( + holder_node_id.clone(), + (BillEventType::BillAccepted, ActionType::CheckBill), + ); + } + + if self.bill.drawer.node_id != self.sender_node_id + && self.bill.drawer.node_id != holder_node_id + { + overrides.insert( + self.bill.drawer.node_id.clone(), + (BillEventType::BillAccepted, ActionType::CheckBill), + ); + } + + self.generate_action_messages(overrides, None, None) + } + + fn generate_request_to_pay_internal(&self) -> HashMap> { + let mut overrides: HashMap = HashMap::new(); + + if self.bill.drawee.node_id != self.sender_node_id { + overrides.insert( + self.bill.drawee.node_id.clone(), + (BillEventType::BillPaymentRequested, ActionType::PayBill), + ); + } + + self.generate_action_messages(overrides, None, None) + } + + fn generate_request_to_accept_internal(&self) -> HashMap> { + let mut overrides: HashMap = HashMap::new(); + + if self.bill.drawee.node_id != self.sender_node_id { + overrides.insert( + self.bill.drawee.node_id.clone(), + ( + BillEventType::BillAcceptanceRequested, + ActionType::AcceptBill, + ), + ); + } + + self.generate_action_messages(overrides, None, None) + } + + fn generate_bill_endorsed_internal(&self) -> HashMap> { + let mut overrides: HashMap = HashMap::new(); + + if let Some(ref endorsee) = self.bill.endorsee { + let endorsee_node_id = endorsee.node_id(); + if endorsee_node_id != self.sender_node_id { + overrides.insert( + endorsee_node_id, + (BillEventType::BillEndorsed, ActionType::CheckBill), + ); + } + } + + self.generate_action_messages(overrides, None, None) + } + + pub fn generate_offer_to_sell_messages( + &self, + buyer: &BillParticipant, + ) -> HashMap> { + let mut overrides: HashMap = HashMap::new(); + let buyer_node_id = buyer.node_id(); + + if buyer_node_id != self.sender_node_id { + overrides.insert( + buyer_node_id, + (BillEventType::BillSellOffered, ActionType::CheckBill), + ); + } + + self.generate_action_messages(overrides, None, None) + } + + pub fn generate_bill_sold_messages( + &self, + buyer: &BillParticipant, + ) -> HashMap> { + let mut overrides: HashMap = HashMap::new(); + let buyer_node_id = buyer.node_id(); + + if buyer_node_id != self.sender_node_id { + overrides.insert( + buyer_node_id, + (BillEventType::BillSold, ActionType::CheckBill), + ); + } + + self.generate_action_messages(overrides, None, None) + } + + pub fn generate_rejected_messages( + &self, + rejected_action: ActionType, + ) -> HashMap> { + let event_type = match rejected_action { + ActionType::AcceptBill => BillEventType::BillAcceptanceRejected, + ActionType::PayBill => BillEventType::BillPaymentRejected, + ActionType::BuyBill => BillEventType::BillBuyingRejected, + ActionType::RecourseBill => BillEventType::BillRecourseRejected, + _ => return HashMap::new(), + }; + + let mut overrides: HashMap = HashMap::new(); + let drawee_node_id = self.bill.drawee.node_id.clone(); + + let recipient_ids: Vec = match rejected_action { + ActionType::RecourseBill => { + // Recourse rejection goes to all NON-BEARER prior holders + let current_holder = self + .bill + .endorsee + .as_ref() + .map(|e| e.node_id()) + .unwrap_or_else(|| self.bill.payee.node_id()); + match self + .chain + .get_past_endorsees_for_bill(&self.bill_keys, ¤t_holder) + { + Ok(endorsees) => endorsees + .into_iter() + .map(|e| e.pay_to_the_order_of.node_id) + .collect(), + Err(e) => { + error!("Failed to get past endorsees for recourse rejection: {e}"); + return HashMap::new(); + } + } + } + _ => { + // Regular rejection goes to all NON-BEARER participants except drawee + match self + .chain + .get_all_ident_nodes_with_added_block_height(&self.bill_keys) + { + Ok(nodes) => nodes.into_keys().collect(), + Err(e) => { + error!("Failed to get Ident participants for rejection: {e}"); + return HashMap::new(); + } + } + } + }; + + for node_id in recipient_ids { + if node_id != self.sender_node_id && node_id != drawee_node_id { + overrides.insert(node_id, (event_type.clone(), ActionType::CheckBill)); + } + } + + self.generate_action_messages(overrides, None, None) + } + + pub fn generate_timeout_messages( + &self, + timed_out_action: ActionType, + ) -> HashMap> { + let event_type = match timed_out_action { + ActionType::AcceptBill => BillEventType::BillAcceptanceTimeout, + ActionType::PayBill => BillEventType::BillPaymentTimeout, + ActionType::RecourseBill => BillEventType::BillRecourseTimeout, + _ => return HashMap::new(), + }; + + let mut overrides: HashMap = HashMap::new(); + + let recipient_ids: Vec = match timed_out_action { + ActionType::RecourseBill => { + // Recourse timeout goes to all NON-BEARER prior holders + let current_holder = self + .bill + .endorsee + .as_ref() + .map(|e| e.node_id()) + .unwrap_or_else(|| self.bill.payee.node_id()); + match self + .chain + .get_past_endorsees_for_bill(&self.bill_keys, ¤t_holder) + { + Ok(endorsees) => endorsees + .into_iter() + .map(|e| e.pay_to_the_order_of.node_id) + .collect(), + Err(e) => { + error!("Failed to get past endorsees for recourse timeout: {e}"); + return HashMap::new(); + } + } + } + _ => { + // Regular timeout goes to all NON-BEARER participants + match self + .chain + .get_all_ident_nodes_with_added_block_height(&self.bill_keys) + { + Ok(nodes) => nodes.into_keys().collect(), + Err(e) => { + error!("Failed to get Ident participants for timeout: {e}"); + return HashMap::new(); + } + } + } + }; + + for node_id in recipient_ids { + if node_id != self.sender_node_id { + overrides.insert(node_id, (event_type.clone(), ActionType::CheckBill)); + } + } + + self.generate_action_messages(overrides, None, None) + } + + pub fn generate_recourse_messages( + &self, + action: ActionType, + recoursee: &BillIdentParticipant, + ) -> HashMap> { + let event_type = match action { + ActionType::AcceptBill => BillEventType::BillAcceptanceRecourse, + ActionType::PayBill => BillEventType::BillPaymentRecourse, + _ => return HashMap::new(), + }; + + let mut overrides: HashMap = HashMap::new(); + + if recoursee.node_id != self.sender_node_id { + overrides.insert(recoursee.node_id.clone(), (event_type, action.clone())); + } + + self.generate_action_messages(overrides, None, None) + } + + fn generate_bill_paid_internal(&self) -> HashMap> { + let mut overrides: HashMap = HashMap::new(); + + let holder_node_id = self + .bill + .endorsee + .as_ref() + .map(|e| e.node_id()) + .unwrap_or_else(|| self.bill.payee.node_id()); + + if holder_node_id != self.sender_node_id { + overrides.insert( + holder_node_id, + (BillEventType::BillPaid, ActionType::CheckBill), + ); + } + + self.generate_action_messages(overrides, None, None) + } } /// Used to signal a change in the blockchain of a bill and an optional @@ -153,6 +481,10 @@ pub struct BillChainEventPayload { pub bill_id: BillId, pub action_type: Option, pub sum: Option, + /// The node ID of the participant who triggered this event (sender) + pub sender_node_id: Option, + /// The display name of the participant who triggered this event + pub sender_name: Option, } /// The different types of events that can be sent via this service. diff --git a/crates/bcr-ebill-persistence/src/db/notification.rs b/crates/bcr-ebill-persistence/src/db/notification.rs index 0f99f797..98cf20a1 100644 --- a/crates/bcr-ebill-persistence/src/db/notification.rs +++ b/crates/bcr-ebill-persistence/src/db/notification.rs @@ -16,7 +16,7 @@ use crate::{ }; use bcr_ebill_core::{application::ServiceTraitBounds, protocol::event::bill_events::ActionType}; use bcr_ebill_core::{ - application::notification::{Notification, NotificationType}, + application::notification::{Notification, NotificationLevel, NotificationType}, protocol::DateTimeUtc, protocol::Timestamp, }; @@ -114,6 +114,9 @@ impl NotificationStoreApi for SurrealNotificationStore { if let Some(node_ids) = filter.get_node_ids() { bindings.add(&node_ids.0, node_ids.1.to_owned())?; } + if let Some(level) = filter.get_level() { + bindings.add(&level.0, level.1.to_owned())?; + } let result: Vec = self.db.query(&format!( "SELECT * FROM type::table($table) {filters} ORDER BY datetime DESC LIMIT $limit START $offset" ), bindings).await?; @@ -270,6 +273,7 @@ struct NotificationDb { pub description: String, pub datetime: DateTimeUtc, pub active: bool, + pub level: NotificationLevel, pub payload: Option, pub event_id: Option, } @@ -284,6 +288,7 @@ impl From for Notification { description: value.description, datetime: value.datetime, active: value.active, + level: value.level, payload: value.payload, event_id: value.event_id, } @@ -304,6 +309,7 @@ impl From for NotificationDb { description: value.description, datetime: value.datetime, active: value.active, + level: value.level, payload: value.payload, event_id: value.event_id, } @@ -671,7 +677,13 @@ mod tests { } fn test_notification(bill_id: &BillId, payload: Option) -> Notification { - Notification::new_bill_notification(bill_id, &node_id_test(), "test_notification", payload) + Notification::new_bill_notification( + bill_id, + &node_id_test(), + "test_notification", + payload, + NotificationLevel::Informational, + ) } fn test_payload() -> Value { @@ -687,6 +699,7 @@ mod tests { description: "general desc".to_string(), datetime: Timestamp::now().to_datetime(), active: true, + level: NotificationLevel::Informational, payload: None, event_id: None, } diff --git a/crates/bcr-ebill-persistence/src/notification.rs b/crates/bcr-ebill-persistence/src/notification.rs index b055df03..aa7161ae 100644 --- a/crates/bcr-ebill-persistence/src/notification.rs +++ b/crates/bcr-ebill-persistence/src/notification.rs @@ -73,6 +73,7 @@ pub struct NotificationFilter { pub notification_type: Option, pub node_ids: Vec, pub event_id: Option, + pub level: Option, pub limit: Option, pub offset: Option, } @@ -92,6 +93,9 @@ impl NotificationFilter { if self.event_id.is_some() { parts.push("event_id = $event_id"); } + if self.level.is_some() { + parts.push("level = $level"); + } if !self.node_ids.is_empty() { parts.push("node_id IN $node_ids"); @@ -145,6 +149,12 @@ impl NotificationFilter { None } } + + pub fn get_level(&self) -> Option<(String, String)> { + self.level + .as_ref() + .map(|level| ("level".to_string(), level.to_string())) + } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -199,12 +209,13 @@ mod tests { notification_type: Some("Bill".to_string()), node_ids: vec![node_id_test()], event_id: Some("test_event_id".to_string()), + level: Some("ActionRequired".to_string()), ..Default::default() }; assert_eq!( all.filters(), - "WHERE active = $active AND reference_id = $reference_id AND notification_type = $notification_type AND event_id = $event_id AND node_id IN $node_ids" + "WHERE active = $active AND reference_id = $reference_id AND notification_type = $notification_type AND event_id = $event_id AND level = $level AND node_id IN $node_ids" ); } } diff --git a/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs b/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs index 340648e7..4d16c977 100644 --- a/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs +++ b/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs @@ -6,7 +6,9 @@ use async_trait::async_trait; use bcr_common::core::{BillId, NodeId}; use bcr_ebill_api::service::transport_service::{Error, Result}; use bcr_ebill_core::application::ServiceTraitBounds; -use bcr_ebill_core::application::notification::{Notification, NotificationType}; +use bcr_ebill_core::application::notification::{ + Notification, NotificationLevel, NotificationType, +}; use bcr_ebill_core::protocol::event::BillChainEventPayload; use bcr_ebill_core::protocol::event::BillEventType; use bcr_ebill_core::protocol::event::Event; @@ -97,12 +99,26 @@ impl BillActionEventHandler { return Ok(()); } + // Determine notification level from action type + let level = event + .action_type + .as_ref() + .map(|a| { + if a.is_actionable() { + NotificationLevel::ActionRequired + } else { + NotificationLevel::Informational + } + }) + .unwrap_or(NotificationLevel::Informational); + // create notification let mut notification = Notification::new_bill_notification( &event.bill_id, node_id, &event_description(&event.event_type), Some(serde_json::to_value(event)?), + level, ); notification.event_id = event_id; @@ -250,9 +266,11 @@ mod tests { EventType::Bill, BillChainEventPayload { bill_id: bill_id_test(), - event_type: BillEventType::BillBlock, - sum: Some(Sum::new_sat(100).expect("sat works")), - action_type: Some(ActionType::CheckBill), + event_type: BillEventType::BillAcceptanceRequested, + sum: Some(Sum::new_sat(500).expect("sat works")), + action_type: Some(ActionType::AcceptBill), + sender_node_id: None, + sender_name: None, }, ); @@ -292,6 +310,8 @@ mod tests { event_type: BillEventType::BillBlock, sum: None, action_type: None, + sender_node_id: None, + sender_name: None, }, ); @@ -337,6 +357,7 @@ mod tests { &node_id_test(), "description", None, + NotificationLevel::ActionRequired, )) }); @@ -355,6 +376,8 @@ mod tests { event_type: BillEventType::BillAcceptanceRequested, sum: Some(Sum::new_sat(500).expect("sat works")), action_type: Some(ActionType::AcceptBill), + sender_node_id: None, + sender_name: None, }, ); @@ -435,6 +458,8 @@ mod tests { event_type: BillEventType::BillAcceptanceRequested, sum: Some(Sum::new_sat(500).expect("sat works")), action_type: Some(ActionType::AcceptBill), + sender_node_id: None, + sender_name: None, }, ); diff --git a/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs b/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs index 30ff380b..a8f63efb 100644 --- a/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs +++ b/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs @@ -12,7 +12,7 @@ use bcr_ebill_core::{ application::{ company::CompanyStatus, identity::ActiveIdentityState, - notification::{Notification, NotificationType}, + notification::{Notification, NotificationLevel, NotificationType}, }, protocol::{ EditOptionalFieldMode, Validate, @@ -840,8 +840,13 @@ impl CompanyChainEventProcessor { _ => return Ok(()), // no notifications for these yet }; - let notification = - Notification::new_company_notification(company_id, node_id, &description, event); + let notification = Notification::new_company_notification( + company_id, + node_id, + &description, + event, + NotificationLevel::ActionRequired, + ); // mark Company event as done if any active one exists match self diff --git a/crates/bcr-ebill-transport/src/handler/contact_share_handler.rs b/crates/bcr-ebill-transport/src/handler/contact_share_handler.rs index c42cd9e8..5e485421 100644 --- a/crates/bcr-ebill-transport/src/handler/contact_share_handler.rs +++ b/crates/bcr-ebill-transport/src/handler/contact_share_handler.rs @@ -8,7 +8,7 @@ use bcr_ebill_core::{ application::ServiceTraitBounds, application::contact::Contact, application::nostr_contact::NostrContact, - application::notification::Notification, + application::notification::{Notification, NotificationLevel}, protocol::{ Timestamp, crypto::decrypt_ecies, @@ -160,6 +160,7 @@ impl NotificationHandlerApi for ContactShareEventHandler { node_id, description, serde_json::to_value(contact).ok(), + NotificationLevel::ActionRequired, ); self.notification_store diff --git a/crates/bcr-ebill-transport/src/notification_transport.rs b/crates/bcr-ebill-transport/src/notification_transport.rs index 440b0215..f5aee179 100644 --- a/crates/bcr-ebill-transport/src/notification_transport.rs +++ b/crates/bcr-ebill-transport/src/notification_transport.rs @@ -10,7 +10,9 @@ use bcr_ebill_api::service::transport_service::NotificationTransportServiceApi; use bcr_ebill_api::service::transport_service::{Error, Result}; use bcr_ebill_api::util::{validate_bill_id_network, validate_node_id_network}; use bcr_ebill_core::application::ServiceTraitBounds; -use bcr_ebill_core::application::notification::{Notification, NotificationType}; +use bcr_ebill_core::application::notification::{ + Notification, NotificationLevel, NotificationType, +}; use bcr_ebill_core::{ protocol::Sum, protocol::blockchain::bill::participant::BillParticipant, @@ -58,6 +60,8 @@ impl NotificationTransportService { bill_id: bill_id.to_owned(), action_type, sum, + sender_node_id: None, + sender_name: None, }; let notification = Notification::new_bill_notification( @@ -68,6 +72,7 @@ impl NotificationTransportService { serde_json::to_value(&payload) .map_err(|e| Error::Message(format!("Failed to serialize payload: {e}")))?, ), + NotificationLevel::Informational, ); if let Ok(Some(currently_active)) = self @@ -200,6 +205,8 @@ impl NotificationTransportServiceApi for NotificationTransportService { bill_id: bill_id.to_owned(), action_type: Some(ActionType::CheckBill), sum, + sender_node_id: Some(sender_node_id.clone()), + sender_name: None, }; let event = Event::new_bill(payload); if let Some(r) = recoursee { @@ -285,8 +292,11 @@ mod tests { use bcr_common::core::{BillId, NodeId}; use bcr_ebill_api::service::transport_service::NotificationTransportServiceApi; use bcr_ebill_core::{ - application::notification::Notification, protocol::Email, protocol::Sum, - protocol::blockchain::bill::participant::BillParticipant, protocol::crypto::BcrKeys, + application::notification::{Notification, NotificationLevel}, + protocol::Email, + protocol::Sum, + protocol::blockchain::bill::participant::BillParticipant, + protocol::crypto::BcrKeys, protocol::event::ActionType, }; use bcr_ebill_persistence::notification::NotificationFilter; @@ -409,8 +419,13 @@ mod tests { #[tokio::test] async fn get_client_notifications() { init_test_cfg(); - let result = - Notification::new_bill_notification(&bill_id_test(), &node_id_test(), "desc", None); + let result = Notification::new_bill_notification( + &bill_id_test(), + &node_id_test(), + "desc", + None, + NotificationLevel::Informational, + ); let filter = NotificationFilter { active: Some(true), ..Default::default() diff --git a/crates/bcr-ebill-transport/src/transport_service.rs b/crates/bcr-ebill-transport/src/transport_service.rs index 4f09a8e3..42f57af6 100644 --- a/crates/bcr-ebill-transport/src/transport_service.rs +++ b/crates/bcr-ebill-transport/src/transport_service.rs @@ -69,60 +69,32 @@ impl TransportServiceApi for TransportService { } async fn send_bill_is_signed_event(&self, event: &BillChainEvent) -> Result<()> { - let event_type = BillEventType::BillSigned; - let sender = event.sender(); - let drawer = &event.bill.drawer.node_id; - let drawee = &event.bill.drawee.node_id; - let payee = &event.bill.payee.node_id(); - - let all_events = event.generate_action_messages( - HashMap::from_iter(vec![ - ( - event.bill.drawee.node_id.clone(), - (event_type.clone(), ActionType::AcceptBill), - ), - ( - event.bill.payee.node_id().clone(), - (event_type, ActionType::CheckBill), - ), - ]), - None, - None, - ); + let all_events = event.generate_messages(BillEventType::BillSigned); self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport - .send_all_bill_events(&sender, &all_events) + .send_all_bill_events(&event.sender(), &all_events) .await?; - // send email(s) - if drawer != drawee && drawer != payee { - // if we're drawer, but neither drawee, nor payee, send mail to both - if let Some(payee_event) = all_events.get(payee) { - self.notification_transport_service - .send_email_notification(&event.sender(), payee, payee_event) - .await; - } - if let Some(drawee_event) = all_events.get(drawee) { - self.notification_transport_service - .send_email_notification(&event.sender(), drawee, drawee_event) - .await; - } - } else if drawer == drawee { - // if we're drawer & drawee, send mail to payee only + let payee = event.bill.payee.node_id(); + let drawee = event.bill.drawee.node_id.clone(); + let drawer = event.bill.drawer.node_id.clone(); - if let Some(payee_event) = all_events.get(payee) { + if let Some(payee_event) = all_events.get(&payee) { + // No self-notification: don't email payee if drawer is payee (unless drawer == drawee) + if drawer != payee || drawer == drawee { self.notification_transport_service - .send_email_notification(&event.sender(), payee, payee_event) + .send_email_notification(&event.sender(), &payee, payee_event) .await; } - } else if drawer == payee { - // if we're drawer & payee, send mail to drawee only - if let Some(drawee_event) = all_events.get(drawee) { + } + if let Some(drawee_event) = all_events.get(&drawee) { + // No self-notification: don't email drawee if drawer is drawee (unless drawer == payee) + if drawer != drawee || drawer == payee { self.notification_transport_service - .send_email_notification(&event.sender(), drawee, drawee_event) + .send_email_notification(&event.sender(), &drawee, drawee_event) .await; } } @@ -131,151 +103,98 @@ impl TransportServiceApi for TransportService { } async fn send_bill_is_accepted_event(&self, event: &BillChainEvent) -> Result<()> { - let payee = event.bill.payee.node_id(); - let drawer = &event.bill.drawer.node_id; - - // Build recipients: payee and drawer (avoiding duplicates) - let mut recipients = vec![( - payee.clone(), - (BillEventType::BillAccepted, ActionType::CheckBill), - )]; - - // Add drawer only if different from payee - if drawer != &payee { - recipients.push(( - drawer.clone(), - (BillEventType::BillAccepted, ActionType::CheckBill), - )); - } - - let all_events = event.generate_action_messages(HashMap::from_iter(recipients), None, None); + let all_events = event.generate_messages(BillEventType::BillAccepted); self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to payee - if let Some(payee_event) = all_events.get(&payee) { + let holder = event + .bill + .endorsee + .as_ref() + .map(|e| e.node_id()) + .unwrap_or_else(|| event.bill.payee.node_id()); + if let Some(holder_event) = all_events.get(&holder) { self.notification_transport_service - .send_email_notification(&event.sender(), &payee, payee_event) + .send_email_notification(&event.sender(), &holder, holder_event) .await; } Ok(()) } async fn send_request_to_accept_event(&self, event: &BillChainEvent) -> Result<()> { - let drawee = &event.bill.drawee.node_id; - let all_events = event.generate_action_messages( - HashMap::from_iter(vec![( - drawee.clone(), - ( - BillEventType::BillAcceptanceRequested, - ActionType::AcceptBill, - ), - )]), - None, - None, - ); + let all_events = event.generate_messages(BillEventType::BillAcceptanceRequested); self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to drawee - if let Some(drawee_event) = all_events.get(drawee) { + let drawee = event.bill.drawee.node_id.clone(); + if let Some(drawee_event) = all_events.get(&drawee) { self.notification_transport_service - .send_email_notification(&event.sender(), drawee, drawee_event) + .send_email_notification(&event.sender(), &drawee, drawee_event) .await; } Ok(()) } async fn send_request_to_pay_event(&self, event: &BillChainEvent) -> Result<()> { - let drawee = &event.bill.drawee.node_id; - let all_events = event.generate_action_messages( - HashMap::from_iter(vec![( - drawee.clone(), - (BillEventType::BillPaymentRequested, ActionType::PayBill), - )]), - None, - None, - ); + let all_events = event.generate_messages(BillEventType::BillPaymentRequested); self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to drawee - if let Some(drawee_event) = all_events.get(drawee) { + let drawee = event.bill.drawee.node_id.clone(); + if let Some(drawee_event) = all_events.get(&drawee) { self.notification_transport_service - .send_email_notification(&event.sender(), drawee, drawee_event) + .send_email_notification(&event.sender(), &drawee, drawee_event) .await; } Ok(()) } async fn send_bill_is_paid_event(&self, event: &BillChainEvent) -> Result<()> { - let sender = event.sender(); - let payee = event.bill.payee.node_id(); - let drawer = &event.bill.drawer.node_id; - - // Build recipients: payee and drawer (avoiding duplicates) - let mut recipients = vec![( - payee.clone(), - (BillEventType::BillPaid, ActionType::CheckBill), - )]; - - // Add drawer only if different from payee - if drawer != &payee { - recipients.push(( - drawer.clone(), - (BillEventType::BillPaid, ActionType::CheckBill), - )); - } - - let all_events = event.generate_action_messages(HashMap::from_iter(recipients), None, None); + let all_events = event.generate_messages(BillEventType::BillPaid); self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport - .send_all_bill_events(&sender, &all_events) + .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to holder and only if we are drawee - let holder = event.bill.endorsee.as_ref().unwrap_or(&event.bill.payee); - if let Some(holder_event) = all_events.get(&holder.node_id()) - && sender == event.bill.drawee.node_id - { + let holder = event + .bill + .endorsee + .as_ref() + .map(|e| e.node_id()) + .unwrap_or_else(|| event.bill.payee.node_id()); + if let Some(holder_event) = all_events.get(&holder) { self.notification_transport_service - .send_email_notification(&sender, &holder.node_id(), holder_event) + .send_email_notification(&event.sender(), &holder, holder_event) .await; } Ok(()) } async fn send_bill_is_endorsed_event(&self, event: &BillChainEvent) -> Result<()> { - let endorsee = event.bill.endorsee.as_ref().unwrap().node_id(); - let all_events = event.generate_action_messages( - HashMap::from_iter(vec![( - endorsee.clone(), - (BillEventType::BillEndorsed, ActionType::CheckBill), - )]), - None, - None, - ); + let all_events = event.generate_messages(BillEventType::BillEndorsed); self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to endorsee - if let Some(endorsee_event) = all_events.get(&endorsee) { - self.notification_transport_service - .send_email_notification(&event.sender(), &endorsee, endorsee_event) - .await; + if let Some(ref endorsee) = event.bill.endorsee { + let endorsee_node_id = endorsee.node_id(); + if let Some(endorsee_event) = all_events.get(&endorsee_node_id) { + self.notification_transport_service + .send_email_notification(&event.sender(), &endorsee_node_id, endorsee_event) + .await; + } } Ok(()) } @@ -285,24 +204,17 @@ impl TransportServiceApi for TransportService { event: &BillChainEvent, buyer: &BillParticipant, ) -> Result<()> { - let all_events = event.generate_action_messages( - HashMap::from_iter(vec![( - buyer.node_id().clone(), - (BillEventType::BillSellOffered, ActionType::CheckBill), - )]), - None, - None, - ); + let all_events = event.generate_offer_to_sell_messages(buyer); self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to buyer - if let Some(buyer_event) = all_events.get(&buyer.node_id()) { + let buyer_node_id = buyer.node_id(); + if let Some(buyer_event) = all_events.get(&buyer_node_id) { self.notification_transport_service - .send_email_notification(&event.sender(), &buyer.node_id(), buyer_event) + .send_email_notification(&event.sender(), &buyer_node_id, buyer_event) .await; } Ok(()) @@ -313,33 +225,17 @@ impl TransportServiceApi for TransportService { event: &BillChainEvent, buyer: &BillParticipant, ) -> Result<()> { - let seller = event.bill.endorsee.as_ref().unwrap_or(&event.bill.payee); - - // Build recipients: buyer and seller (avoiding duplicates) - let mut recipients = vec![( - buyer.node_id().clone(), - (BillEventType::BillSold, ActionType::CheckBill), - )]; - - // Add seller only if different from buyer - if buyer.node_id() != seller.node_id() { - recipients.push(( - seller.node_id(), - (BillEventType::BillSold, ActionType::CheckBill), - )); - } - - let all_events = event.generate_action_messages(HashMap::from_iter(recipients), None, None); + let all_events = event.generate_bill_sold_messages(buyer); self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to buyer - if let Some(buyer_event) = all_events.get(&buyer.node_id()) { + let buyer_node_id = buyer.node_id(); + if let Some(buyer_event) = all_events.get(&buyer_node_id) { self.notification_transport_service - .send_email_notification(&event.sender(), &buyer.node_id(), buyer_event) + .send_email_notification(&event.sender(), &buyer_node_id, buyer_event) .await; } Ok(()) @@ -364,7 +260,6 @@ impl TransportServiceApi for TransportService { self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to recoursee if let Some(recoursee_event) = all_events.get(&recoursee.node_id) { self.notification_transport_service .send_email_notification(&event.sender(), &recoursee.node_id, recoursee_event) @@ -384,6 +279,8 @@ impl TransportServiceApi for TransportService { bill_id: bill.id.clone(), action_type: Some(ActionType::CheckBill), sum: Some(bill.sum.clone()), + sender_node_id: Some(sender_node_id.clone()), + sender_name: None, }); let node = self.nostr_transport.get_node_transport(sender_node_id); node.send_private_event(sender_node_id, mint, event.clone().try_into()?) @@ -400,30 +297,23 @@ impl TransportServiceApi for TransportService { event: &BillChainEvent, rejected_action: ActionType, ) -> Result<()> { - if let Some(event_type) = rejected_action.get_rejected_event_type() { - let drawee = &event.bill.drawee.node_id; - - // Build recipients: everyone in bill chain except payer (drawee) - let recipients: HashMap = event - .get_all_participant_node_ids() - .into_iter() - .filter(|node_id| node_id != drawee) - .map(|node_id| (node_id, (event_type.clone(), ActionType::CheckBill))) - .collect(); - - let all_events = event.generate_action_messages(recipients, None, None); - + let all_events = event.generate_rejected_messages(rejected_action); + if !all_events.is_empty() { self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to holder (=requester) - let holder = event.bill.endorsee.as_ref().unwrap_or(&event.bill.payee); - if let Some(holder_event) = all_events.get(&holder.node_id()) { + let holder = event + .bill + .endorsee + .as_ref() + .map(|e| e.node_id()) + .unwrap_or_else(|| event.bill.payee.node_id()); + if let Some(holder_event) = all_events.get(&holder) { self.notification_transport_service - .send_email_notification(&event.sender(), &holder.node_id(), holder_event) + .send_email_notification(&event.sender(), &holder, holder_event) .await; } } @@ -436,22 +326,14 @@ impl TransportServiceApi for TransportService { action: ActionType, recoursee: &BillIdentParticipant, ) -> Result<()> { - if let Some(event_type) = action.get_recourse_event_type() { - let all_events = event.generate_action_messages( - HashMap::from_iter(vec![( - recoursee.node_id.clone(), - (event_type.clone(), action.clone()), - )]), - None, - None, - ); + let all_events = event.generate_recourse_messages(action, recoursee); + if !all_events.is_empty() { self.block_transport_service .send_bill_chain_events(event.clone()) .await?; self.nostr_transport .send_all_bill_events(&event.sender(), &all_events) .await?; - // Only send email to recoursee if let Some(recoursee_event) = all_events.get(&recoursee.node_id) { self.notification_transport_service .send_email_notification(&event.sender(), &recoursee.node_id, recoursee_event) @@ -778,22 +660,21 @@ mod tests { .returning(|_, _, _| Ok(())) .times(2); - // expect to send recourse rejected event to all recipients (except payer) + // Recourse rejection goes to prior holders only; this test chain has none transport .expect_send_private_event() .withf(|_, _, e| check_chain_payload(e, BillEventType::BillRecourseRejected)) - .returning(|_, _, _| Ok(())) - .times(2); + .never(); block_transport .expect_send_bill_chain_events() .returning(|_| Ok(())) - .times(4); + .times(3); notification_transport .expect_send_email_notification() .returning(|_, _, _| ()) - .times(4); + .times(3); // this is only required for the test as it contains an invite block so it tries to send an // invite to new participants as well and the test data doesn't have them all. @@ -2588,6 +2469,8 @@ mod tests { bill_id: bill_id_test(), action_type: Some(ActionType::CheckBill), sum: Some(Sum::new_sat(1).unwrap()), + sender_node_id: None, + sender_name: None, }); let payload = base58::encode(&borsh::to_vec(&legacy_event).unwrap()); diff --git a/crates/bcr-ebill-wasm/src/data/mod.rs b/crates/bcr-ebill-wasm/src/data/mod.rs index 29dc4bc9..51cef8bf 100644 --- a/crates/bcr-ebill-wasm/src/data/mod.rs +++ b/crates/bcr-ebill-wasm/src/data/mod.rs @@ -267,6 +267,7 @@ pub struct NotificationFilters { pub active: Option, pub reference_id: Option, pub notification_type: Option, + pub level: Option, #[tsify(type = "string[] | undefined")] pub node_ids: Option>, pub limit: Option, @@ -281,6 +282,7 @@ impl From for NotificationFilter { notification_type: value.notification_type, node_ids: value.node_ids.unwrap_or_default(), event_id: None, + level: value.level, limit: value.limit, offset: value.offset, } diff --git a/crates/bcr-ebill-wasm/src/data/notification.rs b/crates/bcr-ebill-wasm/src/data/notification.rs index cce36a2e..4c744567 100644 --- a/crates/bcr-ebill-wasm/src/data/notification.rs +++ b/crates/bcr-ebill-wasm/src/data/notification.rs @@ -1,5 +1,7 @@ use bcr_common::core::NodeId; -use bcr_ebill_core::application::notification::{Notification, NotificationType}; +use bcr_ebill_core::application::notification::{ + Notification, NotificationLevel, NotificationType, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tsify::Tsify; @@ -24,6 +26,7 @@ pub struct NotificationWeb { pub description: String, pub datetime: String, pub active: bool, + pub level: NotificationLevelWeb, #[tsify(type = "any | undefined")] pub payload: Option, } @@ -40,6 +43,7 @@ impl From for NotificationWeb { .datetime .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), active: val.active, + level: val.level.into(), payload: val.payload, } } @@ -64,3 +68,19 @@ impl From for NotificationTypeWeb { } } } + +#[derive(Tsify, Debug, Copy, Clone, Serialize, Deserialize)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub enum NotificationLevelWeb { + Informational, + ActionRequired, +} + +impl From for NotificationLevelWeb { + fn from(val: NotificationLevel) -> Self { + match val { + NotificationLevel::Informational => NotificationLevelWeb::Informational, + NotificationLevel::ActionRequired => NotificationLevelWeb::ActionRequired, + } + } +} From d38a3895507c2e0c54902ab38a3a65ac545a8c2f Mon Sep 17 00:00:00 2001 From: tompro Date: Tue, 5 May 2026 16:23:34 +0200 Subject: [PATCH 3/7] Cleanup, logging and minor fixes --- .../src/service/bill_service/blocks.rs | 15 +- .../src/service/bill_service/payment.rs | 86 +++++-- .../src/service/bill_service/test_utils.rs | 13 +- .../src/service/bill_service/tests.rs | 17 +- .../src/service/file_reference_helper.rs | 1 - .../notification_transport.rs | 13 +- .../service/transport_service/transport.rs | 4 - .../src/application/notification.rs | 2 +- .../src/protocol/event/bill_events.rs | 73 +++++- .../handler/company_chain_event_processor.rs | 233 ++++++++++++++++-- .../src/notification_transport.rs | 18 +- crates/bcr-ebill-transport/src/test_utils.rs | 8 + .../src/transport_service.rs | 63 ----- 13 files changed, 428 insertions(+), 118 deletions(-) diff --git a/crates/bcr-ebill-api/src/service/bill_service/blocks.rs b/crates/bcr-ebill-api/src/service/bill_service/blocks.rs index 7f184956..f77ee314 100644 --- a/crates/bcr-ebill-api/src/service/bill_service/blocks.rs +++ b/crates/bcr-ebill-api/src/service/bill_service/blocks.rs @@ -30,7 +30,7 @@ use bcr_ebill_core::{ BcrKeys, btc::{calculate_tweak_hash_for_payment_request, get_address_to_pay}, }, - event::{CompanyChainEvent, IdentityChainEvent}, + event::{ActionType, BillEventType, CompanyChainEvent, IdentityChainEvent}, }, }; use log::error; @@ -655,6 +655,19 @@ impl BillService { self.validate_and_add_block(&bill_id, blockchain, block.clone()) .await?; + if let BillAction::Sell(_) = bill_action { + self.transport_service + .notification_transport() + .create_local_bill_notification( + &signer_public_data.node_id(), + &bill_id, + BillEventType::BillSold, + Some(ActionType::CheckBill), + Some(bill.sum.clone()), + ) + .await?; + } + self.add_identity_and_company_chain_blocks_for_signed_bill_action( signer_public_data, &bill_id, diff --git a/crates/bcr-ebill-api/src/service/bill_service/payment.rs b/crates/bcr-ebill-api/src/service/bill_service/payment.rs index 43af13ec..b6fc337f 100644 --- a/crates/bcr-ebill-api/src/service/bill_service/payment.rs +++ b/crates/bcr-ebill-api/src/service/bill_service/payment.rs @@ -13,15 +13,14 @@ use bcr_ebill_core::{ blockchain::{ Blockchain, bill::{ - BillBlockchain, BillOpCode, BitcreditBill, OfferToSellWaitingForPayment, - RecourseWaitingForPayment, - block::BillRequestToPayBlockData, + BillOpCode, BitcreditBill, OfferToSellWaitingForPayment, RecourseWaitingForPayment, + block::{BillOfferToSellBlockData, BillRequestToPayBlockData}, participant::{BillAnonParticipant, BillIdentParticipant, BillParticipant}, }, identity::IdentityType, }, crypto::BcrKeys, - event::BillChainEvent, + event::{ActionType, BillEventType}, }, }; use log::{debug, info}; @@ -89,9 +88,7 @@ impl BillService { self.store.invalidate_bill_in_cache(bill_id).await?; // the bill is paid now - trigger notification if let PaymentState::PaidConfirmed(_) = payment_state - && let Err(e) = self - .trigger_is_paid_notification(identity, &chain, &bill_keys, &bill) - .await + && let Err(e) = self.trigger_is_paid_notification(&bill).await { log::error!("Could not send is-paid notification for {bill_id}: {e}"); } @@ -108,22 +105,26 @@ impl BillService { Ok(()) } - async fn trigger_is_paid_notification( - &self, - identity: &Identity, - blockchain: &BillBlockchain, - bill_keys: &BcrKeys, - last_version_bill: &BitcreditBill, - ) -> Result<()> { - let chain_event = BillChainEvent::new( - last_version_bill, - blockchain, - bill_keys, - true, - &identity.node_id, - )?; + async fn trigger_is_paid_notification(&self, last_version_bill: &BitcreditBill) -> Result<()> { + let holder_node_id = last_version_bill + .endorsee + .as_ref() + .map(|e| e.node_id()) + .unwrap_or_else(|| last_version_bill.payee.node_id()); + log::debug!( + "BillPaid notification: bill_id={} recipient={}", + last_version_bill.id, + holder_node_id + ); self.transport_service - .send_bill_is_paid_event(&chain_event) + .notification_transport() + .create_local_bill_notification( + &holder_node_id, + &last_version_bill.id, + BillEventType::BillPaid, + Some(ActionType::CheckBill), + Some(last_version_bill.sum.clone()), + ) .await?; Ok(()) } @@ -368,6 +369,47 @@ impl BillService { } } } + } else if chain.get_latest_block().op_code == BillOpCode::OfferToSell { + // Deadline has passed and the last block is still OfferToSell (not Sell). + // Create a timeout notification for OUR local identity only. + let last_block = chain.get_latest_block(); + if let Ok(block_data) = + last_block.get_decrypted_block::(&bill_keys) + { + let buyer_node_id = block_data.buyer.node_id(); + let seller_node_id = block_data.seller.node_id(); + let local_node_id = &identity.identity.node_id; + let recipient = if local_node_id == &buyer_node_id { + Some(buyer_node_id) + } else if local_node_id == &seller_node_id { + Some(seller_node_id) + } else { + None + }; + if let Some(recipient_node_id) = recipient { + log::debug!( + "OfferToSell timeout: bill_id={} recipient={} (local)", + bill_id, + recipient_node_id + ); + if let Err(e) = self + .transport_service + .notification_transport() + .create_local_bill_notification( + &recipient_node_id, + bill_id, + BillEventType::BillSellOfferTimeout, + Some(ActionType::CheckBill), + Some(block_data.payment_data.sum), + ) + .await + { + log::error!( + "Failed to create sell offer timeout notification for {recipient_node_id} of bill {bill_id}: {e}" + ); + } + } + } } Ok(()) } diff --git a/crates/bcr-ebill-api/src/service/bill_service/test_utils.rs b/crates/bcr-ebill-api/src/service/bill_service/test_utils.rs index 0278c426..bd3d99d4 100644 --- a/crates/bcr-ebill-api/src/service/bill_service/test_utils.rs +++ b/crates/bcr-ebill-api/src/service/bill_service/test_utils.rs @@ -4,7 +4,7 @@ use crate::{ service::{ company_service::tests::{get_valid_company_chain, get_valid_identity_chain}, contact_service::tests::get_baseline_contact, - transport_service::MockTransportServiceApi, + transport_service::{MockNotificationTransportServiceApi, MockTransportServiceApi}, }, tests::tests::{ MockBillChainStoreApiMock, MockBillStoreApiMock, MockCompanyChainStoreApiMock, @@ -293,6 +293,17 @@ pub fn get_service(mut ctx: MockBillContext) -> BillService { ctx.transport_service .expect_publish_file_metadata() .returning(|_, _, _, _, _| Ok(())); + let mut default_notification = MockNotificationTransportServiceApi::new(); + default_notification + .expect_get_active_bill_notification() + .returning(|_| None); + default_notification + .expect_create_local_bill_notification() + .returning(|_, _, _, _, _| Ok(())); + ctx.transport_service + .expect_notification_transport() + .times(0..) + .return_const(Arc::new(default_notification)); BillService::new( Arc::new(ctx.bill_store), Arc::new(ctx.bill_blockchain_store), diff --git a/crates/bcr-ebill-api/src/service/bill_service/tests.rs b/crates/bcr-ebill-api/src/service/bill_service/tests.rs index f27f104b..7dc5b252 100644 --- a/crates/bcr-ebill-api/src/service/bill_service/tests.rs +++ b/crates/bcr-ebill-api/src/service/bill_service/tests.rs @@ -3192,10 +3192,13 @@ async fn sell_bitcredit_bill_baseline() { chain.try_add_block(offer_to_sell); Ok(chain) }); - // Request to sell event should be sent ctx.transport_service .expect_send_bill_is_sold_event() .returning(|_, _| Ok(())); + ctx.transport_service.expect_on_notification_transport(|t| { + t.expect_create_local_bill_notification() + .returning(|_, _, _, _, _| Ok(())); + }); ctx.identity_store .expect_get_email_confirmations() .returning(|| Ok(vec![signed_identity_proof_test()])); @@ -3269,6 +3272,10 @@ async fn sell_bitcredit_bill_anon_baseline() { ctx.transport_service .expect_send_bill_is_sold_event() .returning(|_, _| Ok(())); + ctx.transport_service.expect_on_notification_transport(|t| { + t.expect_create_local_bill_notification() + .returning(|_, _, _, _, _| Ok(())); + }); ctx.identity_store .expect_get_email_confirmations() .returning(|| Ok(vec![signed_identity_proof_test()])); @@ -3975,6 +3982,10 @@ async fn check_bill_offer_to_sell_payment_baseline() { ctx.transport_service .expect_send_bill_is_sold_event() .returning(|_, _| Ok(())); + ctx.transport_service.expect_on_notification_transport(|t| { + t.expect_create_local_bill_notification() + .returning(|_, _, _, _, _| Ok(())); + }); ctx.identity_store .expect_get_email_confirmations() .returning(|| Ok(vec![signed_identity_proof_test()])); @@ -4034,6 +4045,10 @@ async fn check_bills_offer_to_sell_payment_company_is_seller() { ctx.transport_service .expect_send_bill_is_sold_event() .returning(|_, _| Ok(())); + ctx.transport_service.expect_on_notification_transport(|t| { + t.expect_create_local_bill_notification() + .returning(|_, _, _, _, _| Ok(())); + }); ctx.identity_store .expect_get_email_confirmations() .returning(|| Ok(vec![signed_identity_proof_test()])); diff --git a/crates/bcr-ebill-api/src/service/file_reference_helper.rs b/crates/bcr-ebill-api/src/service/file_reference_helper.rs index 7b366a06..df9505dd 100644 --- a/crates/bcr-ebill-api/src/service/file_reference_helper.rs +++ b/crates/bcr-ebill-api/src/service/file_reference_helper.rs @@ -354,7 +354,6 @@ mod tests { async fn send_bill_is_accepted_event(&self, event: &bcr_ebill_core::protocol::event::BillChainEvent) -> crate::service::transport_service::Result<()>; async fn send_request_to_accept_event(&self, event: &bcr_ebill_core::protocol::event::BillChainEvent) -> crate::service::transport_service::Result<()>; async fn send_request_to_pay_event(&self, event: &bcr_ebill_core::protocol::event::BillChainEvent) -> crate::service::transport_service::Result<()>; - async fn send_bill_is_paid_event(&self, event: &bcr_ebill_core::protocol::event::BillChainEvent) -> crate::service::transport_service::Result<()>; async fn send_bill_is_endorsed_event(&self, event: &bcr_ebill_core::protocol::event::BillChainEvent) -> crate::service::transport_service::Result<()>; async fn send_offer_to_sell_event(&self, event: &bcr_ebill_core::protocol::event::BillChainEvent, buyer: &bcr_ebill_core::protocol::blockchain::bill::participant::BillParticipant) -> crate::service::transport_service::Result<()>; async fn send_bill_is_sold_event(&self, event: &bcr_ebill_core::protocol::event::BillChainEvent, buyer: &bcr_ebill_core::protocol::blockchain::bill::participant::BillParticipant) -> crate::service::transport_service::Result<()>; diff --git a/crates/bcr-ebill-api/src/service/transport_service/notification_transport.rs b/crates/bcr-ebill-api/src/service/transport_service/notification_transport.rs index 7c410fae..07e003df 100644 --- a/crates/bcr-ebill-api/src/service/transport_service/notification_transport.rs +++ b/crates/bcr-ebill-api/src/service/transport_service/notification_transport.rs @@ -7,7 +7,7 @@ use bcr_ebill_core::{ protocol::Sum, protocol::blockchain::bill::participant::BillParticipant, protocol::event::ActionType, - protocol::event::{BillChainEventPayload, Event}, + protocol::event::{BillChainEventPayload, BillEventType, Event}, }; use bcr_ebill_persistence::notification::NotificationFilter; use std::collections::HashMap; @@ -43,6 +43,17 @@ pub trait NotificationTransportServiceApi: ServiceTraitBounds { node_ids: &[NodeId], ) -> Result>; + /// Creates a local bill notification for the given node without sending Nostr events. + /// Marks any existing active bill notification as done and pushes to connected clients. + async fn create_local_bill_notification( + &self, + node_id: &NodeId, + bill_id: &BillId, + event_type: BillEventType, + action_type: Option, + sum: Option, + ) -> Result<()>; + /// In case a participant did not perform an action (e.g. request to accept, request /// to pay) in time we notify all bill participants about the timed out action. Will /// only send the event if the given action can be a timed out action. diff --git a/crates/bcr-ebill-api/src/service/transport_service/transport.rs b/crates/bcr-ebill-api/src/service/transport_service/transport.rs index 08980372..381d977f 100644 --- a/crates/bcr-ebill-api/src/service/transport_service/transport.rs +++ b/crates/bcr-ebill-api/src/service/transport_service/transport.rs @@ -54,10 +54,6 @@ pub trait TransportServiceApi: ServiceTraitBounds { /// Receiver: Payer, Action: PayBill async fn send_request_to_pay_event(&self, event: &BillChainEvent) -> Result<()>; - /// Sent when: A bill is paid by: Payer (Bitcoin API) - /// Receiver: Payee, Action: CheckBill - async fn send_bill_is_paid_event(&self, event: &BillChainEvent) -> Result<()>; - /// Sent when: A bill is endorsed by: Previous Holder /// Receiver: NewHolder, Action: CheckBill async fn send_bill_is_endorsed_event(&self, event: &BillChainEvent) -> Result<()>; diff --git a/crates/bcr-ebill-core/src/application/notification.rs b/crates/bcr-ebill-core/src/application/notification.rs index dd25ecd6..471e1975 100644 --- a/crates/bcr-ebill-core/src/application/notification.rs +++ b/crates/bcr-ebill-core/src/application/notification.rs @@ -119,7 +119,7 @@ impl Display for NotificationType { /// Indicates the urgency/attention level of a notification. /// ActionRequired means the user needs to take action. /// Informational means no immediate action is needed. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub enum NotificationLevel { #[default] Informational, diff --git a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs index c47d1684..ec6df14d 100644 --- a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs +++ b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs @@ -3,7 +3,8 @@ use crate::protocol::{ blockchain::{ Blockchain, bill::{ - BillBlock, BillBlockchain, BitcreditBill, + BillBlock, BillBlockchain, BillOpCode, BitcreditBill, + block::BillOfferToSellBlockData, participant::{BillIdentParticipant, BillParticipant}, }, }, @@ -129,6 +130,13 @@ impl BillChainEvent { .unwrap_or((event_type.clone(), action.clone())); event_type.map(|e| { + log::debug!( + "Bill transport event: type={:?} sender={} recipient={} action={:?}", + e, + sender_node_id, + node_id, + override_action + ); ( node_id.to_owned(), Event::new( @@ -377,6 +385,7 @@ impl BillChainEvent { ActionType::AcceptBill => BillEventType::BillAcceptanceTimeout, ActionType::PayBill => BillEventType::BillPaymentTimeout, ActionType::RecourseBill => BillEventType::BillRecourseTimeout, + ActionType::BuyBill => BillEventType::BillSellOfferTimeout, _ => return HashMap::new(), }; @@ -405,6 +414,37 @@ impl BillChainEvent { } } } + ActionType::BuyBill => match self + .chain + .get_last_version_block_with_op_code(BillOpCode::OfferToSell) + { + Some(offer_block) => { + match offer_block + .get_decrypted_block::(&self.bill_keys) + { + Ok(block_data) => { + let mut ids = vec![]; + let buyer_node_id = block_data.buyer.node_id(); + let seller_node_id = block_data.seller.node_id(); + if buyer_node_id != self.sender_node_id { + ids.push(buyer_node_id); + } + if seller_node_id != self.sender_node_id { + ids.push(seller_node_id); + } + ids + } + Err(e) => { + error!("Failed to decrypt offer to sell block for timeout: {e}"); + return HashMap::new(); + } + } + } + None => { + error!("No offer to sell block found for BuyBill timeout"); + return HashMap::new(); + } + }, _ => { // Regular timeout goes to all NON-BEARER participants match self @@ -509,6 +549,7 @@ pub enum BillEventType { BillRecourseTimeout, BillPaymentTimeout, BillSellOffered, + BillSellOfferTimeout, BillBuyingRejected, BillPaid, BillRecoursePaid, @@ -537,6 +578,7 @@ impl BillEventType { Self::BillRecourseTimeout, Self::BillRecourseRejected, Self::BillSellOffered, + Self::BillSellOfferTimeout, Self::BillBuyingRejected, Self::BillPaid, Self::BillRecoursePaid, @@ -584,6 +626,7 @@ impl BillEventType { BillEventType::BillRecourseRejected => "Bill_recourse_rejected".to_string(), BillEventType::BillRecourseTimeout => "Bill_recourse_timed_out".to_string(), BillEventType::BillSellOffered => "bill_request_to_buy".to_string(), + BillEventType::BillSellOfferTimeout => "bill_sell_offer_timed_out".to_string(), BillEventType::BillBuyingRejected => "bill_buying_rejected".to_string(), BillEventType::BillPaid => "bill_paid".to_string(), BillEventType::BillRecoursePaid => "bill_recourse_paid".to_string(), @@ -623,6 +666,7 @@ impl ActionType { Self::AcceptBill => Some(BillEventType::BillAcceptanceTimeout), Self::PayBill => Some(BillEventType::BillPaymentTimeout), Self::RecourseBill => Some(BillEventType::BillRecourseTimeout), + Self::BuyBill => Some(BillEventType::BillSellOfferTimeout), _ => None, } } @@ -641,3 +685,30 @@ impl ActionType { !matches!(self, Self::CheckBill) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_timeout_event_type() { + assert_eq!( + ActionType::AcceptBill.get_timeout_event_type(), + Some(BillEventType::BillAcceptanceTimeout) + ); + assert_eq!( + ActionType::PayBill.get_timeout_event_type(), + Some(BillEventType::BillPaymentTimeout) + ); + assert_eq!( + ActionType::RecourseBill.get_timeout_event_type(), + Some(BillEventType::BillRecourseTimeout) + ); + assert_eq!( + ActionType::BuyBill.get_timeout_event_type(), + Some(BillEventType::BillSellOfferTimeout) + ); + assert_eq!(ActionType::CheckBill.get_timeout_event_type(), None); + assert_eq!(ActionType::CheckQuote.get_timeout_event_type(), None); + } +} diff --git a/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs b/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs index a8f63efb..2e59618b 100644 --- a/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs +++ b/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs @@ -26,7 +26,7 @@ use std::sync::Arc; use bcr_ebill_core::{ application::ServiceTraitBounds, - application::company::Company, + application::company::{Company, CompanySignatoryStatus}, protocol::BlockId, protocol::blockchain::{ Blockchain, BlockchainType, @@ -624,6 +624,33 @@ impl CompanyChainEventProcessor { error!("Couldn't create notification for company invite for {company_id}: {e}"); } + for signatory in company.signatories.iter() { + if signatory.node_id != payload.inviter + && signatory.node_id != payload.invitee + && matches!( + signatory.status, + CompanySignatoryStatus::InviteAcceptedIdentityProven { .. } + ) + && let Err(e) = self + .save_company_notification( + company_id, + &signatory.node_id, + &format!( + "{} invited {} to become a signatory", + payload.inviter, payload.invitee + ), + Some(serde_json::to_value(&company)?), + NotificationLevel::Informational, + ) + .await + { + error!( + "Couldn't create notification for signatory {} about company invite for {company_id}: {e}", + signatory.node_id + ); + } + } + // reset local hiding state for the invited user, so it's shown again if let Err(e) = self .company_store @@ -642,7 +669,7 @@ impl CompanyChainEventProcessor { .ensure_nostr_contact(&payload.accepter) .await; company.apply_block_data( - &CompanyBlockPayload::SignatoryAcceptInvite(payload), + &CompanyBlockPayload::SignatoryAcceptInvite(payload.clone()), identity_node_id, timestamp, ); @@ -650,20 +677,92 @@ impl CompanyChainEventProcessor { .update(company_id, company) .await .map_err(|e| Error::Persistence(e.to_string()))?; + + if let Some(inviter) = company + .signatories + .iter() + .find(|s| matches!(&s.status, CompanySignatoryStatus::Invited { inviter, .. } if *inviter == payload.accepter)) + .map(|s| s.node_id.clone()) + { + for signatory in company.signatories.iter() { + if signatory.node_id != payload.accepter + && matches!( + signatory.status, + CompanySignatoryStatus::InviteAcceptedIdentityProven { .. } + ) + { + let desc = if signatory.node_id == inviter { + format!("{} accepted your invitation to become a signatory", payload.accepter) + } else { + format!("{} accepted the invitation to become a signatory", payload.accepter) + }; + if let Err(e) = self + .save_company_notification( + company_id, + &signatory.node_id, + &desc, + Some(serde_json::to_value(&company)?), + NotificationLevel::Informational, + ) + .await + { + error!("Couldn't create notification for signatory {} about invite accept for {company_id}: {e}", signatory.node_id); + } + } + } + } } - update @ CompanyBlockPayload::SignatoryRejectInvite(_) => { + CompanyBlockPayload::SignatoryRejectInvite(payload) => { info!("Signatory rejected invite to company {company_id}"); - company.apply_block_data(&update, identity_node_id, timestamp); + company.apply_block_data( + &CompanyBlockPayload::SignatoryRejectInvite(payload.clone()), + identity_node_id, + timestamp, + ); self.company_store .update(company_id, company) .await .map_err(|e| Error::Persistence(e.to_string()))?; + + if let Some(inviter) = company + .signatories + .iter() + .find(|s| matches!(&s.status, CompanySignatoryStatus::Invited { inviter, .. } if *inviter == payload.rejecter)) + .map(|s| s.node_id.clone()) + { + for signatory in company.signatories.iter() { + if signatory.node_id != payload.rejecter + && matches!( + signatory.status, + CompanySignatoryStatus::InviteAcceptedIdentityProven { .. } + ) + { + let desc = if signatory.node_id == inviter { + format!("{} rejected your invitation to become a signatory", payload.rejecter) + } else { + format!("{} rejected the invitation to become a signatory", payload.rejecter) + }; + if let Err(e) = self + .save_company_notification( + company_id, + &signatory.node_id, + &desc, + Some(serde_json::to_value(&company)?), + NotificationLevel::Informational, + ) + .await + { + error!("Couldn't create notification for signatory {} about invite reject for {company_id}: {e}", signatory.node_id); + } + } + } + } } CompanyBlockPayload::RemoveSignatory(payload) => { let removee = payload.removee.clone(); info!("Removing signatory from company {company_id}"); company.apply_block_data( - &CompanyBlockPayload::RemoveSignatory(payload), + &CompanyBlockPayload::RemoveSignatory(payload.clone()), identity_node_id, timestamp, ); @@ -692,6 +791,42 @@ impl CompanyChainEventProcessor { "Couldn't set active identity to personal after removing self from company: {e}" ); } + + if let Err(e) = self + .create_notification( + company_id, + &payload.removee, + &CompanyBlockPayload::RemoveSignatory(payload.clone()), + ) + .await + { + error!( + "Couldn't create notification for removed signatory {} from company {company_id}: {e}", + payload.removee + ); + } + + for signatory in company.signatories.iter() { + if signatory.node_id != payload.remover + && signatory.node_id != payload.removee + && matches!( + signatory.status, + CompanySignatoryStatus::InviteAcceptedIdentityProven { .. } + ) + && let Err(e) = self + .create_notification( + company_id, + &signatory.node_id, + &CompanyBlockPayload::RemoveSignatory(payload.clone()), + ) + .await + { + error!( + "Couldn't create notification for signatory {} about removal from company {company_id}: {e}", + signatory.node_id + ); + } + } } CompanyBlockPayload::SignBill(payload) => { if let Some(bill_key) = payload.bill_key @@ -829,36 +964,75 @@ impl CompanyChainEventProcessor { .get(company_id) .await .map_err(|e| Error::Persistence(e.to_string()))?; - let (description, event) = match payload { + let (description, event, level) = match payload { CompanyBlockPayload::InviteSignatory(_) => { let desc = format!( "{} have requested you to become an authorised signer", company.name ); - (desc, Some(serde_json::to_value(&company)?)) + ( + desc, + Some(serde_json::to_value(&company)?), + NotificationLevel::ActionRequired, + ) + } + CompanyBlockPayload::RemoveSignatory(payload) => { + let desc = if node_id == &payload.removee { + format!("You were removed from {}", company.name) + } else { + format!("{} was removed from {}", payload.removee, company.name) + }; + ( + desc, + Some(serde_json::to_value(&company)?), + NotificationLevel::Informational, + ) } _ => return Ok(()), // no notifications for these yet }; - let notification = Notification::new_company_notification( - company_id, - node_id, - &description, - event, - NotificationLevel::ActionRequired, + self.save_company_notification(company_id, node_id, &description, event, level) + .await + } + + async fn save_company_notification( + &self, + company_id: &NodeId, + node_id: &NodeId, + description: &str, + event: Option, + level: NotificationLevel, + ) -> Result<()> { + debug!( + "Company notification: company_id={} recipient={} level={:?} desc=\"{}\"", + company_id, node_id, level, description ); + let notification = + Notification::new_company_notification(company_id, node_id, description, event, level); // mark Company event as done if any active one exists + // BUT only if we're not demoting an ActionRequired notification to Informational match self .notification_store .get_latest_by_reference(&company_id.to_string(), NotificationType::Company) .await { Ok(Some(currently_active)) => { - if let Err(e) = self - .notification_store - .mark_as_done(¤tly_active.id) - .await + let should_replace = match (currently_active.level, level) { + // Never demote ActionRequired to Informational + (NotificationLevel::ActionRequired, NotificationLevel::Informational) => { + trace!( + "Skipping company notification for {company_id}: would demote ActionRequired to Informational" + ); + false + } + _ => true, + }; + if should_replace + && let Err(e) = self + .notification_store + .mark_as_done(¤tly_active.id) + .await { error!("Failed to mark currently active notification as done: {e}"); } @@ -893,6 +1067,7 @@ impl CompanyChainEventProcessor { impl ServiceTraitBounds for CompanyChainEventProcessor {} #[cfg(test)] +#[allow(unused_mut)] pub mod tests { use std::sync::Arc; @@ -910,6 +1085,7 @@ pub mod tests { use bcr_ebill_core::{ application::company::Company, application::identity::ActiveIdentityState, + application::notification::{Notification, NotificationLevel}, protocol::Sha256Hash, protocol::Timestamp, protocol::blockchain::{ @@ -1909,13 +2085,13 @@ pub mod tests { let ( mut chain_store, mut store, - notification_store, + mut notification_store, mut contact, bill, mut identity, chain_event_store, transport, - push_service, + mut push_service, ) = create_mocks(); let new_node_id = node_id_test_another(); let (node_id, (mut company, keys)) = get_company_data(); @@ -1963,13 +2139,12 @@ pub mod tests { .returning(move |_| Ok(keys.clone())) .once(); - // get the current company state let expected_company = company.clone(); store .expect_get() .with(eq(node_id.clone())) .returning(move |_| Ok(expected_company.clone())) - .once(); + .times(2); // apply changes from block with the signatory let expected_node = node_id.clone(); @@ -2008,6 +2183,22 @@ pub mod tests { .returning(|_| ()) .never(); + notification_store + .expect_get_latest_by_reference() + .returning(|_, _| Ok(None)); + + notification_store.expect_add().returning(|_| { + Ok(Notification::new_company_notification( + &node_id_test(), + &node_id_test_another(), + "test", + None, + NotificationLevel::Informational, + )) + }); + + push_service.expect_send().returning(|_| ()); + let handler = CompanyChainEventProcessor::new( Arc::new(chain_store), Arc::new(store), diff --git a/crates/bcr-ebill-transport/src/notification_transport.rs b/crates/bcr-ebill-transport/src/notification_transport.rs index f5aee179..f10898a4 100644 --- a/crates/bcr-ebill-transport/src/notification_transport.rs +++ b/crates/bcr-ebill-transport/src/notification_transport.rs @@ -20,7 +20,7 @@ use bcr_ebill_core::{ }; use bcr_ebill_persistence::NotificationStoreApi; use bcr_ebill_persistence::notification::{EmailNotificationStoreApi, NotificationFilter}; -use log::error; +use log::{debug, error}; use crate::PushApi; @@ -55,6 +55,10 @@ impl NotificationTransportService { action_type: Option, sum: Option, ) -> Result<()> { + debug!( + "Local bill notification: type={:?} bill_id={} recipient={} action={:?}", + event_type, bill_id, node_id, action_type + ); let payload = BillChainEventPayload { event_type: event_type.clone(), bill_id: bill_id.to_owned(), @@ -172,6 +176,18 @@ impl NotificationTransportServiceApi for NotificationTransportService { .unwrap_or_default()) } + async fn create_local_bill_notification( + &self, + node_id: &NodeId, + bill_id: &BillId, + event_type: BillEventType, + action_type: Option, + sum: Option, + ) -> Result<()> { + self.create_bill_notification(node_id, bill_id, event_type, action_type, sum) + .await + } + async fn send_request_to_action_timed_out_event( &self, sender_node_id: &NodeId, diff --git a/crates/bcr-ebill-transport/src/test_utils.rs b/crates/bcr-ebill-transport/src/test_utils.rs index 9559599d..23fb8a01 100644 --- a/crates/bcr-ebill-transport/src/test_utils.rs +++ b/crates/bcr-ebill-transport/src/test_utils.rs @@ -517,6 +517,14 @@ mockall::mock! { &self, node_ids: &[NodeId], ) -> Result>; + async fn create_local_bill_notification( + &self, + node_id: &NodeId, + bill_id: &BillId, + event_type: BillEventType, + action_type: Option, + sum: Option, + ) -> Result<()>; async fn send_request_to_action_timed_out_event( &self, sender_node_id: &NodeId, diff --git a/crates/bcr-ebill-transport/src/transport_service.rs b/crates/bcr-ebill-transport/src/transport_service.rs index 42f57af6..7adffcf9 100644 --- a/crates/bcr-ebill-transport/src/transport_service.rs +++ b/crates/bcr-ebill-transport/src/transport_service.rs @@ -158,28 +158,6 @@ impl TransportServiceApi for TransportService { Ok(()) } - async fn send_bill_is_paid_event(&self, event: &BillChainEvent) -> Result<()> { - let all_events = event.generate_messages(BillEventType::BillPaid); - self.block_transport_service - .send_bill_chain_events(event.clone()) - .await?; - self.nostr_transport - .send_all_bill_events(&event.sender(), &all_events) - .await?; - let holder = event - .bill - .endorsee - .as_ref() - .map(|e| e.node_id()) - .unwrap_or_else(|| event.bill.payee.node_id()); - if let Some(holder_event) = all_events.get(&holder) { - self.notification_transport_service - .send_email_notification(&event.sender(), &holder, holder_event) - .await; - } - Ok(()) - } - async fn send_bill_is_endorsed_event(&self, event: &BillChainEvent) -> Result<()> { let all_events = event.generate_messages(BillEventType::BillEndorsed); self.block_transport_service @@ -1296,47 +1274,6 @@ mod tests { .expect("failed to send event"); } - #[tokio::test] - async fn test_send_bill_is_paid_event() { - init_test_cfg(); - let payer = get_identity_public_data( - &node_id_test(), - &Email::new("drawee@example.com").unwrap(), - vec![], - ); - let payee = get_identity_public_data( - &node_id_test_other(), - &Email::new("payee@example.com").unwrap(), - vec![], - ); - let bill = get_test_bitcredit_bill(&bill_id_test(), &payer, &payee, None, None); - let chain = get_genesis_chain(Some(bill.clone())); - let (service, event) = expect_service( - |mock, mock_contact_store, _, _, _, notification_transport, _, block_transport| { - let payer = payer.clone(); - let payee = payee.clone(); - setup_chain_expectation( - vec![ - (payee, BillEventType::BillPaid, Some(ActionType::CheckBill)), - (payer, BillEventType::BillPaid, Some(ActionType::CheckBill)), - ], - &bill, - &chain, - false, - mock_contact_store, - mock, - block_transport, - notification_transport, - ) - }, - ); - - service - .send_bill_is_paid_event(&event) - .await - .expect("failed to send event"); - } - #[tokio::test] async fn test_send_bill_is_endorsed_event() { init_test_cfg(); From f153889d544b4d1092e8a89104bb7488b5d26d2c Mon Sep 17 00:00:00 2001 From: tompro Date: Wed, 6 May 2026 12:32:44 +0200 Subject: [PATCH 4/7] Review fixes, preserve action by recipient --- crates/bcr-ebill-api/src/tests/mod.rs | 6 ++++ .../src/application/notification.rs | 1 + .../src/db/notification.rs | 20 +++++++++++++ .../bcr-ebill-persistence/src/notification.rs | 7 +++++ .../src/handler/bill_action_event_handler.rs | 28 ++++++++++++++----- .../handler/company_chain_event_processor.rs | 10 +++++-- crates/bcr-ebill-transport/src/handler/mod.rs | 6 ++++ .../src/notification_transport.rs | 10 +++++-- crates/bcr-ebill-transport/src/test_utils.rs | 6 ++++ 9 files changed, 81 insertions(+), 13 deletions(-) diff --git a/crates/bcr-ebill-api/src/tests/mod.rs b/crates/bcr-ebill-api/src/tests/mod.rs index 3deb8909..ac32677b 100644 --- a/crates/bcr-ebill-api/src/tests/mod.rs +++ b/crates/bcr-ebill-api/src/tests/mod.rs @@ -415,6 +415,12 @@ pub mod tests { reference: &str, notification_type: NotificationType, ) -> Result>; + async fn get_latest_by_reference_and_node_id( + &self, + reference: &str, + notification_type: NotificationType, + node_id: &NodeId, + ) -> Result>; #[allow(unused)] async fn list_by_type(&self, notification_type: NotificationType) -> Result>; async fn mark_as_done(&self, notification_id: &str) -> Result<()>; diff --git a/crates/bcr-ebill-core/src/application/notification.rs b/crates/bcr-ebill-core/src/application/notification.rs index 471e1975..5410e45a 100644 --- a/crates/bcr-ebill-core/src/application/notification.rs +++ b/crates/bcr-ebill-core/src/application/notification.rs @@ -29,6 +29,7 @@ pub struct Notification { /// some action to dismiss the notification. pub active: bool, /// The urgency/attention level of the notification + #[serde(default)] pub level: NotificationLevel, /// Additional data to be used for notification specific logic pub payload: Option, diff --git a/crates/bcr-ebill-persistence/src/db/notification.rs b/crates/bcr-ebill-persistence/src/db/notification.rs index 98cf20a1..8e814739 100644 --- a/crates/bcr-ebill-persistence/src/db/notification.rs +++ b/crates/bcr-ebill-persistence/src/db/notification.rs @@ -178,6 +178,25 @@ impl NotificationStoreApi for SurrealNotificationStore { .await?; Ok(result.first().cloned()) } + /// Returns the latest active notification for the given reference, notification type and node id + async fn get_latest_by_reference_and_node_id( + &self, + reference: &str, + notification_type: NotificationType, + node_id: &NodeId, + ) -> Result> { + let result = self + .list(NotificationFilter { + active: Some(true), + reference_id: Some(reference.to_owned()), + notification_type: Some(notification_type.to_string()), + node_ids: vec![node_id.to_owned()], + limit: Some(1), + ..Default::default() + }) + .await?; + Ok(result.first().cloned()) + } /// Returns all notifications for the given reference and notification type that are active async fn list_by_type(&self, notification_type: NotificationType) -> Result> { let result = self @@ -273,6 +292,7 @@ struct NotificationDb { pub description: String, pub datetime: DateTimeUtc, pub active: bool, + #[serde(default)] pub level: NotificationLevel, pub payload: Option, pub event_id: Option, diff --git a/crates/bcr-ebill-persistence/src/notification.rs b/crates/bcr-ebill-persistence/src/notification.rs index aa7161ae..062c2e1e 100644 --- a/crates/bcr-ebill-persistence/src/notification.rs +++ b/crates/bcr-ebill-persistence/src/notification.rs @@ -36,6 +36,13 @@ pub trait NotificationStoreApi: ServiceTraitBounds { reference: &str, notification_type: NotificationType, ) -> Result>; + /// Returns the latest active notification for the given reference, notification type and node id + async fn get_latest_by_reference_and_node_id( + &self, + reference: &str, + notification_type: NotificationType, + node_id: &NodeId, + ) -> Result>; /// Returns all notifications for the given reference and notification type that are active #[allow(unused)] async fn list_by_type(&self, notification_type: NotificationType) -> Result>; diff --git a/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs b/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs index 4d16c977..fbf5f89b 100644 --- a/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs +++ b/crates/bcr-ebill-transport/src/handler/bill_action_event_handler.rs @@ -77,7 +77,11 @@ impl BillActionEventHandler { let current_active = self .notification_store - .get_latest_by_reference(&event.bill_id.to_string(), NotificationType::Bill) + .get_latest_by_reference_and_node_id( + &event.bill_id.to_string(), + NotificationType::Bill, + node_id, + ) .await; let current_is_actionable = match ¤t_active { @@ -248,7 +252,9 @@ mod tests { .returning(|_, _| Ok(false)); // not look for currently active notification - notification_store.expect_get_latest_by_reference().never(); + notification_store + .expect_get_latest_by_reference_and_node_id() + .never(); // not store new notification notification_store.expect_add().never(); @@ -290,7 +296,9 @@ mod tests { let (mut notification_store, mut push_service, chain_processor) = create_mocks(); // look for currently active notification - notification_store.expect_get_latest_by_reference().never(); + notification_store + .expect_get_latest_by_reference_and_node_id() + .never(); // store new notification notification_store.expect_add().never(); @@ -345,10 +353,14 @@ mod tests { // look for currently active notification notification_store - .expect_get_latest_by_reference() - .with(eq(bill_id_test().to_string()), eq(NotificationType::Bill)) + .expect_get_latest_by_reference_and_node_id() + .with( + eq(bill_id_test().to_string()), + eq(NotificationType::Bill), + eq(node_id_test()), + ) .times(1) - .returning(|_, _| Ok(None)); + .returning(|_, _, _| Ok(None)); // store new notification notification_store.expect_add().times(1).returning(|_| { @@ -438,7 +450,9 @@ mod tests { .returning(|_, _| Ok(true)); // should NOT look for currently active notification - notification_store.expect_get_latest_by_reference().never(); + notification_store + .expect_get_latest_by_reference_and_node_id() + .never(); // should NOT store new notification notification_store.expect_add().never(); diff --git a/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs b/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs index 2e59618b..f7cd40e8 100644 --- a/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs +++ b/crates/bcr-ebill-transport/src/handler/company_chain_event_processor.rs @@ -1014,7 +1014,11 @@ impl CompanyChainEventProcessor { // BUT only if we're not demoting an ActionRequired notification to Informational match self .notification_store - .get_latest_by_reference(&company_id.to_string(), NotificationType::Company) + .get_latest_by_reference_and_node_id( + &company_id.to_string(), + NotificationType::Company, + node_id, + ) .await { Ok(Some(currently_active)) => { @@ -2184,8 +2188,8 @@ pub mod tests { .never(); notification_store - .expect_get_latest_by_reference() - .returning(|_, _| Ok(None)); + .expect_get_latest_by_reference_and_node_id() + .returning(|_, _, _| Ok(None)); notification_store.expect_add().returning(|_| { Ok(Notification::new_company_notification( diff --git a/crates/bcr-ebill-transport/src/handler/mod.rs b/crates/bcr-ebill-transport/src/handler/mod.rs index 59b7656c..14d78cf6 100644 --- a/crates/bcr-ebill-transport/src/handler/mod.rs +++ b/crates/bcr-ebill-transport/src/handler/mod.rs @@ -467,6 +467,12 @@ mod test_utils { reference: &str, notification_type: NotificationType, ) -> Result>; + async fn get_latest_by_reference_and_node_id( + &self, + reference: &str, + notification_type: NotificationType, + node_id: &NodeId, + ) -> Result>; #[allow(unused)] async fn list_by_type(&self, notification_type: bcr_ebill_core::application::notification::NotificationType) -> Result>; async fn mark_as_done(&self, notification_id: &str) -> Result<()>; diff --git a/crates/bcr-ebill-transport/src/notification_transport.rs b/crates/bcr-ebill-transport/src/notification_transport.rs index f10898a4..7960dfd6 100644 --- a/crates/bcr-ebill-transport/src/notification_transport.rs +++ b/crates/bcr-ebill-transport/src/notification_transport.rs @@ -81,7 +81,11 @@ impl NotificationTransportService { if let Ok(Some(currently_active)) = self .notification_store - .get_latest_by_reference(&bill_id.to_string(), NotificationType::Bill) + .get_latest_by_reference_and_node_id( + &bill_id.to_string(), + NotificationType::Bill, + node_id, + ) .await { let _ = self @@ -354,8 +358,8 @@ mod tests { let service = expect_service(|mock_store, _, email_client, mock_push| { mock_store.expect_add().returning(Ok).times(2); mock_store - .expect_get_latest_by_reference() - .returning(|_, _| Ok(None)) + .expect_get_latest_by_reference_and_node_id() + .returning(|_, _, _| Ok(None)) .times(2); mock_push.expect_send().returning(|_| ()).times(2); diff --git a/crates/bcr-ebill-transport/src/test_utils.rs b/crates/bcr-ebill-transport/src/test_utils.rs index 23fb8a01..d0ad41c1 100644 --- a/crates/bcr-ebill-transport/src/test_utils.rs +++ b/crates/bcr-ebill-transport/src/test_utils.rs @@ -631,6 +631,12 @@ mockall::mock! { reference: &str, notification_type: bcr_ebill_core::application::notification::NotificationType, ) -> bcr_ebill_persistence::Result>; + async fn get_latest_by_reference_and_node_id( + &self, + reference: &str, + notification_type: bcr_ebill_core::application::notification::NotificationType, + node_id: &NodeId, + ) -> bcr_ebill_persistence::Result>; #[allow(unused)] async fn list_by_type(&self, notification_type: bcr_ebill_core::application::notification::NotificationType) -> bcr_ebill_persistence::Result>; async fn mark_as_done(&self, notification_id: &str) -> bcr_ebill_persistence::Result<()>; From 0563b093e43174e7c162b313a78d102c4c20278b Mon Sep 17 00:00:00 2001 From: tompro Date: Wed, 6 May 2026 14:37:22 +0200 Subject: [PATCH 5/7] Review fixes --- .../src/service/bill_service/payment.rs | 43 +-------- .../src/protocol/blockchain/bill/chain.rs | 90 +++++++++++-------- .../src/protocol/event/bill_events.rs | 10 +-- 3 files changed, 59 insertions(+), 84 deletions(-) diff --git a/crates/bcr-ebill-api/src/service/bill_service/payment.rs b/crates/bcr-ebill-api/src/service/bill_service/payment.rs index b6fc337f..39f15416 100644 --- a/crates/bcr-ebill-api/src/service/bill_service/payment.rs +++ b/crates/bcr-ebill-api/src/service/bill_service/payment.rs @@ -14,7 +14,7 @@ use bcr_ebill_core::{ Blockchain, bill::{ BillOpCode, BitcreditBill, OfferToSellWaitingForPayment, RecourseWaitingForPayment, - block::{BillOfferToSellBlockData, BillRequestToPayBlockData}, + block::BillRequestToPayBlockData, participant::{BillAnonParticipant, BillIdentParticipant, BillParticipant}, }, identity::IdentityType, @@ -369,47 +369,6 @@ impl BillService { } } } - } else if chain.get_latest_block().op_code == BillOpCode::OfferToSell { - // Deadline has passed and the last block is still OfferToSell (not Sell). - // Create a timeout notification for OUR local identity only. - let last_block = chain.get_latest_block(); - if let Ok(block_data) = - last_block.get_decrypted_block::(&bill_keys) - { - let buyer_node_id = block_data.buyer.node_id(); - let seller_node_id = block_data.seller.node_id(); - let local_node_id = &identity.identity.node_id; - let recipient = if local_node_id == &buyer_node_id { - Some(buyer_node_id) - } else if local_node_id == &seller_node_id { - Some(seller_node_id) - } else { - None - }; - if let Some(recipient_node_id) = recipient { - log::debug!( - "OfferToSell timeout: bill_id={} recipient={} (local)", - bill_id, - recipient_node_id - ); - if let Err(e) = self - .transport_service - .notification_transport() - .create_local_bill_notification( - &recipient_node_id, - bill_id, - BillEventType::BillSellOfferTimeout, - Some(ActionType::CheckBill), - Some(block_data.payment_data.sum), - ) - .await - { - log::error!( - "Failed to create sell offer timeout notification for {recipient_node_id} of bill {bill_id}: {e}" - ); - } - } - } } Ok(()) } diff --git a/crates/bcr-ebill-core/src/protocol/blockchain/bill/chain.rs b/crates/bcr-ebill-core/src/protocol/blockchain/bill/chain.rs index bd8638b8..93f9ee9c 100644 --- a/crates/bcr-ebill-core/src/protocol/blockchain/bill/chain.rs +++ b/crates/bcr-ebill-core/src/protocol/blockchain/bill/chain.rs @@ -836,104 +836,120 @@ impl BillBlockchain { } /// Returns all Ident (non-Anon) nodes of the bill with the block height they were added in. - /// BEARER (Anon) participants are excluded. + /// BEARER (Anon) participants are excluded, but nodes that appear as both Anon and Ident + /// are included because they are not exclusively BEARER. pub fn get_all_ident_nodes_with_added_block_height( &self, bill_keys: &BcrKeys, ) -> Result> { let all_nodes = self.get_all_nodes_with_added_block_height(bill_keys)?; - let anon_nodes = self.get_anon_node_ids(bill_keys)?; + let ident_nodes = self.get_ident_node_ids(bill_keys)?; Ok(all_nodes .into_iter() - .filter(|(node_id, _)| !anon_nodes.contains(node_id)) + .filter(|(node_id, _)| ident_nodes.contains(node_id)) .collect()) } - /// Returns a set of all Anon (BEARER) node IDs that have participated in the bill chain. - fn get_anon_node_ids(&self, bill_keys: &BcrKeys) -> Result> { + /// Returns a set of all Ident node IDs that have participated in the bill chain. + fn get_ident_node_ids(&self, bill_keys: &BcrKeys) -> Result> { use super::block::BillParticipantBlockData; - let mut anon_nodes: HashSet = HashSet::new(); + let mut ident_nodes: HashSet = HashSet::new(); for block in self.blocks.iter() { match block.op_code { BillOpCode::Issue => { let bill: BillIssueBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = bill.payee { - anon_nodes.insert(data.node_id.clone()); + // drawer and drawee are always Ident + ident_nodes.insert(bill.drawer.node_id.clone()); + ident_nodes.insert(bill.drawee.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = bill.payee { + ident_nodes.insert(data.node_id.clone()); } } BillOpCode::Endorse => { let block: BillEndorseBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.endorsee { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.endorsee { + ident_nodes.insert(data.node_id.clone()); } - if let BillParticipantBlockData::Anon(ref data) = block.endorser { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.endorser { + ident_nodes.insert(data.node_id.clone()); } } BillOpCode::Mint => { let block: BillMintBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.endorsee { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.endorsee { + ident_nodes.insert(data.node_id.clone()); } - if let BillParticipantBlockData::Anon(ref data) = block.endorser { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.endorser { + ident_nodes.insert(data.node_id.clone()); } } BillOpCode::RequestToAccept => { let block: BillRequestToAcceptBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.requester { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.requester { + ident_nodes.insert(data.node_id.clone()); } } BillOpCode::RequestToPay => { let block: BillRequestToPayBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.requester { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.requester { + ident_nodes.insert(data.node_id.clone()); } } BillOpCode::OfferToSell => { let block: BillOfferToSellBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.buyer { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.buyer { + ident_nodes.insert(data.node_id.clone()); } - if let BillParticipantBlockData::Anon(ref data) = block.seller { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.seller { + ident_nodes.insert(data.node_id.clone()); } } BillOpCode::Sell => { let block: BillSellBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.buyer { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.buyer { + ident_nodes.insert(data.node_id.clone()); } - if let BillParticipantBlockData::Anon(ref data) = block.seller { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.seller { + ident_nodes.insert(data.node_id.clone()); } } BillOpCode::RejectToBuy => { let block: BillRejectToBuyBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.rejecter { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.rejecter { + ident_nodes.insert(data.node_id.clone()); } } BillOpCode::RequestRecourse => { let block: BillRequestRecourseBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.recourser { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.recourser { + ident_nodes.insert(data.node_id.clone()); } + // recoursee can only be Ident + ident_nodes.insert(block.recoursee.node_id.clone()); } BillOpCode::Recourse => { let block: BillRecourseBlockData = block.get_decrypted_block(bill_keys)?; - if let BillParticipantBlockData::Anon(ref data) = block.recourser { - anon_nodes.insert(data.node_id.clone()); + if let BillParticipantBlockData::Ident(ref data) = block.recourser { + ident_nodes.insert(data.node_id.clone()); } + // recoursee can only be Ident + ident_nodes.insert(block.recoursee.node_id.clone()); + } + BillOpCode::Accept => { + let block: BillAcceptBlockData = block.get_decrypted_block(bill_keys)?; + ident_nodes.insert(block.accepter.node_id.clone()); + } + BillOpCode::RejectToAccept + | BillOpCode::RejectToPay + | BillOpCode::RejectToPayRecourse => { + let block: BillRejectBlockData = block.get_decrypted_block(bill_keys)?; + ident_nodes.insert(block.rejecter.node_id.clone()); } - // Accept, RejectToAccept, RejectToPay, RejectToPayRecourse only have Ident participants - _ => {} } } - Ok(anon_nodes) + Ok(ident_nodes) } pub fn get_bill_history(&self, bill_keys: &BcrKeys) -> Result { diff --git a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs index ec6df14d..b0c88569 100644 --- a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs +++ b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs @@ -88,11 +88,6 @@ impl BillChainEvent { self.participants.keys().cloned().collect() } - /// Generates bill action events for participants. Individual `node_id`s can be assigned a - /// specific event and action type by providing an override in `event_overrides`. - /// If `event_type` and `action` are provided, participants without an override receive that - /// event. Participants without an override and where `event_type` is `None` will not receive - /// any event. The recipient `node_id` is the key in the map. fn sender_name(&self) -> Option { if self.bill.drawer.node_id == self.sender_node_id { return Some(self.bill.drawer.name.to_string()); @@ -113,6 +108,11 @@ impl BillChainEvent { None } + /// Generates bill action events for participants. Individual `node_id`s can be assigned a + /// specific event and action type by providing an override in `event_overrides`. + /// If `event_type` and `action` are provided, participants without an override receive that + /// event. Participants without an override and where `event_type` is `None` will not receive + /// any event. The recipient `node_id` is the key in the map. pub fn generate_action_messages( &self, event_overrides: HashMap, From f60933a828386d8b27fabc0013295efe2d7682f5 Mon Sep 17 00:00:00 2001 From: tompro Date: Wed, 6 May 2026 16:38:11 +0200 Subject: [PATCH 6/7] Seed phrase backup notification --- crates/bcr-ebill-api/src/constants.rs | 2 ++ .../src/service/bill_service/test_utils.rs | 3 ++ .../src/service/identity_service.rs | 31 +++++++++++++++++-- .../notification_transport.rs | 12 ++++++- .../src/application/notification.rs | 20 ++++++++++++ .../src/notification_transport.rs | 28 +++++++++++++++++ crates/bcr-ebill-transport/src/test_utils.rs | 9 +++++- 7 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/bcr-ebill-api/src/constants.rs b/crates/bcr-ebill-api/src/constants.rs index 400c8645..25a891cd 100644 --- a/crates/bcr-ebill-api/src/constants.rs +++ b/crates/bcr-ebill-api/src/constants.rs @@ -26,3 +26,5 @@ pub const COMPANY_LOGO_FILE_FIELD: &str = "logo_file"; pub const COMPANY_PROOF_OF_REGISTRATION_FILE_FIELD: &str = "proof_of_registration_file"; pub const IDENTITY_PROFILE_PICTURE_FILE_FIELD: &str = "profile_picture_file"; pub const IDENTITY_DOCUMENT_FILE_FIELD: &str = "identity_document_file"; +pub const SAVE_SEED_PHRASE_NOTIFICATION_KEY: &str = "save_seed_phrase"; +pub const SAVE_SEED_PHRASE_NOTIFICATION_REFERENCE_ID: &str = "seed_phrase"; diff --git a/crates/bcr-ebill-api/src/service/bill_service/test_utils.rs b/crates/bcr-ebill-api/src/service/bill_service/test_utils.rs index bd3d99d4..e3fa9346 100644 --- a/crates/bcr-ebill-api/src/service/bill_service/test_utils.rs +++ b/crates/bcr-ebill-api/src/service/bill_service/test_utils.rs @@ -300,6 +300,9 @@ pub fn get_service(mut ctx: MockBillContext) -> BillService { default_notification .expect_create_local_bill_notification() .returning(|_, _, _, _, _| Ok(())); + default_notification + .expect_create_general_notification() + .returning(|_, _, _, _| Ok(())); ctx.transport_service .expect_notification_transport() .times(0..) diff --git a/crates/bcr-ebill-api/src/service/identity_service.rs b/crates/bcr-ebill-api/src/service/identity_service.rs index cee141b5..4f2e98c0 100644 --- a/crates/bcr-ebill-api/src/service/identity_service.rs +++ b/crates/bcr-ebill-api/src/service/identity_service.rs @@ -1,5 +1,8 @@ use super::Result; -use crate::constants::{IDENTITY_DOCUMENT_FILE_FIELD, IDENTITY_PROFILE_PICTURE_FILE_FIELD}; +use crate::constants::{ + IDENTITY_DOCUMENT_FILE_FIELD, IDENTITY_PROFILE_PICTURE_FILE_FIELD, + SAVE_SEED_PHRASE_NOTIFICATION_KEY, SAVE_SEED_PHRASE_NOTIFICATION_REFERENCE_ID, +}; use crate::external::email::EmailClientApi; use crate::external::file_storage::FileStorageClientApi; use crate::service::Error; @@ -708,6 +711,20 @@ impl IdentityServiceApi for IdentityService { self.populate_block(&identity, first_block, &keys).await?; self.on_identity_contact_change(&identity, &keys).await?; + if let Err(e) = self + .block_transport + .notification_transport() + .create_general_notification( + &node_id, + SAVE_SEED_PHRASE_NOTIFICATION_KEY, + Some(SAVE_SEED_PHRASE_NOTIFICATION_REFERENCE_ID.to_string()), + bcr_ebill_core::application::notification::NotificationLevel::ActionRequired, + ) + .await + { + error!("Failed to create save seed phrase notification: {e}"); + } + // Create and populate identity proof block if let Some((proof, data)) = email_confirmation { self.create_identity_proof_block(proof, data, &identity, &keys, &mut identity_chain) @@ -1126,6 +1143,11 @@ mod tests { t.expect_publish_contact().returning(|_, _| Ok(())).once(); t.expect_ensure_nostr_contact().returning(|_| ()).once(); }); + transport.expect_on_notification_transport(|t| { + t.expect_create_general_notification() + .returning(|_, _, _, _| Ok(())) + .once(); + }); let service = get_service_with_chain_storage(storage, chain_storage, transport); let res = service @@ -1171,8 +1193,11 @@ mod tests { t.expect_publish_contact().returning(|_, _| Ok(())).once(); t.expect_ensure_nostr_contact().returning(|_| ()).once(); }); - - // publishes contact info to nostr + transport.expect_on_notification_transport(|t| { + t.expect_create_general_notification() + .returning(|_, _, _, _| Ok(())) + .once(); + }); let service = get_service_with_chain_storage(storage, chain_storage, transport); let res = service diff --git a/crates/bcr-ebill-api/src/service/transport_service/notification_transport.rs b/crates/bcr-ebill-api/src/service/transport_service/notification_transport.rs index 07e003df..0f4ca4d2 100644 --- a/crates/bcr-ebill-api/src/service/transport_service/notification_transport.rs +++ b/crates/bcr-ebill-api/src/service/transport_service/notification_transport.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use bcr_common::core::{BillId, NodeId}; use bcr_ebill_core::{ application::ServiceTraitBounds, - application::notification::Notification, + application::notification::{Notification, NotificationLevel}, protocol::Sum, protocol::blockchain::bill::participant::BillParticipant, protocol::event::ActionType, @@ -54,6 +54,16 @@ pub trait NotificationTransportServiceApi: ServiceTraitBounds { sum: Option, ) -> Result<()>; + /// Creates a general (non-bill, non-company) notification for the given node. + /// Used for system-level notifications like "save your seed phrase". + async fn create_general_notification( + &self, + node_id: &NodeId, + description: &str, + reference_id: Option, + level: NotificationLevel, + ) -> Result<()>; + /// In case a participant did not perform an action (e.g. request to accept, request /// to pay) in time we notify all bill participants about the timed out action. Will /// only send the event if the given action can be a timed out action. diff --git a/crates/bcr-ebill-core/src/application/notification.rs b/crates/bcr-ebill-core/src/application/notification.rs index 5410e45a..ce2d11de 100644 --- a/crates/bcr-ebill-core/src/application/notification.rs +++ b/crates/bcr-ebill-core/src/application/notification.rs @@ -100,6 +100,26 @@ impl Notification { event_id: None, } } + + pub fn new_general_notification( + node_id: &NodeId, + description: &str, + reference_id: Option, + level: NotificationLevel, + ) -> Self { + Self { + id: Uuid::new_v4().to_string(), + node_id: Some(node_id.to_owned()), + notification_type: NotificationType::General, + reference_id, + description: description.to_string(), + datetime: Timestamp::now().to_datetime(), + active: true, + level, + payload: None, + event_id: None, + } + } } /// The type/topic of a notification we show to the user diff --git a/crates/bcr-ebill-transport/src/notification_transport.rs b/crates/bcr-ebill-transport/src/notification_transport.rs index 7960dfd6..fade3064 100644 --- a/crates/bcr-ebill-transport/src/notification_transport.rs +++ b/crates/bcr-ebill-transport/src/notification_transport.rs @@ -191,6 +191,34 @@ impl NotificationTransportServiceApi for NotificationTransportService { self.create_bill_notification(node_id, bill_id, event_type, action_type, sum) .await } + async fn create_general_notification( + &self, + node_id: &NodeId, + description: &str, + reference_id: Option, + level: NotificationLevel, + ) -> Result<()> { + let notification = + Notification::new_general_notification(node_id, description, reference_id, level); + + self.notification_store + .add(notification.clone()) + .await + .map_err(|e| { + error!("Failed to save general notification: {e}"); + Error::Persistence("Failed to save general notification".to_string()) + })?; + + match serde_json::to_value(notification) { + Ok(notification_value) => { + self.push_service.send(notification_value).await; + } + Err(e) => { + error!("Failed to serialize general notification for push: {e}"); + } + } + Ok(()) + } async fn send_request_to_action_timed_out_event( &self, diff --git a/crates/bcr-ebill-transport/src/test_utils.rs b/crates/bcr-ebill-transport/src/test_utils.rs index d0ad41c1..c164ae14 100644 --- a/crates/bcr-ebill-transport/src/test_utils.rs +++ b/crates/bcr-ebill-transport/src/test_utils.rs @@ -25,7 +25,7 @@ use bcr_ebill_core::{ application::contact::Contact, application::identity::{Identity, IdentityWithAll}, application::nostr_contact::{HandshakeStatus, NostrContact, NostrPublicKey, TrustLevel}, - application::notification::Notification, + application::notification::{Notification, NotificationLevel}, protocol::blockchain::BlockchainType, protocol::blockchain::{ bill::{ @@ -525,6 +525,13 @@ mockall::mock! { action_type: Option, sum: Option, ) -> Result<()>; + async fn create_general_notification( + &self, + node_id: &NodeId, + description: &str, + reference_id: Option, + level: NotificationLevel, + ) -> Result<()>; async fn send_request_to_action_timed_out_event( &self, sender_node_id: &NodeId, From 2b9725c435aa486c6d579cae655435115c38478c Mon Sep 17 00:00:00 2001 From: tompro Date: Fri, 8 May 2026 08:55:40 +0200 Subject: [PATCH 7/7] Local notification for Recourse, translation key --- .../src/service/bill_service/blocks.rs | 37 +++++++++++++------ .../src/protocol/event/bill_events.rs | 2 +- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/crates/bcr-ebill-api/src/service/bill_service/blocks.rs b/crates/bcr-ebill-api/src/service/bill_service/blocks.rs index f77ee314..e2ce7e75 100644 --- a/crates/bcr-ebill-api/src/service/bill_service/blocks.rs +++ b/crates/bcr-ebill-api/src/service/bill_service/blocks.rs @@ -655,17 +655,32 @@ impl BillService { self.validate_and_add_block(&bill_id, blockchain, block.clone()) .await?; - if let BillAction::Sell(_) = bill_action { - self.transport_service - .notification_transport() - .create_local_bill_notification( - &signer_public_data.node_id(), - &bill_id, - BillEventType::BillSold, - Some(ActionType::CheckBill), - Some(bill.sum.clone()), - ) - .await?; + match bill_action { + BillAction::Sell(_) => { + self.transport_service + .notification_transport() + .create_local_bill_notification( + &signer_public_data.node_id(), + &bill_id, + BillEventType::BillSold, + Some(ActionType::CheckBill), + Some(bill.sum.clone()), + ) + .await?; + } + BillAction::Recourse(_) => { + self.transport_service + .notification_transport() + .create_local_bill_notification( + &signer_public_data.node_id(), + &bill_id, + BillEventType::BillRecoursePaid, + Some(ActionType::CheckBill), + Some(bill.sum.clone()), + ) + .await?; + } + _ => {} } self.add_identity_and_company_chain_blocks_for_signed_bill_action( diff --git a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs index b0c88569..a63c9bd7 100644 --- a/crates/bcr-ebill-core/src/protocol/event/bill_events.rs +++ b/crates/bcr-ebill-core/src/protocol/event/bill_events.rs @@ -625,7 +625,7 @@ impl BillEventType { BillEventType::BillPaymentRecourse => "bill_recourse_payment_required".to_string(), BillEventType::BillRecourseRejected => "Bill_recourse_rejected".to_string(), BillEventType::BillRecourseTimeout => "Bill_recourse_timed_out".to_string(), - BillEventType::BillSellOffered => "bill_request_to_buy".to_string(), + BillEventType::BillSellOffered => "bill_sell_offer".to_string(), BillEventType::BillSellOfferTimeout => "bill_sell_offer_timed_out".to_string(), BillEventType::BillBuyingRejected => "bill_buying_rejected".to_string(), BillEventType::BillPaid => "bill_paid".to_string(),