From ddd616bf29831ea818588c40054b97b258470520 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 3 Sep 2024 19:55:56 +0200 Subject: [PATCH] Added stub for process managers --- .../ProcessManagers/BusinessProcessTests.cs | 244 ++++++++++++++++++ .../ProcessManagers/EntityDefinitionTests.cs | 148 +++++++++++ .../GroupCheckouts/GroupCheckout.cs | 156 +++++++++++ .../GroupCheckouts/GroupCheckoutFacade.cs | 82 ++++++ .../GroupCheckouts/GroupCheckoutSaga.cs | 60 +++++ .../GroupCheckouts/GroupCheckoutsConfig.cs | 36 +++ .../GuestStayAccounts/GroupCheckoutsConfig.cs | 18 ++ .../GuestStayAccounts/GuestStayAccount.cs | 120 +++++++++ .../GuestStayAccounts/GuestStayFacade.cs | 86 ++++++ 9 files changed, 950 insertions(+) create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/BusinessProcessTests.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/EntityDefinitionTests.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckout.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutFacade.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutSaga.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutsConfig.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GroupCheckoutsConfig.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GuestStayAccount.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GuestStayFacade.cs diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/BusinessProcessTests.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/BusinessProcessTests.cs new file mode 100644 index 00000000..2b75aaac --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/BusinessProcessTests.cs @@ -0,0 +1,244 @@ +using Bogus; +using BusinessProcesses.Core; +using BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GroupCheckouts; +using BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = BusinessProcesses.Core.Database; + +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; +using static GroupCheckoutCommand; +using static GroupCheckoutsConfig; +using static GuestStayAccountsConfig; + +public class BusinessProcessTests +{ + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithoutPaymentsAndCharges_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllStaysSettled_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[1], amounts[1], now.AddHours(-2))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithOneSettledAndRestUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🟢 settled + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[1], now), + new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[2], now), + new GuestCheckoutFailed(guestStays[2], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [guestStays[0]], + [guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🛑 charge without payment + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[0], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[2], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [], + [guestStays[0], guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly CommandBus commandBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly GroupCheckoutFacade groupCheckoutFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public BusinessProcessTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + groupCheckoutFacade = new GroupCheckoutFacade(database, eventBus); + + eventBus.Use(publishedMessages.Catch); + commandBus.Use(publishedMessages.Catch); + + ConfigureGroupCheckouts(eventBus, commandBus, groupCheckoutFacade); + ConfigureGuestStayAccounts(commandBus, guestStayFacade); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/EntityDefinitionTests.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/EntityDefinitionTests.cs new file mode 100644 index 00000000..44ce0617 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/EntityDefinitionTests.cs @@ -0,0 +1,148 @@ +using Bogus; +using BusinessProcesses.Core; +using BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = BusinessProcesses.Core.Database; + +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; + +public class EntityDefinitionTests +{ + [Fact] + public async Task CheckingInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + var command = new CheckInGuest(guestStayId, now); + publishedMessages.Reset(); + + // When + await guestStayFacade.CheckInGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); + } + + [Fact] + public async Task RecordingChargeForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordCharge(guestStayId, amount, now); + + // When + await guestStayFacade.RecordCharge(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, generate.Finance.Amount(), now.AddHours(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + await guestStayFacade.CheckOutGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFailed() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount + 10, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + try + { + await guestStayFacade.CheckOutGuest(command); + } + catch (Exception exc) + { + testOutputHelper.WriteLine(exc.Message); + } + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckoutFailed(guestStayId, GuestCheckoutFailed.FailureReason.BalanceNotSettled, now)); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public EntityDefinitionTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + eventBus.Use(publishedMessages.Catch); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckout.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckout.cs new file mode 100644 index 00000000..94738281 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckout.cs @@ -0,0 +1,156 @@ +using BusinessProcesses.Version2_ImmutableEntities.Core; + +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GroupCheckouts; + +using static GroupCheckoutEvent; + +public abstract record GroupCheckoutEvent +{ + public record GroupCheckoutInitiated( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset InitiatedAt + ): GroupCheckoutEvent; + + public record GuestCheckoutCompleted( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GuestCheckoutFailed( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutCompleted( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutFailed( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + Guid[] FailedCheckouts, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + private GroupCheckoutEvent() { } +} + +public record GroupCheckout( + Guid Id, + Dictionary GuestStayCheckouts, + CheckoutStatus Status = CheckoutStatus.Initiated +) +{ + public static GroupCheckoutInitiated Initiate(Guid groupCheckoutId, Guid clerkId, Guid[] guestStayIds, + DateTimeOffset initiatedAt) => + new(groupCheckoutId, clerkId, guestStayIds, initiatedAt); + + public GroupCheckoutEvent[] RecordGuestCheckoutCompletion( + Guid guestStayId, + DateTimeOffset now + ) + { + if (Status != CheckoutStatus.Initiated || GuestStayCheckouts[guestStayId] == CheckoutStatus.Completed) + return []; + + var guestCheckoutCompleted = new GuestCheckoutCompleted(Id, guestStayId, now); + + var guestStayCheckouts = GuestStayCheckouts.With(guestStayId, CheckoutStatus.Completed); + + return AreAnyOngoingCheckouts(guestStayCheckouts) + ? [guestCheckoutCompleted] + : [guestCheckoutCompleted, Finalize(guestStayCheckouts, now)]; + } + + public GroupCheckoutEvent[] RecordGuestCheckoutFailure( + Guid guestStayId, + DateTimeOffset now + ) + { + if (Status != CheckoutStatus.Initiated || GuestStayCheckouts[guestStayId] == CheckoutStatus.Failed) + return []; + + var guestCheckoutFailed = new GuestCheckoutFailed(Id, guestStayId, now); + + var guestStayCheckouts = GuestStayCheckouts.With(guestStayId, CheckoutStatus.Failed); + + return AreAnyOngoingCheckouts(guestStayCheckouts) + ? [guestCheckoutFailed] + : [guestCheckoutFailed, Finalize(guestStayCheckouts, now)]; + } + + private GroupCheckoutEvent Finalize( + Dictionary guestStayCheckouts, + DateTimeOffset now + ) => + !AreAnyFailedCheckouts(guestStayCheckouts) + ? new GroupCheckoutCompleted + ( + Id, + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Completed), + now + ) + : new GroupCheckoutFailed + ( + Id, + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Completed), + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Failed), + now + ); + + private static bool AreAnyOngoingCheckouts(Dictionary guestStayCheckouts) => + guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Initiated); + + private static bool AreAnyFailedCheckouts(Dictionary guestStayCheckouts) => + guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Failed); + + private static Guid[] CheckoutsWith(Dictionary guestStayCheckouts, CheckoutStatus status) => + guestStayCheckouts + .Where(pair => pair.Value == status) + .Select(pair => pair.Key) + .ToArray(); + + + public GroupCheckout Evolve(GroupCheckoutEvent @event) => + @event switch + { + GroupCheckoutInitiated initiated => this with + { + Id = initiated.GroupCheckoutId, + GuestStayCheckouts = initiated.GuestStayIds.ToDictionary(id => id, _ => CheckoutStatus.Initiated), + Status = CheckoutStatus.Initiated + }, + GuestCheckoutCompleted guestCheckedOut => this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => vs.Key == guestCheckedOut.GuestStayId ? CheckoutStatus.Completed : vs.Value + ) + }, + GuestCheckoutFailed guestCheckedOutFailed => this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => vs.Key == guestCheckedOutFailed.GuestStayId ? CheckoutStatus.Failed : vs.Value + ) + }, + GroupCheckoutCompleted => this with { Status = CheckoutStatus.Completed }, + GroupCheckoutFailed => this with { Status = CheckoutStatus.Failed }, + _ => this + }; + + public static GroupCheckout Initial = new(default, [], default); +} + +public enum CheckoutStatus +{ + Initiated, + Completed, + Failed +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutFacade.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutFacade.cs new file mode 100644 index 00000000..a485f28a --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutFacade.cs @@ -0,0 +1,82 @@ +using BusinessProcesses.Core; +using Database = BusinessProcesses.Core.Database; + +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GroupCheckouts; + +using static GroupCheckoutCommand; + +public class GroupCheckoutFacade(Database database, EventBus eventBus) +{ + public async ValueTask InitiateGroupCheckout(InitiateGroupCheckout command, CancellationToken ct = default) + { + var @event = + new GroupCheckoutEvent.GroupCheckoutInitiated(command.GroupCheckoutId, command.ClerkId, + command.GuestStayIds, command.Now); + + await database.Store(command.GroupCheckoutId, GroupCheckout.Initial.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordGuestCheckoutCompletion( + RecordGuestCheckoutCompletion command, + CancellationToken ct = default + ) + { + var groupCheckout = await database.Get(command.GroupCheckoutId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var events = groupCheckout.RecordGuestCheckoutCompletion(command.GuestStayId, command.CompletedAt); + + if (events.Length == 0) + return; + + await database.Store(command.GroupCheckoutId, + events.Aggregate(groupCheckout, (state, @event) => state.Evolve(@event)), ct); + + await eventBus.Publish(events.Cast().ToArray(), ct); + } + + public async ValueTask RecordGuestCheckoutFailure( + RecordGuestCheckoutFailure command, + CancellationToken ct = default + ) + { + var groupCheckout = await database.Get(command.GroupCheckoutId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var events = groupCheckout.RecordGuestCheckoutFailure(command.GuestStayId, command.FailedAt); + + if (events.Length == 0) + return; + + var newState = events.Aggregate(groupCheckout, (state, @event) => state.Evolve(@event)); + + await database.Store(command.GroupCheckoutId, newState, ct); + + await eventBus.Publish(events.Cast().ToArray(), ct); + } +} + +public abstract record GroupCheckoutCommand +{ + public record InitiateGroupCheckout( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset Now + ): GroupCheckoutCommand; + + public record RecordGuestCheckoutCompletion( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutCommand; + + public record RecordGuestCheckoutFailure( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutCommand; + + private GroupCheckoutCommand() { } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutSaga.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutSaga.cs new file mode 100644 index 00000000..2ca517a6 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutSaga.cs @@ -0,0 +1,60 @@ +using BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GuestStayAccounts; + +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GroupCheckouts; + +using static GroupCheckoutCommand; +using static GroupCheckoutEvent; +using static GuestStayAccountCommand; +using static GuestStayAccountEvent; +using static SagaResult; + +public static class GroupCheckoutSaga +{ + public static Command[] Handle(GroupCheckoutInitiated @event) => + @event.GuestStayIds.Select(guestAccountId => + Send(new CheckOutGuest(guestAccountId, @event.InitiatedAt, @event.GroupCheckoutId)) + ).ToArray(); + + public static SagaResult Handle(GuestCheckedOut @event) + { + if (!@event.GroupCheckOutId.HasValue) + return Ignore; + + return Send( + new RecordGuestCheckoutCompletion( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.CheckedOutAt + ) + ); + } + + public static SagaResult Handle(GuestStayAccountEvent.GuestCheckoutFailed @event) + { + if (!@event.GroupCheckOutId.HasValue) + return Ignore; + + return Send( + new RecordGuestCheckoutFailure( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.FailedAt + ) + ); + } +}; + +public abstract record SagaResult +{ + public record Command(T Message): SagaResult; + + public record Event(T Message): SagaResult; + + public record None: SagaResult; + + public static Command Send(T command) => new(command); + + public static Event Publish(T @event) => new(@event); + + public static readonly None Ignore = new(); +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutsConfig.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutsConfig.cs new file mode 100644 index 00000000..b9b0c1bb --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GroupCheckouts/GroupCheckoutsConfig.cs @@ -0,0 +1,36 @@ +using BusinessProcesses.Core; +using BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GuestStayAccounts; + +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GroupCheckouts; + +using static GuestStayAccountEvent; +using static GroupCheckoutCommand; +using static SagaResult; + +public static class GroupCheckoutsConfig +{ + public static void ConfigureGroupCheckouts( + EventBus eventBus, + CommandBus commandBus, + GroupCheckoutFacade groupCheckoutFacade + ) + { + eventBus + .Subscribe((@event, ct) => + commandBus.Send(GroupCheckoutSaga.Handle(@event).Select(c => c.Message).ToArray(), ct) + ) + .Subscribe((@event, ct) => + GroupCheckoutSaga.Handle(@event) is Command(var command) + ? commandBus.Send([command], ct) + : ValueTask.CompletedTask + ) + .Subscribe((@event, ct) => + GroupCheckoutSaga.Handle(@event) is Command(var command) + ? commandBus.Send([command], ct) + : ValueTask.CompletedTask + ); + + commandBus.Handle(groupCheckoutFacade.RecordGuestCheckoutCompletion); + commandBus.Handle(groupCheckoutFacade.RecordGuestCheckoutFailure); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GroupCheckoutsConfig.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GroupCheckoutsConfig.cs new file mode 100644 index 00000000..b6a6e9e5 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GroupCheckoutsConfig.cs @@ -0,0 +1,18 @@ +using BusinessProcesses.Core; +using BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GroupCheckouts; +using BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GuestStayAccounts; + +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GuestStayAccounts; + +using static GuestStayAccountCommand; + +public static class GuestStayAccountsConfig +{ + public static void ConfigureGuestStayAccounts( + CommandBus commandBus, + GuestStayFacade guestStayFacade + ) + { + commandBus.Handle(guestStayFacade.CheckOutGuest); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GuestStayAccount.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GuestStayAccount.cs new file mode 100644 index 00000000..a9d4ad31 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GuestStayAccount.cs @@ -0,0 +1,120 @@ +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GuestStayAccounts; + +using static GuestStayAccountEvent; + +public abstract record GuestStayAccountEvent +{ + public record GuestCheckedIn( + Guid GuestStayId, + DateTimeOffset CheckedInAt + ): GuestStayAccountEvent; + + public record ChargeRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record PaymentRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record GuestCheckedOut( + Guid GuestStayId, + DateTimeOffset CheckedOutAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent; + + public record GuestCheckoutFailed( + Guid GuestStayId, + GuestCheckoutFailed.FailureReason Reason, + DateTimeOffset FailedAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent + { + public enum FailureReason + { + NotOpened, + BalanceNotSettled + } + } + + private GuestStayAccountEvent() { } +} + +public record GuestStayAccount( + Guid Id, + decimal Balance = 0, + GuestStayAccountStatus Status = GuestStayAccountStatus.Opened +) +{ + public bool IsSettled => Balance == 0; + + public static GuestCheckedIn CheckIn(Guid guestStayId, DateTimeOffset now) => new(guestStayId, now); + + public ChargeRecorded RecordCharge(decimal amount, DateTimeOffset now) + { + if (Status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + return new ChargeRecorded(Id, amount, now); + } + + public PaymentRecorded RecordPayment(decimal amount, DateTimeOffset now) + { + if (Status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + return new PaymentRecorded(Id, amount, now); + } + + public GuestStayAccountEvent CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) + { + if (Status != GuestStayAccountStatus.Opened) + return new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.NotOpened, + now, + groupCheckoutId + ); + + return IsSettled + ? new GuestCheckedOut( + Id, + now, + groupCheckoutId + ) + : new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.BalanceNotSettled, + now, + groupCheckoutId + ); + } + + // This method can be used to build state from events + // You can ignore it if you're not into Event Sourcing + public GuestStayAccount Evolve(GuestStayAccountEvent @event) => + @event switch + { + GuestCheckedIn checkedIn => this with + { + Id = checkedIn.GuestStayId, Status = GuestStayAccountStatus.Opened + }, + ChargeRecorded charge => this with { Balance = Balance - charge.Amount }, + PaymentRecorded payment => this with { Balance = Balance + payment.Amount }, + GuestCheckedOut => this with { Status = GuestStayAccountStatus.CheckedOut }, + GuestCheckoutFailed => this, + _ => this + }; + + public static readonly GuestStayAccount Initial = new(default, default, default); +} + +public enum GuestStayAccountStatus +{ + Opened = 1, + CheckedOut = 2 +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GuestStayFacade.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GuestStayFacade.cs new file mode 100644 index 00000000..f08ae473 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/ProcessManagers/GuestStayAccounts/GuestStayFacade.cs @@ -0,0 +1,86 @@ +using BusinessProcesses.Core; + +namespace BusinessProcesses.Version2_ImmutableEntities.ProcessManagers.GuestStayAccounts; + +using static GuestStayAccountCommand; +using static GuestStayAccountEvent; + +public class GuestStayFacade(Database database, EventBus eventBus) +{ + public async ValueTask CheckInGuest(CheckInGuest command, CancellationToken ct = default) + { + var @event = GuestStayAccount.CheckIn(command.GuestStayId, command.Now); + + await database.Store(command.GuestStayId, GuestStayAccount.Initial.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordCharge(RecordCharge command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var @event = account.RecordCharge(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordPayment(RecordPayment command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var @event = account.RecordPayment(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask CheckOutGuest(CheckOutGuest command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + switch (account.CheckOut(command.Now, command.GroupCheckOutId)) + { + case GuestCheckedOut checkedOut: + { + await database.Store(command.GuestStayId, account.Evolve(checkedOut), ct); + await eventBus.Publish([checkedOut], ct); + return; + } + case GuestCheckoutFailed checkOutFailed: + { + await eventBus.Publish([checkOutFailed], ct); + return; + } + } + } +} + +public abstract record GuestStayAccountCommand +{ + public record CheckInGuest( + Guid GuestStayId, + DateTimeOffset Now + ); + + public record RecordCharge( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ); + + public record RecordPayment( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ); + + public record CheckOutGuest( + Guid GuestStayId, + DateTimeOffset Now, + Guid? GroupCheckOutId = null + ); +}