diff --git a/app.go b/app.go index 2475dd9..0642d71 100644 --- a/app.go +++ b/app.go @@ -5,6 +5,7 @@ import ( "github.com/dogmatiq/dogma" "github.com/dogmatiq/example/domain" + "github.com/dogmatiq/example/integrations" "github.com/dogmatiq/example/projections" "github.com/dogmatiq/projectionkit/sqlprojection" ) @@ -24,6 +25,8 @@ type App struct { TransferProcess domain.TransferProcessHandler WithdrawalProcess domain.WithdrawalProcessHandler + ThirdPartyBank integrations.ThirdPartyBankIntegrationHandler + ReadDB *sql.DB AccountProjection projections.AccountProjectionHandler CustomerProjection projections.CustomerProjectionHandler @@ -44,6 +47,8 @@ func (a *App) Configure(c dogma.ApplicationConfigurer) { dogma.ViaProcess(a.TransferProcess), dogma.ViaProcess(a.WithdrawalProcess), + dogma.ViaIntegration(a.ThirdPartyBank), + dogma.ViaProjection(sqlprojection.New(a.ReadDB, sqlprojection.SQLiteDriver, &a.AccountProjection)), dogma.ViaProjection(sqlprojection.New(a.ReadDB, sqlprojection.SQLiteDriver, &a.CustomerProjection)), ) diff --git a/domain/dailydebitlimit.go b/domain/dailydebitlimit.go index ce711b5..08c9ae9 100644 --- a/domain/dailydebitlimit.go +++ b/domain/dailydebitlimit.go @@ -51,7 +51,7 @@ func (d *dailyDebitLimit) wouldExceedLimit(amount int64) bool { func (d *dailyDebitLimit) ApplyEvent(m dogma.Event) { switch x := m.(type) { case *events.DailyDebitLimitConsumed: - d.TotalDebitsForDay = x.Amount + d.TotalDebitsForDay = x.TotalDebitsForDay } } diff --git a/domain/deposit_test.go b/domain/deposit_test.go index 3d874d8..9b63c45 100644 --- a/domain/deposit_test.go +++ b/domain/deposit_test.go @@ -43,7 +43,7 @@ func Test_Deposit(t *testing.T) { }, ), ). - // verify that funds are availalbe + // verify that funds are available Expect( ExecuteCommand( &commands.Withdraw{ diff --git a/domain/transaction.go b/domain/transaction.go index 8b6c9fd..e71a9f9 100644 --- a/domain/transaction.go +++ b/domain/transaction.go @@ -78,11 +78,12 @@ func (t *transaction) StartTransfer(s dogma.AggregateCommandScope, m *commands.T } s.RecordEvent(&events.TransferStarted{ - TransactionID: m.TransactionID, - FromAccountID: m.FromAccountID, - ToAccountID: m.ToAccountID, - Amount: m.Amount, - ScheduledTime: m.ScheduledTime, + TransactionID: m.TransactionID, + FromAccountID: m.FromAccountID, + ToAccountID: m.ToAccountID, + ToThirdPartyBank: m.ToThirdPartyBank, + Amount: m.Amount, + ScheduledTime: m.ScheduledTime, }) } @@ -105,6 +106,15 @@ func (t *transaction) DeclineTransfer(s dogma.AggregateCommandScope, m *commands }) } +func (t *transaction) MarkTransferAsFailed(s dogma.AggregateCommandScope, m *commands.MarkTransferAsFailed) { + s.RecordEvent(&events.TransferFailed{ + TransactionID: m.TransactionID, + FromAccountID: m.FromAccountID, + ToAccountID: m.ToAccountID, + Amount: m.Amount, + }) +} + func (t *transaction) ApplyEvent(m dogma.Event) { switch m.(type) { case *events.DepositStarted: @@ -141,6 +151,7 @@ func (TransactionHandler) Configure(c dogma.AggregateConfigurer) { dogma.HandlesCommand[*commands.Transfer](), dogma.HandlesCommand[*commands.ApproveTransfer](), dogma.HandlesCommand[*commands.DeclineTransfer](), + dogma.HandlesCommand[*commands.MarkTransferAsFailed](), dogma.RecordsEvent[*events.DepositStarted](), dogma.RecordsEvent[*events.DepositApproved](), dogma.RecordsEvent[*events.WithdrawalStarted](), @@ -149,6 +160,7 @@ func (TransactionHandler) Configure(c dogma.AggregateConfigurer) { dogma.RecordsEvent[*events.TransferStarted](), dogma.RecordsEvent[*events.TransferApproved](), dogma.RecordsEvent[*events.TransferDeclined](), + dogma.RecordsEvent[*events.TransferFailed](), ) } @@ -172,6 +184,8 @@ func (TransactionHandler) RouteCommandToInstance(m dogma.Command) string { return x.TransactionID case *commands.DeclineTransfer: return x.TransactionID + case *commands.MarkTransferAsFailed: + return x.TransactionID default: panic(dogma.UnexpectedMessage) } @@ -203,6 +217,8 @@ func (TransactionHandler) HandleCommand( t.ApproveTransfer(s, x) case *commands.DeclineTransfer: t.DeclineTransfer(s, x) + case *commands.MarkTransferAsFailed: + t.MarkTransferAsFailed(s, x) default: panic(dogma.UnexpectedMessage) } diff --git a/domain/transfer.go b/domain/transfer.go index 5cc407a..aa73bdb 100644 --- a/domain/transfer.go +++ b/domain/transfer.go @@ -18,10 +18,11 @@ func init() { // transfer is the process root for a funds transfer. type transferProcess struct { - FromAccountID string - ToAccountID string - Amount int64 - DeclineReason messages.DebitFailureReason + FromAccountID string + ToAccountID string + ToThirdPartyBank bool + Amount int64 + DeclineReason messages.DebitFailureReason } // MarshalBinary returns the transferProcess encoded as binary data. @@ -54,13 +55,18 @@ func (TransferProcessHandler) Configure(c dogma.ProcessConfigurer) { dogma.HandlesEvent[*events.DailyDebitLimitConsumed](), dogma.HandlesEvent[*events.DailyDebitLimitExceeded](), dogma.HandlesEvent[*events.AccountCredited](), + dogma.HandlesEvent[*events.ThirdPartyAccountCredited](), + dogma.HandlesEvent[*events.ThirdPartyAccountCreditFailed](), dogma.HandlesEvent[*events.TransferApproved](), dogma.HandlesEvent[*events.TransferDeclined](), + dogma.HandlesEvent[*events.TransferFailed](), dogma.ExecutesCommand[*commands.DebitAccount](), dogma.ExecutesCommand[*commands.ConsumeDailyDebitLimit](), dogma.ExecutesCommand[*commands.CreditAccount](), + dogma.ExecutesCommand[*commands.CreditThirdPartyAccount](), dogma.ExecutesCommand[*commands.ApproveTransfer](), dogma.ExecutesCommand[*commands.DeclineTransfer](), + dogma.ExecutesCommand[*commands.MarkTransferAsFailed](), dogma.SchedulesTimeout[*TransferReadyToProceed](), ) } @@ -84,10 +90,16 @@ func (TransferProcessHandler) RouteEventToInstance( return x.TransactionID, x.DebitType == messages.Transfer, nil case *events.AccountCredited: return x.TransactionID, x.TransactionType == messages.Transfer, nil + case *events.ThirdPartyAccountCredited: + return x.TransactionID, true, nil + case *events.ThirdPartyAccountCreditFailed: + return x.TransactionID, true, nil case *events.TransferApproved: return x.TransactionID, true, nil case *events.TransferDeclined: return x.TransactionID, true, nil + case *events.TransferFailed: + return x.TransactionID, true, nil default: panic(dogma.UnexpectedMessage) } @@ -106,6 +118,7 @@ func (TransferProcessHandler) HandleEvent( case *events.TransferStarted: t.FromAccountID = x.FromAccountID t.ToAccountID = x.ToAccountID + t.ToThirdPartyBank = x.ToThirdPartyBank t.Amount = x.Amount s.ScheduleTimeout( @@ -134,13 +147,20 @@ func (TransferProcessHandler) HandleEvent( }) case *events.DailyDebitLimitConsumed: - // continue transfer - s.ExecuteCommand(&commands.CreditAccount{ - TransactionID: x.TransactionID, - AccountID: t.ToAccountID, - TransactionType: messages.Transfer, - Amount: x.Amount, - }) + if t.ToThirdPartyBank { + s.ExecuteCommand(&commands.CreditThirdPartyAccount{ + TransactionID: x.TransactionID, + AccountID: t.ToAccountID, + Amount: x.Amount, + }) + } else { + s.ExecuteCommand(&commands.CreditAccount{ + TransactionID: x.TransactionID, + AccountID: t.ToAccountID, + TransactionType: messages.Transfer, + Amount: x.Amount, + }) + } case *events.DailyDebitLimitExceeded: t.DeclineReason = messages.DailyDebitLimitExceeded @@ -163,7 +183,7 @@ func (TransferProcessHandler) HandleEvent( Amount: x.Amount, }) } else { - // it was a compensating credit to undo the transfer (failure) + // it was a compensating credit to undo the transfer (business rejection) s.ExecuteCommand(&commands.DeclineTransfer{ TransactionID: x.TransactionID, FromAccountID: t.FromAccountID, @@ -173,7 +193,30 @@ func (TransferProcessHandler) HandleEvent( }) } - case *events.TransferApproved, *events.TransferDeclined: + case *events.ThirdPartyAccountCredited: + s.ExecuteCommand(&commands.ApproveTransfer{ + TransactionID: x.TransactionID, + FromAccountID: t.FromAccountID, + ToAccountID: t.ToAccountID, + Amount: t.Amount, + }) + + case *events.ThirdPartyAccountCreditFailed: + s.ExecuteCommand(&commands.MarkTransferAsFailed{ + TransactionID: x.TransactionID, + FromAccountID: t.FromAccountID, + ToAccountID: t.ToAccountID, + Amount: t.Amount, + }) + + s.ExecuteCommand(&commands.CreditAccount{ + TransactionID: x.TransactionID, + AccountID: t.FromAccountID, + TransactionType: messages.Transfer, + Amount: t.Amount, + }) + + case *events.TransferApproved, *events.TransferDeclined, *events.TransferFailed: s.End() default: diff --git a/domain/transfer_test.go b/domain/transfer_test.go index 75aeae6..182e6f8 100644 --- a/domain/transfer_test.go +++ b/domain/transfer_test.go @@ -12,77 +12,186 @@ import ( ) func Test_Transfer(t *testing.T) { - t.Run( - "when there are sufficient funds", - func(t *testing.T) { - t.Run( - "it transfers the funds from one account to another", - func(t *testing.T) { - Begin(t, &example.App{}). - Prepare( - ExecuteCommand( - &commands.OpenAccount{ - CustomerID: "C001", - AccountID: "A001", - AccountName: "Anna Smith", - }, - ), - ExecuteCommand( - &commands.OpenAccount{ - CustomerID: "C002", - AccountID: "A002", - AccountName: "Bob Jones", - }, - ), - ExecuteCommand( - &commands.Deposit{ - TransactionID: "D001", - AccountID: "A001", - Amount: 500, - }, - ), - ). - Expect( - ExecuteCommand( - &commands.Transfer{ - TransactionID: "T001", - FromAccountID: "A001", - ToAccountID: "A002", - Amount: 100, - ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), - }, - ), - ToRecordEvent( - &events.TransferApproved{ - TransactionID: "T001", - FromAccountID: "A001", - ToAccountID: "A002", - Amount: 100, - }, + cases := []struct { + Name string + Transfer *commands.Transfer + }{ + {"it transfers to an in-house account", &commands.Transfer{ + ToAccountID: "A002", + }}, + {"it transfers to a third-party account", &commands.Transfer{ + ToAccountID: "100001", + ToThirdPartyBank: true, + }}, + } + + for _, c := range cases { + t.Run( + c.Name, + func(t *testing.T) { + app := func(opts ...TestOption) *Test { + a := Begin(t, &example.App{}, opts...) + if c.Transfer.ToThirdPartyBank { + a = a.EnableHandlers("third-party-bank") + } + return a + } + + t.Run( + "when there are sufficient funds", + func(t *testing.T) { + transfer := *c.Transfer + transfer.TransactionID = "T001" + transfer.FromAccountID = "A001" + transfer.Amount = 100 + transfer.ScheduledTime = time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC) + + app(). + Prepare( + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C001", + AccountID: "A001", + AccountName: "Anna Smith", + }, + ), + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C002", + AccountID: "A002", + AccountName: "Bob Jones", + }, + ), + ExecuteCommand( + &commands.Deposit{ + TransactionID: "D001", + AccountID: "A001", + Amount: 500, + }, + ), + ). + Expect( + ExecuteCommand(&transfer), + ToRecordEvent( + &events.TransferApproved{ + TransactionID: "T001", + FromAccountID: "A001", + ToAccountID: c.Transfer.ToAccountID, + Amount: 100, + }, + ), + ) + }, + ) + + t.Run( + "when the transfer does not exceed the daily debit limit", + func(t *testing.T) { + transfer := *c.Transfer + transfer.TransactionID = "T002" + transfer.FromAccountID = "A001" + transfer.Amount = 500 + transfer.ScheduledTime = time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC) + + app(). + Prepare( + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C001", + AccountID: "A001", + AccountName: "Anna Smith", + }, + ), + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C002", + AccountID: "A002", + AccountName: "Bob Jones", + }, + ), + ExecuteCommand( + &commands.Deposit{ + TransactionID: "D001", + AccountID: "A001", + Amount: expectedDailyDebitLimit + 10000, + }, + ), + ). + Expect( + ExecuteCommand(&transfer), + ToRecordEvent( + &events.TransferApproved{ + TransactionID: "T002", + FromAccountID: "A001", + ToAccountID: c.Transfer.ToAccountID, + Amount: 500, + }, + ), + ) + }, + ) + + t.Run( + "when the transfer is scheduled for a future date", + func(t *testing.T) { + transfer := *c.Transfer + transfer.TransactionID = "T001" + transfer.FromAccountID = "A001" + transfer.Amount = 100 + transfer.ScheduledTime = time.Date(2001, time.February, 4, 0, 0, 0, 0, time.UTC) + + app( + StartTimeAt( + time.Date(2001, time.February, 3, 11, 22, 33, 0, time.UTC), ), ). - // verify that funds are availalbe - Expect( - ExecuteCommand( - &commands.Withdraw{ - TransactionID: "W001", - AccountID: "A002", - Amount: 100, - ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), - }, - ), - ToRecordEvent( - &events.WithdrawalApproved{ - TransactionID: "W001", - AccountID: "A002", - Amount: 100, - }, - ), - ) - }, - ) - }, - ) + Prepare( + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C001", + AccountID: "A001", + AccountName: "Anna Smith", + }, + ), + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C002", + AccountID: "A002", + AccountName: "Bob Jones", + }, + ), + ExecuteCommand( + &commands.Deposit{ + TransactionID: "D001", + AccountID: "A001", + Amount: 500, + }, + ), + ). + Expect( + ExecuteCommand(&transfer), + NoneOf( + ToRecordEventOfType(&events.TransferApproved{}), + ), + ). + Expect( + AdvanceTime( + ToTime(time.Date(2001, time.February, 4, 0, 0, 0, 0, time.UTC)), + ), + ToRecordEvent( + &events.TransferApproved{ + TransactionID: "T001", + FromAccountID: "A001", + ToAccountID: c.Transfer.ToAccountID, + Amount: 100, + }, + ), + ) + }, + ) + }, + ) + } t.Run( "when there are insufficient funds", @@ -134,7 +243,7 @@ func Test_Transfer(t *testing.T) { }, ), ). - // verify that funds are not availalbe + // verify that funds are not available Expect( ExecuteCommand( &commands.Withdraw{ @@ -158,78 +267,6 @@ func Test_Transfer(t *testing.T) { }, ) - t.Run( - "when the transfer does not exceed the daily debit limit", - func(t *testing.T) { - t.Run( - "it transfers the funds from one account to another", - func(t *testing.T) { - Begin(t, &example.App{}). - Prepare( - ExecuteCommand( - &commands.OpenAccount{ - CustomerID: "C001", - AccountID: "A001", - AccountName: "Anna Smith", - }, - ), - ExecuteCommand( - &commands.OpenAccount{ - CustomerID: "C002", - AccountID: "A002", - AccountName: "Bob Jones", - }, - ), - ExecuteCommand( - &commands.Deposit{ - TransactionID: "T001", - AccountID: "A001", - Amount: expectedDailyDebitLimit + 10000, - }, - ), - ). - Expect( - ExecuteCommand( - &commands.Transfer{ - TransactionID: "T002", - FromAccountID: "A001", - ToAccountID: "A002", - Amount: 500, - ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), - }, - ), - ToRecordEvent( - &events.TransferApproved{ - TransactionID: "T002", - FromAccountID: "A001", - ToAccountID: "A002", - Amount: 500, - }, - ), - ). - // verify that funds are availalbe - Expect( - ExecuteCommand( - &commands.Withdraw{ - TransactionID: "W001", - AccountID: "A002", - Amount: 100, - ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), - }, - ), - ToRecordEvent( - &events.WithdrawalApproved{ - TransactionID: "W001", - AccountID: "A002", - Amount: 100, - }, - ), - ) - }, - ) - }, - ) - t.Run( "when the transfer exceeds the daily debit limit", func(t *testing.T) { @@ -280,7 +317,7 @@ func Test_Transfer(t *testing.T) { }, ), ). - // verify that funds are not availalbe + // verify that funds are not available Expect( ExecuteCommand( &commands.Withdraw{ @@ -305,18 +342,15 @@ func Test_Transfer(t *testing.T) { ) t.Run( - "when the transfer is scheduled for a future date", + "when the third-party credit fails", func(t *testing.T) { t.Run( - "it transfers the funds after the scheduled time", + "it refunds the source account", func(t *testing.T) { - Begin( - t, - &example.App{}, - StartTimeAt( - time.Date(2001, time.February, 3, 11, 22, 33, 0, time.UTC), - ), - ). + // Integration handler is intentionally not enabled so the + // process stalls after issuing CreditThirdPartyAccount, allowing + // us to inject ThirdPartyAccountCreditFailed directly. + Begin(t, &example.App{}). Prepare( ExecuteCommand( &commands.OpenAccount{ @@ -325,13 +359,6 @@ func Test_Transfer(t *testing.T) { AccountName: "Anna Smith", }, ), - ExecuteCommand( - &commands.OpenAccount{ - CustomerID: "C002", - AccountID: "A002", - AccountName: "Bob Jones", - }, - ), ExecuteCommand( &commands.Deposit{ TransactionID: "D001", @@ -339,49 +366,49 @@ func Test_Transfer(t *testing.T) { Amount: 500, }, ), - ). - Expect( ExecuteCommand( &commands.Transfer{ - TransactionID: "T001", - FromAccountID: "A001", - ToAccountID: "A002", - Amount: 100, - ScheduledTime: time.Date(2001, time.February, 4, 0, 0, 0, 0, time.UTC), + TransactionID: "T001", + FromAccountID: "A001", + ToAccountID: "100001", + Amount: 100, + ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), + ToThirdPartyBank: true, }, ), - NoneOf( - ToRecordEventOfType(&events.TransferApproved{}), - ), ). Expect( - AdvanceTime( - ToTime(time.Date(2001, time.February, 4, 0, 0, 0, 0, time.UTC)), + RecordEvent( + &events.ThirdPartyAccountCreditFailed{ + TransactionID: "T001", + AccountID: "100001", + Amount: 100, + }, ), ToRecordEvent( - &events.TransferApproved{ + &events.TransferFailed{ TransactionID: "T001", FromAccountID: "A001", - ToAccountID: "A002", + ToAccountID: "100001", Amount: 100, }, ), ). - // verify that funds are availalbe + // verify that the funds were returned Expect( ExecuteCommand( &commands.Withdraw{ TransactionID: "W001", - AccountID: "A002", - Amount: 100, - ScheduledTime: time.Date(2001, time.February, 4, 0, 0, 0, 0, time.UTC), + AccountID: "A001", + Amount: 500, + ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), }, ), ToRecordEvent( &events.WithdrawalApproved{ TransactionID: "W001", - AccountID: "A002", - Amount: 100, + AccountID: "A001", + Amount: 500, }, ), ) diff --git a/domain/withdrawal_test.go b/domain/withdrawal_test.go index feca7fa..c288a12 100644 --- a/domain/withdrawal_test.go +++ b/domain/withdrawal_test.go @@ -257,6 +257,63 @@ func Test_Withdraw(t *testing.T) { ) }, ) + + t.Run( + "it declines a second withdrawal that cumulatively exceeds the daily limit", + func(t *testing.T) { + Begin(t, &example.App{}). + Prepare( + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C001", + AccountID: "A001", + AccountName: "Anna Smith", + }, + ), + ExecuteCommand( + &commands.Deposit{ + TransactionID: "D001", + AccountID: "A001", + Amount: expectedDailyDebitLimit + 1, + }, + ), + ExecuteCommand( + &commands.Withdraw{ + TransactionID: "T001", + AccountID: "A001", + Amount: expectedDailyDebitLimit / 2, + ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), + }, + ), + ExecuteCommand( + &commands.Withdraw{ + TransactionID: "T002", + AccountID: "A001", + Amount: expectedDailyDebitLimit / 2, + ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), + }, + ), + ). + Expect( + ExecuteCommand( + &commands.Withdraw{ + TransactionID: "T003", + AccountID: "A001", + Amount: 1, + ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), + }, + ), + ToRecordEvent( + &events.WithdrawalDeclined{ + TransactionID: "T003", + AccountID: "A001", + Amount: 1, + Reason: messages.DailyDebitLimitExceeded, + }, + ), + ) + }, + ) }, ) diff --git a/integrations/doc.go b/integrations/doc.go new file mode 100644 index 0000000..6e29b6b --- /dev/null +++ b/integrations/doc.go @@ -0,0 +1,3 @@ +// Package integrations implements Dogma integration handlers that communicate +// with external systems on behalf of the application. +package integrations diff --git a/integrations/thirdpartybank.go b/integrations/thirdpartybank.go new file mode 100644 index 0000000..1e0449c --- /dev/null +++ b/integrations/thirdpartybank.go @@ -0,0 +1,63 @@ +package integrations + +import ( + "context" + "strconv" + + "github.com/dogmatiq/dogma" + "github.com/dogmatiq/example/messages" + "github.com/dogmatiq/example/messages/commands" + "github.com/dogmatiq/example/messages/events" +) + +// ThirdPartyBankIntegrationHandler handles commands that interact with +// a hypothetical third-party bank's API on behalf of the application. +type ThirdPartyBankIntegrationHandler struct{} + +// Configure configures the behavior of the engine as it relates to this handler. +func (ThirdPartyBankIntegrationHandler) Configure(c dogma.IntegrationConfigurer) { + c.Identity("third-party-bank", "f2a7e4b1-9c3d-4f8a-b6e5-1d0c2a9f7e3b") + + c.Routes( + dogma.HandlesCommand[*commands.CreditThirdPartyAccount](), + dogma.RecordsEvent[*events.ThirdPartyAccountCredited](), + dogma.RecordsEvent[*events.ThirdPartyAccountCreditFailed](), + ) +} + +// HandleCommand handles a command message that has been routed to this handler. +func (ThirdPartyBankIntegrationHandler) HandleCommand( + _ context.Context, + s dogma.IntegrationCommandScope, + c dogma.Command, +) error { + switch x := c.(type) { + case *commands.CreditThirdPartyAccount: + s.Log( + "crediting third-party account %s with %s (transaction %s)", + x.AccountID, + messages.FormatAmount(x.Amount), + x.TransactionID, + ) + + if _, err := strconv.ParseUint(x.AccountID, 10, 64); err != nil { + s.Log("third-party bank rejected the credit: account %s not found", x.AccountID) + s.RecordEvent(&events.ThirdPartyAccountCreditFailed{ + TransactionID: x.TransactionID, + AccountID: x.AccountID, + Amount: x.Amount, + }) + } else { + s.RecordEvent(&events.ThirdPartyAccountCredited{ + TransactionID: x.TransactionID, + AccountID: x.AccountID, + Amount: x.Amount, + }) + } + + default: + panic(dogma.UnexpectedMessage) + } + + return nil +} diff --git a/integrations/thirdpartybank_test.go b/integrations/thirdpartybank_test.go new file mode 100644 index 0000000..81b7548 --- /dev/null +++ b/integrations/thirdpartybank_test.go @@ -0,0 +1,104 @@ +package integrations_test + +import ( + "testing" + "time" + + "github.com/dogmatiq/example" + "github.com/dogmatiq/example/messages/commands" + "github.com/dogmatiq/example/messages/events" + . "github.com/dogmatiq/testkit" +) + +func Test_ThirdPartyBankIntegrationHandler(t *testing.T) { + t.Run( + "when a credit is requested", + func(t *testing.T) { + t.Run( + "it credits the account if the account ID is numeric", + func(t *testing.T) { + Begin(t, &example.App{}). + EnableHandlers("third-party-bank"). + Prepare( + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C001", + AccountID: "A001", + AccountName: "Anna Smith", + }, + ), + ExecuteCommand( + &commands.Deposit{ + TransactionID: "D001", + AccountID: "A001", + Amount: 500, + }, + ), + ). + Expect( + ExecuteCommand( + &commands.Transfer{ + TransactionID: "T001", + FromAccountID: "A001", + ToAccountID: "100001", + Amount: 100, + ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), + ToThirdPartyBank: true, + }, + ), + ToRecordEvent( + &events.ThirdPartyAccountCredited{ + TransactionID: "T001", + AccountID: "100001", + Amount: 100, + }, + ), + ) + }, + ) + + t.Run( + "it fails the credit if the account ID is not numeric", + func(t *testing.T) { + Begin(t, &example.App{}). + EnableHandlers("third-party-bank"). + Prepare( + ExecuteCommand( + &commands.OpenAccount{ + CustomerID: "C001", + AccountID: "A001", + AccountName: "Anna Smith", + }, + ), + ExecuteCommand( + &commands.Deposit{ + TransactionID: "D001", + AccountID: "A001", + Amount: 500, + }, + ), + ). + Expect( + ExecuteCommand( + &commands.Transfer{ + TransactionID: "T001", + FromAccountID: "A001", + ToAccountID: "EXT001", + Amount: 100, + ScheduledTime: time.Date(2001, time.February, 3, 0, 0, 0, 0, time.UTC), + ToThirdPartyBank: true, + }, + ), + ToRecordEvent( + &events.ThirdPartyAccountCreditFailed{ + TransactionID: "T001", + AccountID: "EXT001", + Amount: 100, + }, + ), + ) + }, + ) + }, + ) +} diff --git a/messages/commands/thirdpartycredit.go b/messages/commands/thirdpartycredit.go new file mode 100644 index 0000000..a21907e --- /dev/null +++ b/messages/commands/thirdpartycredit.go @@ -0,0 +1,59 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/dogmatiq/dogma" + "github.com/dogmatiq/example/messages" +) + +func init() { + dogma.RegisterCommand[*CreditThirdPartyAccount]("c4a2e6f1-8b3d-4e7a-9f5c-2d1b0e8a3c6f") +} + +// CreditThirdPartyAccount is a command to credit an account held at a +// third-party bank. +type CreditThirdPartyAccount struct { + TransactionID string + AccountID string + Amount int64 +} + +// MessageDescription returns a human-readable description of the message. +func (m *CreditThirdPartyAccount) MessageDescription() string { + return fmt.Sprintf( + "transfer %s: crediting %s to third-party account %s", + m.TransactionID, + messages.FormatAmount(m.Amount), + m.AccountID, + ) +} + +// MarshalBinary returns a binary representation of the message. +// For simplicity in this example we use JSON. +func (m *CreditThirdPartyAccount) MarshalBinary() ([]byte, error) { + return json.Marshal(m) +} + +// UnmarshalBinary populates the message from its binary representation. +// For simplicity in this example we use JSON. +func (m *CreditThirdPartyAccount) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, m) +} + +// Validate returns a non-nil error if the message is invalid. +func (m *CreditThirdPartyAccount) Validate(dogma.CommandValidationScope) error { + if m.TransactionID == "" { + return errors.New("CreditThirdPartyAccount must not have an empty transaction ID") + } + if m.AccountID == "" { + return errors.New("CreditThirdPartyAccount must not have an empty account ID") + } + if m.Amount < 1 { + return errors.New("CreditThirdPartyAccount must have a positive amount") + } + + return nil +} diff --git a/messages/commands/transfer.go b/messages/commands/transfer.go index 37b4bf9..29c371b 100644 --- a/messages/commands/transfer.go +++ b/messages/commands/transfer.go @@ -14,16 +14,18 @@ func init() { dogma.RegisterCommand[*Transfer]("5ee87c7b-bde3-4b39-9f12-44968cdb9889") dogma.RegisterCommand[*ApproveTransfer]("0d22aaa5-4449-459a-b9b1-c5fb0ce4a990") dogma.RegisterCommand[*DeclineTransfer]("d7d069a2-41fc-415e-91dd-7db3affa9f6d") + dogma.RegisterCommand[*MarkTransferAsFailed]("b3e5f6a2-7c1d-4e8b-a9f0-3d2c1e4b5f6a") } // Transfer is a command requesting that funds be transferred from one bank // account to another. type Transfer struct { - TransactionID string - FromAccountID string - ToAccountID string - Amount int64 - ScheduledTime time.Time + TransactionID string + FromAccountID string + ToAccountID string + ToThirdPartyBank bool + Amount int64 + ScheduledTime time.Time } // ApproveTransfer is a command that approves an account transfer. @@ -43,10 +45,19 @@ type DeclineTransfer struct { Reason messages.DebitFailureReason } +// MarkTransferAsFailed is a command that marks an account transfer as failed +// due to an operational error that occurred after the transfer was initiated. +type MarkTransferAsFailed struct { + TransactionID string + FromAccountID string + ToAccountID string + Amount int64 +} + // MessageDescription returns a human-readable description of the message. func (m *Transfer) MessageDescription() string { return fmt.Sprintf( - "transfer %s: transfering %s from account %s to account %s", + "transfer %s: transferring %s from account %s to account %s", m.TransactionID, messages.FormatAmount(m.Amount), m.FromAccountID, @@ -172,3 +183,44 @@ func (m *DeclineTransfer) MarshalBinary() ([]byte, error) { func (m *DeclineTransfer) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, m) } + +// MessageDescription returns a human-readable description of the message. +func (m *MarkTransferAsFailed) MessageDescription() string { + return fmt.Sprintf( + "transfer %s: failing transfer of %s from account %s to account %s", + m.TransactionID, + messages.FormatAmount(m.Amount), + m.FromAccountID, + m.ToAccountID, + ) +} + +// Validate returns a non-nil error if the message is invalid. +func (m *MarkTransferAsFailed) Validate(dogma.CommandValidationScope) error { + if m.TransactionID == "" { + return errors.New("MarkTransferAsFailed must not have an empty transaction ID") + } + if m.FromAccountID == "" { + return errors.New("MarkTransferAsFailed must not have an empty 'from' account ID") + } + if m.ToAccountID == "" { + return errors.New("MarkTransferAsFailed must not have an empty 'to' account ID") + } + if m.Amount < 1 { + return errors.New("MarkTransferAsFailed must have a positive amount") + } + + return nil +} + +// MarshalBinary returns a binary representation of the message. +// For simplicity in this example we use JSON. +func (m *MarkTransferAsFailed) MarshalBinary() ([]byte, error) { + return json.Marshal(m) +} + +// UnmarshalBinary populates the message from its binary representation. +// For simplicity in this example we use JSON. +func (m *MarkTransferAsFailed) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, m) +} diff --git a/messages/events/dailydebitlimit.go b/messages/events/dailydebitlimit.go index ced4146..708fbe8 100644 --- a/messages/events/dailydebitlimit.go +++ b/messages/events/dailydebitlimit.go @@ -45,8 +45,8 @@ func (m *DailyDebitLimitConsumed) MessageDescription() string { "%s %s: consumed %s from %s daily debit limit of account %s", m.DebitType, m.TransactionID, - m.Date, messages.FormatAmount(m.Amount), + m.Date, m.AccountID, ) } diff --git a/messages/events/thirdpartycredit.go b/messages/events/thirdpartycredit.go new file mode 100644 index 0000000..e13e096 --- /dev/null +++ b/messages/events/thirdpartycredit.go @@ -0,0 +1,105 @@ +package events + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/dogmatiq/dogma" + "github.com/dogmatiq/example/messages" +) + +func init() { + dogma.RegisterEvent[*ThirdPartyAccountCredited]("a7f3c8e2-5d1b-4a9e-8c6f-3b2d0e7a1c5d") + dogma.RegisterEvent[*ThirdPartyAccountCreditFailed]("e9b4d7f0-2c6a-4e8b-9d3f-1a5c0b8e4d2a") +} + +// ThirdPartyAccountCredited is an event indicating that the credit to a +// third-party bank account was completed successfully. +type ThirdPartyAccountCredited struct { + TransactionID string + AccountID string + Amount int64 +} + +// ThirdPartyAccountCreditFailed is an event indicating that the credit to a +// third-party bank account could not be completed. +type ThirdPartyAccountCreditFailed struct { + TransactionID string + AccountID string + Amount int64 +} + +// MessageDescription returns a human-readable description of the message. +func (m *ThirdPartyAccountCredited) MessageDescription() string { + return fmt.Sprintf( + "transfer %s: credited %s to third-party account %s", + m.TransactionID, + messages.FormatAmount(m.Amount), + m.AccountID, + ) +} + +// MessageDescription returns a human-readable description of the message. +func (m *ThirdPartyAccountCreditFailed) MessageDescription() string { + return fmt.Sprintf( + "transfer %s: failed to credit %s to third-party account %s", + m.TransactionID, + messages.FormatAmount(m.Amount), + m.AccountID, + ) +} + +// MarshalBinary returns a binary representation of the message. +// For simplicity in this example we use JSON. +func (m *ThirdPartyAccountCredited) MarshalBinary() ([]byte, error) { + return json.Marshal(m) +} + +// UnmarshalBinary populates the message from its binary representation. +// For simplicity in this example we use JSON. +func (m *ThirdPartyAccountCredited) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, m) +} + +// MarshalBinary returns a binary representation of the message. +// For simplicity in this example we use JSON. +func (m *ThirdPartyAccountCreditFailed) MarshalBinary() ([]byte, error) { + return json.Marshal(m) +} + +// UnmarshalBinary populates the message from its binary representation. +// For simplicity in this example we use JSON. +func (m *ThirdPartyAccountCreditFailed) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, m) +} + +// Validate returns a non-nil error if the message is invalid. +func (m *ThirdPartyAccountCredited) Validate(dogma.EventValidationScope) error { + if m.TransactionID == "" { + return errors.New("ThirdPartyAccountCredited must not have an empty transaction ID") + } + if m.AccountID == "" { + return errors.New("ThirdPartyAccountCredited must not have an empty account ID") + } + if m.Amount < 1 { + return errors.New("ThirdPartyAccountCredited must have a positive amount") + } + + return nil +} + +// Validate returns a non-nil error if the message is invalid. +func (m *ThirdPartyAccountCreditFailed) Validate(dogma.EventValidationScope) error { + if m.TransactionID == "" { + return errors.New("ThirdPartyAccountCreditFailed must not have an empty transaction ID") + } + if m.AccountID == "" { + return errors.New("ThirdPartyAccountCreditFailed must not have an empty account ID") + } + if m.Amount < 1 { + return errors.New("ThirdPartyAccountCreditFailed must have a positive amount") + } + + return nil +} diff --git a/messages/events/transfer.go b/messages/events/transfer.go index 2c0b004..bac09ea 100644 --- a/messages/events/transfer.go +++ b/messages/events/transfer.go @@ -14,16 +14,18 @@ func init() { dogma.RegisterEvent[*TransferStarted]("e5a7db39-861a-4a98-b109-a6f4187ac407") dogma.RegisterEvent[*TransferApproved]("bcc989cc-4ec7-4175-84dc-24908ac82676") dogma.RegisterEvent[*TransferDeclined]("0e43679a-bf5b-4730-a4a0-543e17a67479") + dogma.RegisterEvent[*TransferFailed]("c6d8e9a1-2b4f-5e7c-8d0a-1f3e5c7b9d2e") } // TransferStarted is an event indicating that the process of transferring funds // from one account to another has begun. type TransferStarted struct { - TransactionID string - FromAccountID string - ToAccountID string - Amount int64 - ScheduledTime time.Time + TransactionID string + FromAccountID string + ToAccountID string + ToThirdPartyBank bool + Amount int64 + ScheduledTime time.Time } // TransferApproved is an event that indicates a requested transfer has been @@ -45,6 +47,15 @@ type TransferDeclined struct { Reason messages.DebitFailureReason } +// TransferFailed is an event that indicates a transfer failed due to an +// operational error after the transfer was initiated. +type TransferFailed struct { + TransactionID string + FromAccountID string + ToAccountID string + Amount int64 +} + // MessageDescription returns a human-readable description of the message. func (m *TransferStarted) MessageDescription() string { return fmt.Sprintf( @@ -180,3 +191,47 @@ func (m *TransferDeclined) MarshalBinary() ([]byte, error) { func (m *TransferDeclined) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, m) } + +// MessageDescription returns a human-readable description of the message. +func (m *TransferFailed) MessageDescription() string { + return fmt.Sprintf( + "transfer %s: failed transfer of %s from account %s to account %s", + m.TransactionID, + messages.FormatAmount(m.Amount), + m.FromAccountID, + m.ToAccountID, + ) +} + +// Validate returns a non-nil error if the message is invalid. +func (m *TransferFailed) Validate(dogma.EventValidationScope) error { + if m.TransactionID == "" { + return errors.New("TransferFailed must not have an empty transaction ID") + } + if m.FromAccountID == "" { + return errors.New("TransferFailed must not have an empty 'from' account ID") + } + if m.ToAccountID == "" { + return errors.New("TransferFailed must not have an empty 'to' account ID") + } + if m.FromAccountID == m.ToAccountID { + return errors.New("TransferFailed from account ID and to account ID must be different") + } + if m.Amount < 1 { + return errors.New("TransferFailed must have a positive amount") + } + + return nil +} + +// MarshalBinary returns a binary representation of the message. +// For simplicity in this example we use JSON. +func (m *TransferFailed) MarshalBinary() ([]byte, error) { + return json.Marshal(m) +} + +// UnmarshalBinary populates the message from its binary representation. +// For simplicity in this example we use JSON. +func (m *TransferFailed) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, m) +} diff --git a/messages/transaction.go b/messages/transaction.go index e8e03b4..a15414f 100644 --- a/messages/transaction.go +++ b/messages/transaction.go @@ -49,7 +49,7 @@ type DebitFailureReason string const ( // InsufficientFunds means there was not enough funds available in the // account to perform the debit. - InsufficientFunds DebitFailureReason = "insufficent funds" + InsufficientFunds DebitFailureReason = "insufficient funds" // DailyDebitLimitExceeded means that the debit cannot be performed // because it will exceed the account daily debit limit. @@ -63,7 +63,7 @@ func (r DebitFailureReason) Validate() error { DailyDebitLimitExceeded: return nil default: - return fmt.Errorf("invalid transaction type: %s", string(r)) + return fmt.Errorf("invalid debit failure reason: %s", string(r)) } }