diff --git a/Justfile b/Justfile index 56adbc6..a297584 100644 --- a/Justfile +++ b/Justfile @@ -10,7 +10,7 @@ build: (cd $dir && wash build); \ done -version := "0.1.0" +version := "0.2.0" push: # Push to GHCR wash push ghcr.io/cosmonic/cosmonic-gitops/bankaccount_projector:{{version}} projector/build/bankaccount_projector_s.wasm diff --git a/aggregate/src/commands.rs b/aggregate/src/commands.rs index e79e664..ad21a79 100644 --- a/aggregate/src/commands.rs +++ b/aggregate/src/commands.rs @@ -12,3 +12,57 @@ pub(crate) fn handle_create_account(input: CreateAccount) -> Result { }, )]) } + +pub(crate) fn handle_withdraw_funds( + input: WithdrawFunds, + state: Option, +) -> Result { + let Some(state) = state else { + return Err(anyhow::anyhow!( + "Rejected command to withdraw funds. Account {} does not exist.", + input.account_number + )); + }; + + if state.available_balance() < input.amount as u32 { + error!( + "Rejecting command to withdraw funds, account {} does not have sufficient funds. Available {}", + &input.account_number, state.available_balance() + ); + Ok(vec![]) + } else { + Ok(vec![Event::new( + FundsWithdrawn::TYPE, + STREAM, + &FundsWithdrawn { + note: input.note, + account_number: input.account_number.to_string(), + amount: input.amount, + customer_id: input.customer_id, + }, + )]) + } +} + +pub(crate) fn handle_deposit_funds( + input: DepositFunds, + state: Option, +) -> Result { + if state.is_none() { + return Err(anyhow::anyhow!( + "Rejected command to deposit funds. Account {} does not exist.", + input.account_number + )); + }; + + Ok(vec![Event::new( + FundsDeposited::TYPE, + STREAM, + &FundsDeposited { + note: input.note, + account_number: input.account_number.to_string(), + amount: input.amount, + customer_id: input.customer_id, + }, + )]) +} diff --git a/aggregate/src/events.rs b/aggregate/src/events.rs index 8964974..fbeebd4 100644 --- a/aggregate/src/events.rs +++ b/aggregate/src/events.rs @@ -15,3 +15,42 @@ impl From for BankAccountAggregateState { pub(crate) fn apply_account_created(input: AccountCreated) -> Result { Ok(StateAck::ok(Some(BankAccountAggregateState::from(input)))) } + +pub(crate) fn apply_funds_deposited( + input: FundsDeposited, + state: Option, +) -> Result { + let Some(state) = state else { + error!( + "Rejecting funds deposited event. Account {} does not exist.", + input.account_number + ); + return Ok(StateAck::error( + "Account does not exist", + None::, + )); + }; + let state = BankAccountAggregateState { + balance: state.balance + input.amount as u32, + ..state + }; + Ok(StateAck::ok(Some(state))) +} + +pub(crate) fn apply_funds_withdrawn( + input: FundsWithdrawn, + state: Option, +) -> Result { + let Some(state) = state else { + error!( + "Rejecting funds withdrawn event. Account {} does not exist.", + input.account_number + ); + return Ok(StateAck::error( + "Account does not exist", + None::, + )); + }; + let state = state.withdraw(input.amount as u32); + Ok(StateAck::ok(Some(state))) +} diff --git a/aggregate/src/lib.rs b/aggregate/src/lib.rs index baa5b2b..b1b7d7a 100644 --- a/aggregate/src/lib.rs +++ b/aggregate/src/lib.rs @@ -2,6 +2,7 @@ use anyhow::Result; use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use wasmcloud_interface_logging::error; mod commands; mod events; @@ -25,7 +26,24 @@ impl BankAccountAggregate for BankAccountAggregateImpl { commands::handle_create_account(input) } + fn handle_withdraw_funds( + &self, + input: WithdrawFunds, + state: Option, + ) -> anyhow::Result { + commands::handle_withdraw_funds(input, state) + } + + fn handle_deposit_funds( + &self, + input: DepositFunds, + state: Option, + ) -> anyhow::Result { + commands::handle_deposit_funds(input, state) + } + // -- Events -- + fn apply_account_created( &self, input: AccountCreated, @@ -33,6 +51,22 @@ impl BankAccountAggregate for BankAccountAggregateImpl { ) -> anyhow::Result { events::apply_account_created(input) } + + fn apply_funds_deposited( + &self, + input: FundsDeposited, + state: Option, + ) -> anyhow::Result { + events::apply_funds_deposited(input, state) + } + + fn apply_funds_withdrawn( + &self, + input: FundsWithdrawn, + state: Option, + ) -> anyhow::Result { + events::apply_funds_withdrawn(input, state) + } } const STREAM: &str = "bankaccount"; diff --git a/eventcatalog/all_events.png b/eventcatalog/all_events.png index 69baebd..1653b37 100644 Binary files a/eventcatalog/all_events.png and b/eventcatalog/all_events.png differ diff --git a/eventcatalog/events/DepositFunds/index.md b/eventcatalog/events/DepositFunds/index.md new file mode 100644 index 0000000..c744127 --- /dev/null +++ b/eventcatalog/events/DepositFunds/index.md @@ -0,0 +1,17 @@ +--- +name: DepositFunds +summary: "A request to deposit funds into an account" +version: 0.0.1 +consumers: + - 'Bank Account Aggregate' +tags: + - label: 'command' +externalLinks: [] +badges: [] +--- +Requests the deposit of a specified amount into the account. This command can fail to process if the parameters are invalid. + + + +## Schema + \ No newline at end of file diff --git a/eventcatalog/events/DepositFunds/schema.json b/eventcatalog/events/DepositFunds/schema.json new file mode 100644 index 0000000..ad2a2cc --- /dev/null +++ b/eventcatalog/events/DepositFunds/schema.json @@ -0,0 +1,30 @@ +{ + "$id": "https://cosmonic.com/concordance/bankaccount/DepositFunds.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "DepositFunds", + "type": "object", + "properties": { + "accountNumber": { + "type": "string", + "description": "The account number" + }, + "amount": { + "type": "integer", + "description": "The amount to deposit" + }, + "note": { + "type": "string", + "description": "An optional note to be associated with the deposit" + }, + "customerId": { + "type": "string", + "description": "The ID of the customer performing the deposit" + }, + "transferId": { + "type": "string", + "description": "A unique ID identifying the transfer transaction if applicable" + } + }, + "required": ["accountNumber", "amount", "customerId"] + } + \ No newline at end of file diff --git a/eventcatalog/events/FundsDeposited/index.md b/eventcatalog/events/FundsDeposited/index.md new file mode 100644 index 0000000..5093f99 --- /dev/null +++ b/eventcatalog/events/FundsDeposited/index.md @@ -0,0 +1,20 @@ +--- +name: FundsDeposited +summary: "Indicates funds have been deposited into an account" +version: 0.0.1 +consumers: + - 'Bank Account Aggregate' + - 'Bank Account Projector' +producers: + - 'Bank Account Aggregate' +tags: + - label: 'event' +externalLinks: [] +badges: [] +--- +Indicates that funds have been deposited into an account. + + + +## Schema + \ No newline at end of file diff --git a/eventcatalog/events/FundsDeposited/schema.json b/eventcatalog/events/FundsDeposited/schema.json new file mode 100644 index 0000000..e3a8211 --- /dev/null +++ b/eventcatalog/events/FundsDeposited/schema.json @@ -0,0 +1,26 @@ +{ + "$id": "https://cosmonic.com/concordance/bankaccount/FundsDeposited.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FundsDeposited", + "type": "object", + "properties": { + "accountNumber": { + "type": "string", + "description": "The account number" + }, + "amount": { + "type": "integer", + "description": "The amount deposited" + }, + "note": { + "type": "string", + "description": "An optional note to associated with the deposit" + }, + "customerId": { + "type": "string", + "description": "The ID of the customer that performed the deposit" + } + }, + "required": ["accountNumber", "amount", "customerId"] + } + \ No newline at end of file diff --git a/eventcatalog/events/FundsWithdrawn/index.md b/eventcatalog/events/FundsWithdrawn/index.md new file mode 100644 index 0000000..b1dbc81 --- /dev/null +++ b/eventcatalog/events/FundsWithdrawn/index.md @@ -0,0 +1,20 @@ +--- +name: FundsWithdrawn +summary: "Indicates a successful withdrawal of funds" +version: 0.0.1 +consumers: + - 'Bank Account Aggregate' + - 'Bank Account Projector' +producers: + - 'Bank Account Aggregate' +tags: + - label: 'event' +externalLinks: [] +badges: [] +--- +Indicates funds have been withdrawn from the account + + + +## Schema + \ No newline at end of file diff --git a/eventcatalog/events/FundsWithdrawn/schema.json b/eventcatalog/events/FundsWithdrawn/schema.json new file mode 100644 index 0000000..8f6f7ec --- /dev/null +++ b/eventcatalog/events/FundsWithdrawn/schema.json @@ -0,0 +1,26 @@ +{ + "$id": "https://cosmonic.com/concordance/bankaccount/FundsWithdrawn.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "FundsWithdrawn", + "type": "object", + "properties": { + "accountNumber": { + "type": "string", + "description": "The account number" + }, + "amount": { + "type": "integer", + "description": "The amount withdrawn" + }, + "note": { + "type": "string", + "description": "An optional note associated with the withdrawal" + }, + "customerId": { + "type": "string", + "description": "The ID of the customer that performed the withdrawal" + } + }, + "required": ["accountNumber", "amount", "customerId"] + } + \ No newline at end of file diff --git a/eventcatalog/events/WithdrawFunds/index.md b/eventcatalog/events/WithdrawFunds/index.md new file mode 100644 index 0000000..7234da9 --- /dev/null +++ b/eventcatalog/events/WithdrawFunds/index.md @@ -0,0 +1,20 @@ +--- +name: WithdrawFunds +summary: "A request to withdraw funds from an account" +version: 0.0.1 +consumers: + - 'Bank Account Aggregate' +tags: + - label: 'command' +externalLinks: [] +badges: [] +--- +Requests the withdrawal of a specified amount from the account. This command can fail to process if the parameters are invalid or if the account does not have sufficient funds. + +Note that there is a design decision here. You can allow the withdrawal to go through even if there is insufficient funds, and then also emit an overdraft event. Or all commands attempting to withdraw below the minimum (or 0 if omitted) are rejected. This is a domain/application decision and +not really something that can be decided by the framework. + + + +## Schema + \ No newline at end of file diff --git a/eventcatalog/events/WithdrawFunds/schema.json b/eventcatalog/events/WithdrawFunds/schema.json new file mode 100644 index 0000000..dc25fbf --- /dev/null +++ b/eventcatalog/events/WithdrawFunds/schema.json @@ -0,0 +1,26 @@ +{ + "$id": "https://cosmonic.com/concordance/bankaccount/WithdrawFunds.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "WithdrawFunds", + "type": "object", + "properties": { + "accountNumber": { + "type": "string", + "description": "The account number" + }, + "amount": { + "type": "integer", + "description": "The amount to withdraw" + }, + "note": { + "type": "string", + "description": "An optional note to be associated with the withdrawal" + }, + "customerId": { + "type": "string", + "description": "The ID of the customer performing the withdrawal" + } + }, + "required": ["accountNumber", "amount", "customerId"] + } + \ No newline at end of file diff --git a/eventcatalog/package.json b/eventcatalog/package.json index f767f0b..3b1b6d0 100644 --- a/eventcatalog/package.json +++ b/eventcatalog/package.json @@ -1,6 +1,6 @@ { "name": "eventcatalog", - "version": "0.1.0", + "version": "0.2.0", "private": true, "scripts": { "start": "eventcatalog start", diff --git a/projector/src/lib.rs b/projector/src/lib.rs index b73d3ca..d0aef99 100644 --- a/projector/src/lib.rs +++ b/projector/src/lib.rs @@ -14,4 +14,12 @@ impl BankAccountProjector for BankAccountProjectorImpl { async fn handle_account_created(&self, input: AccountCreated) -> Result<()> { store::initialize_account(input).await } + + async fn handle_funds_deposited(&self, input: FundsDeposited) -> Result<()> { + store::record_funds_deposited(input).await + } + + async fn handle_funds_withdrawn(&self, input: FundsWithdrawn) -> Result<()> { + store::record_funds_withdrawn(input).await + } } diff --git a/projector/src/store.rs b/projector/src/store.rs index a43700b..3463606 100644 --- a/projector/src/store.rs +++ b/projector/src/store.rs @@ -4,7 +4,7 @@ use crate::*; use serde::{Deserialize, Serialize}; use wasmbus_rpc::actor::prelude::*; -use wasmcloud_interface_keyvalue::{KeyValue, KeyValueSender, SetRequest}; +use wasmcloud_interface_keyvalue::{GetResponse, KeyValue, KeyValueSender, SetRequest}; use wasmcloud_interface_logging::{debug, error}; // Note an invariant: the last() element in a ledger's effective_balance field is @@ -35,6 +35,84 @@ pub async fn initialize_account(event: AccountCreated) -> Result<()> { Ok(()) } +/// Records a deposit by adding a `LedgerLine` to the end of the previously stored +/// ledger and recording the new balance. +pub async fn record_funds_deposited(event: FundsDeposited) -> Result<()> { + debug!("Recording deposit in account {}", event.account_number); + let account_number = event.account_number.to_string(); + let ctx = Context::default(); + + let kv = KeyValueSender::new(); + let ledger_key = format!("ledger.{account_number}"); + + let new_ledger = get(&ctx, &kv, &ledger_key).await.map(|ledger_raw| { + serde_json::from_str::(&ledger_raw).map(|mut ledger| { + let last_balance = ledger.ledger_lines.last().unwrap().effective_balance; + ledger.ledger_lines.push(LedgerLine { + amount: event.amount as u32, + tx_type: TransactionType::Deposit, + effective_balance: last_balance + event.amount as u32, + }); + ledger + }) + }); + if let Some(Ok(ledger)) = new_ledger { + let new_balance = ledger + .ledger_lines + .last() + .map(|l| l.effective_balance) + .unwrap_or(0); + set_ledger(&ctx, &kv, ledger_key, ledger).await; + let balance_key = format!("balance.{account_number}"); + set(&ctx, &kv, balance_key, new_balance.to_string()).await; + } else { + error!("Unable to save projection for deposit on account {account_number}"); + } + + Ok(()) +} + +/// Records a withdrawal from an account by adding a withdrawal ledger item to the +/// ledger and recording the new balance +pub async fn record_funds_withdrawn(event: FundsWithdrawn) -> Result<()> { + debug!("Recording withdrawal in account {}", event.account_number); + let account_number = event.account_number.to_string(); + + let kv = KeyValueSender::new(); + let ledger_key = format!("ledger.{account_number}"); + + let ctx = Context::default(); + + // Note:the aggregate would prevent the creation of an event that would violate + // business rules, so we can safely do the subtraction here without any guards + + let new_ledger = get(&ctx, &kv, &ledger_key).await.map(|ledger_raw| { + serde_json::from_str::(&ledger_raw).map(|mut ledger| { + let last_balance = ledger.ledger_lines.last().unwrap().effective_balance; + ledger.ledger_lines.push(LedgerLine { + amount: event.amount as u32, + tx_type: TransactionType::Withdrawal, + effective_balance: last_balance - event.amount as u32, + }); + ledger + }) + }); + if let Some(Ok(ledger)) = new_ledger { + let new_balance = ledger + .ledger_lines + .last() + .map(|l| l.effective_balance) + .unwrap_or(0); + set_ledger(&ctx, &kv, ledger_key, ledger).await; + let balance_key = format!("balance.{account_number}"); + set(&ctx, &kv, balance_key, new_balance.to_string()).await; + } else { + error!("Unable to save projection for withdrawal on account {account_number}"); + } + + Ok(()) +} + async fn set(ctx: &Context, kv: &KeyValueSender, key: String, value: String) { if let Err(e) = kv .set( @@ -51,6 +129,29 @@ async fn set(ctx: &Context, kv: &KeyValueSender, key: String, value: S } } +async fn set_ledger( + ctx: &Context, + kv: &KeyValueSender, + key: String, + ledger: AccountLedger, +) { + set(ctx, kv, key, serde_json::to_string(&ledger).unwrap()).await +} + +async fn get(ctx: &Context, kv: &KeyValueSender, key: &str) -> Option { + match kv.get(ctx, key).await { + Ok(GetResponse { + value: v, + exists: true, + }) => Some(v), + Ok(GetResponse { exists: false, .. }) => None, + Err(e) => { + error!("Failed to get {key} from store: {e}"); + None + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct AccountLedger { pub account_number: String, diff --git a/wadm.yaml b/wadm.yaml index 0c19113..f07920c 100644 --- a/wadm.yaml +++ b/wadm.yaml @@ -3,14 +3,14 @@ kind: Application metadata: name: bank-account annotations: - version: v0.1.0 + version: v0.2.0 description: "The concordance bank account example" spec: components: - name: catalog type: actor properties: - image: ghcr.io/cosmonic/cosmonic-gitops/bankaccount_catalog:0.1.0 + image: ghcr.io/cosmonic/cosmonic-gitops/bankaccount_catalog:0.2.0 traits: - type: daemonscaler properties: @@ -21,7 +21,7 @@ spec: - name: projector type: actor properties: - image: ghcr.io/cosmonic/cosmonic-gitops/bankaccount_projector:0.1.0 + image: ghcr.io/cosmonic/cosmonic-gitops/bankaccount_projector:0.2.0 traits: - type: daemonscaler properties: @@ -32,7 +32,7 @@ spec: values: NAME: bankaccount_projector ROLE: projector - INTEREST: account_created + INTEREST: account_created,funds_deposited,funds_withdrawn - type: linkdef properties: target: keyvalue @@ -40,7 +40,7 @@ spec: - name: aggregate type: actor properties: - image: ghcr.io/cosmonic/cosmonic-gitops/bankaccount_aggregate:0.1.0 + image: ghcr.io/cosmonic/cosmonic-gitops/bankaccount_aggregate:0.2.0 traits: - type: daemonscaler properties: