- Sponsor
-
Notifications
You must be signed in to change notification settings - Fork 522
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
1 parent
7dc4043
commit 4f57da6
Showing
32 changed files
with
1,894 additions
and
128 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
224 changes: 224 additions & 0 deletions
224
.../EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/BusinessProcessTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,224 @@ | ||
using Bogus; | ||
using BusinessProcesses.Core; | ||
using BusinessProcesses.Version1_Aggregates.GroupCheckouts; | ||
using BusinessProcesses.Version1_Aggregates.GuestStayAccounts; | ||
using Xunit; | ||
using Xunit.Abstractions; | ||
using Database = BusinessProcesses.Core.Database; | ||
|
||
namespace BusinessProcesses.Version1_Aggregates; | ||
|
||
using static GuestStayAccountCommand; | ||
using static GuestStayAccountEvent; | ||
using static GroupCheckoutCommand; | ||
|
||
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 guestStayFacade.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 GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), | ||
new CheckOutGuest(guestStays[1], now, groupCheckoutId), | ||
new GuestCheckedOut(guestStays[1], now, groupCheckoutId), | ||
new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), | ||
new CheckOutGuest(guestStays[2], now, groupCheckoutId), | ||
new GuestCheckedOut(guestStays[2], now, groupCheckoutId), | ||
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 guestStayFacade.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 GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), | ||
new CheckOutGuest(guestStays[1], now, groupCheckoutId), | ||
new GuestCheckedOut(guestStays[1], now, groupCheckoutId), | ||
new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), | ||
new CheckOutGuest(guestStays[2], now, groupCheckoutId), | ||
new GuestCheckedOut(guestStays[2], now, groupCheckoutId), | ||
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 guestStayFacade.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 GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), | ||
new CheckOutGuest(guestStays[1], now, groupCheckoutId), | ||
new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, | ||
groupCheckoutId), | ||
new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now), | ||
new CheckOutGuest(guestStays[2], now, groupCheckoutId), | ||
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 guestStayFacade.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 GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[0], now), | ||
new CheckOutGuest(guestStays[1], now, groupCheckoutId), | ||
new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, | ||
groupCheckoutId), | ||
new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now), | ||
new CheckOutGuest(guestStays[2], now, groupCheckoutId), | ||
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 | ||
), | ||
] | ||
); | ||
} | ||
|
||
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 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); | ||
|
||
eventBus.Use(publishedMessages.Catch); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/CommandBus.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
using System.Collections.Concurrent; | ||
using FluentAssertions; | ||
|
||
namespace BusinessProcesses.Core; | ||
|
||
public class CommandBus | ||
{ | ||
public async ValueTask Send(object[] commands, CancellationToken ct) | ||
{ | ||
foreach (var command in commands) | ||
{ | ||
if (!commandHandlers.TryGetValue(command.GetType(), out var handler)) | ||
continue; | ||
|
||
foreach (var middleware in middlewares) | ||
middleware(command); | ||
|
||
await handler(command, ct); | ||
} | ||
} | ||
|
||
public CommandBus Handle<T>(Func<T, CancellationToken, ValueTask> eventHandler) | ||
{ | ||
commandHandlers[typeof(T)] = (command, ct) => eventHandler((T)command, ct); | ||
|
||
return this; | ||
} | ||
|
||
public void Use(Action<object> middleware) => | ||
middlewares.Add(middleware); | ||
|
||
private readonly ConcurrentDictionary<Type, Func<object, CancellationToken, ValueTask>> commandHandlers = new(); | ||
private readonly List<Action<object>> middlewares = []; | ||
} |
32 changes: 32 additions & 0 deletions
32
Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/Database.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
using System.Text.Json; | ||
|
||
namespace BusinessProcesses.Core; | ||
|
||
public class Database | ||
{ | ||
private readonly Dictionary<string, object> storage = new(); | ||
|
||
public ValueTask Store<T>(Guid id, T obj, CancellationToken _) where T : class | ||
{ | ||
storage[GetId<T>(id)] = obj; | ||
|
||
return ValueTask.CompletedTask; | ||
} | ||
|
||
public ValueTask Delete<T>(Guid id, CancellationToken _) | ||
{ | ||
storage.Remove(GetId<T>(id)); | ||
return ValueTask.CompletedTask; | ||
} | ||
|
||
public ValueTask<T?> Get<T>(Guid id, CancellationToken _) where T : class => | ||
ValueTask.FromResult( | ||
storage.TryGetValue(GetId<T>(id), out var result) | ||
? | ||
// Clone to simulate getting new instance on loading | ||
JsonSerializer.Deserialize<T>(JsonSerializer.Serialize((T)result)) | ||
: null | ||
); | ||
|
||
private static string GetId<T>(Guid id) => $"{typeof(T).Name}-{id}"; | ||
} |
44 changes: 44 additions & 0 deletions
44
Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/EventBus.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
using System.Collections.Concurrent; | ||
|
||
namespace BusinessProcesses.Core; | ||
|
||
public class EventBus | ||
{ | ||
public async ValueTask Publish(object[] events, CancellationToken ct) | ||
{ | ||
foreach (var @event in events) | ||
{ | ||
foreach (var middleware in middlewares) | ||
middleware(@event); | ||
|
||
if (!eventHandlers.TryGetValue(@event.GetType(), out var handlers)) | ||
continue; | ||
|
||
foreach (var handler in handlers) | ||
await handler(@event, ct); | ||
} | ||
} | ||
|
||
public EventBus Subscribe<T>(Func<T, CancellationToken, ValueTask> eventHandler) | ||
{ | ||
Func<object, CancellationToken, ValueTask> handler = (@event, ct) => eventHandler((T)@event, ct); | ||
|
||
eventHandlers.AddOrUpdate( | ||
typeof(T), | ||
_ => [handler], | ||
(_, handlers) => | ||
{ | ||
handlers.Add(handler); | ||
return handlers; | ||
} | ||
); | ||
|
||
return this; | ||
} | ||
|
||
public void Use(Action<object> middleware) => | ||
middlewares.Add(middleware); | ||
|
||
private readonly ConcurrentDictionary<Type, List<Func<object, CancellationToken, ValueTask>>> eventHandlers = new(); | ||
private readonly List<Action<object>> middlewares = []; | ||
} |
26 changes: 26 additions & 0 deletions
26
Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/MessageCatcher.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
using FluentAssertions; | ||
|
||
namespace BusinessProcesses.Core; | ||
|
||
public class MessageCatcher | ||
{ | ||
public List<object> Published { get; } = []; | ||
|
||
public void Catch(object message) => | ||
Published.Add(message); | ||
|
||
public void Reset() => Published.Clear(); | ||
|
||
public void ShouldNotReceiveAnyMessage() => | ||
Published.Should().BeEmpty(); | ||
|
||
public void ShouldReceiveSingleMessage<T>(T message) | ||
{ | ||
Published.Should().HaveCount(1); | ||
Published.OfType<T>().Should().HaveCount(1); | ||
Published.Single().Should().Be(message); | ||
} | ||
|
||
public void ShouldReceiveMessages(object[] messages) => | ||
Published.Should().BeEquivalentTo(messages); | ||
} |
11 changes: 0 additions & 11 deletions
11
Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/UnitTest1.cs
This file was deleted.
Oops, something went wrong.
30 changes: 30 additions & 0 deletions
30
...ntDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/Core/Aggregate.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
using System.Text.Json.Serialization; | ||
|
||
namespace BusinessProcesses.Version1_Aggregates.Core; | ||
|
||
public abstract class Aggregate<TEvent, TId> | ||
where TEvent : class | ||
where TId : notnull | ||
{ | ||
[JsonInclude] public TId Id { get; protected set; } = default!; | ||
|
||
[NonSerialized] private readonly Queue<TEvent> uncommittedEvents = new(); | ||
|
||
public virtual void Apply(TEvent @event) { } | ||
|
||
public object[] DequeueUncommittedEvents() | ||
{ | ||
var dequeuedEvents = uncommittedEvents.Cast<object>().ToArray();; | ||
|
||
uncommittedEvents.Clear(); | ||
|
||
return dequeuedEvents; | ||
} | ||
|
||
protected void Enqueue(TEvent @event) | ||
{ | ||
uncommittedEvents.Enqueue(@event); | ||
Apply(@event); | ||
} | ||
} | ||
|
172 changes: 172 additions & 0 deletions
172
...nArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
using Bogus; | ||
using BusinessProcesses.Core; | ||
using BusinessProcesses.Version1_Aggregates.GroupCheckouts; | ||
using BusinessProcesses.Version1_Aggregates.GuestStayAccounts; | ||
using Xunit; | ||
using Xunit.Abstractions; | ||
using Database = BusinessProcesses.Core.Database; | ||
|
||
namespace BusinessProcesses.Version1_Aggregates; | ||
|
||
using static GuestStayAccountEvent; | ||
using static GuestStayAccountCommand; | ||
using static GroupCheckoutCommand; | ||
|
||
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)); | ||
} | ||
|
||
[Fact] | ||
public async Task GroupCheckoutForMultipleGuestStay_ShouldBeInitiated() | ||
{ | ||
// 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 guestStayFacade.InitiateGroupCheckout(command); | ||
|
||
// Then | ||
publishedMessages.ShouldReceiveSingleMessage(new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, 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); | ||
} | ||
} |
39 changes: 39 additions & 0 deletions
39
...e/Solutions/03-BusinessProcesses/Version1-Aggregates/GroupCheckouts/GroupCheckoutEvent.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
namespace BusinessProcesses.Version1_Aggregates.GroupCheckouts; | ||
|
||
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() { } | ||
} | ||
|
128 changes: 128 additions & 0 deletions
128
.../Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayAccounts/GuestStayAccount.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
using System.Text.Json.Serialization; | ||
using BusinessProcesses.Version1_Aggregates.Core; | ||
|
||
namespace BusinessProcesses.Version1_Aggregates.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 class GuestStayAccount: Aggregate<GuestStayAccountEvent, Guid> | ||
{ | ||
[JsonInclude] private decimal balance; | ||
[JsonInclude] private GuestStayAccountStatus status; | ||
private bool IsSettled => balance == 0; | ||
|
||
[JsonConstructor] | ||
private GuestStayAccount( | ||
Guid id, | ||
decimal balance, | ||
GuestStayAccountStatus status | ||
) | ||
{ | ||
Id = id; | ||
this.balance = balance; | ||
this.status = status; | ||
} | ||
|
||
public static GuestStayAccount CheckIn(Guid guestStayId, DateTimeOffset now) | ||
{ | ||
var guestStay = new GuestStayAccount(guestStayId, 0, GuestStayAccountStatus.Opened); | ||
|
||
guestStay.Enqueue(new GuestCheckedIn(guestStayId, now)); | ||
|
||
return guestStay; | ||
} | ||
|
||
public void RecordCharge(decimal amount, DateTimeOffset now) | ||
{ | ||
if (status != GuestStayAccountStatus.Opened) | ||
throw new InvalidOperationException("Cannot record charge for not opened account"); | ||
|
||
balance -= amount; | ||
|
||
Enqueue(new ChargeRecorded(Id, amount, now)); | ||
} | ||
|
||
public void RecordPayment(decimal amount, DateTimeOffset now) | ||
{ | ||
if (status != GuestStayAccountStatus.Opened) | ||
throw new InvalidOperationException("Cannot record charge for not opened account"); | ||
|
||
balance += amount; | ||
|
||
Enqueue(new PaymentRecorded(Id, amount, now)); | ||
} | ||
|
||
public void CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) | ||
{ | ||
if (status != GuestStayAccountStatus.Opened || !IsSettled) | ||
{ | ||
Enqueue(new GuestCheckoutFailed( | ||
Id, | ||
status != GuestStayAccountStatus.Opened | ||
? GuestCheckoutFailed.FailureReason.NotOpened | ||
: GuestCheckoutFailed.FailureReason.BalanceNotSettled, | ||
now, | ||
groupCheckoutId | ||
)); | ||
return; | ||
} | ||
|
||
status = GuestStayAccountStatus.CheckedOut; | ||
|
||
Enqueue( | ||
new GuestCheckedOut( | ||
Id, | ||
now, | ||
groupCheckoutId | ||
) | ||
); | ||
} | ||
} | ||
|
||
public enum GuestStayAccountStatus | ||
{ | ||
Opened = 1, | ||
CheckedOut = 2 | ||
} |
100 changes: 100 additions & 0 deletions
100
...tDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayFacade.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
using BusinessProcesses.Core; | ||
using BusinessProcesses.Version1_Aggregates.GroupCheckouts; | ||
using BusinessProcesses.Version1_Aggregates.GuestStayAccounts; | ||
|
||
namespace BusinessProcesses.Version1_Aggregates; | ||
|
||
using static GuestStayAccountCommand; | ||
using static GroupCheckoutCommand; | ||
|
||
public class GuestStayFacade(Database database, EventBus eventBus) | ||
{ | ||
public async ValueTask CheckInGuest(CheckInGuest command, CancellationToken ct = default) | ||
{ | ||
var account = GuestStayAccount.CheckIn(command.GuestStayId, command.Now); | ||
|
||
await database.Store(command.GuestStayId, account, ct); | ||
await eventBus.Publish(account.DequeueUncommittedEvents(), ct); | ||
} | ||
|
||
public async ValueTask RecordCharge(RecordCharge command, CancellationToken ct = default) | ||
{ | ||
var account = await database.Get<GuestStayAccount>(command.GuestStayId, ct) | ||
?? throw new InvalidOperationException("Entity not found"); | ||
|
||
account.RecordCharge(command.Amount, command.Now); | ||
|
||
await database.Store(command.GuestStayId, account, ct); | ||
await eventBus.Publish(account.DequeueUncommittedEvents(), ct); | ||
} | ||
|
||
public async ValueTask RecordPayment(RecordPayment command, CancellationToken ct = default) | ||
{ | ||
var account = await database.Get<GuestStayAccount>(command.GuestStayId, ct) | ||
?? throw new InvalidOperationException("Entity not found"); | ||
|
||
account.RecordPayment(command.Amount, command.Now); | ||
|
||
await database.Store(command.GuestStayId, account, ct); | ||
await eventBus.Publish(account.DequeueUncommittedEvents(), ct); | ||
} | ||
|
||
public async ValueTask CheckOutGuest(CheckOutGuest command, CancellationToken ct = default) | ||
{ | ||
var account = await database.Get<GuestStayAccount>(command.GuestStayId, ct) | ||
?? throw new InvalidOperationException("Entity not found"); | ||
|
||
account.CheckOut(command.Now, command.GroupCheckOutId); | ||
|
||
await database.Store(command.GuestStayId, account, ct); | ||
await eventBus.Publish(account.DequeueUncommittedEvents(), ct); | ||
} | ||
|
||
public ValueTask InitiateGroupCheckout(InitiateGroupCheckout command, CancellationToken ct = default) => | ||
eventBus.Publish([ | ||
new GroupCheckoutEvent.GroupCheckoutInitiated( | ||
command.GroupCheckoutId, | ||
command.ClerkId, | ||
command.GuestStayIds, | ||
command.Now | ||
) | ||
], ct); | ||
} | ||
|
||
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 | ||
); | ||
} | ||
|
||
public abstract record GroupCheckoutCommand | ||
{ | ||
public record InitiateGroupCheckout( | ||
Guid GroupCheckoutId, | ||
Guid ClerkId, | ||
Guid[] GuestStayIds, | ||
DateTimeOffset Now | ||
): GroupCheckoutCommand; | ||
|
||
private GroupCheckoutCommand() { } | ||
} |
17 changes: 17 additions & 0 deletions
17
...re/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Core/DictionaryExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
namespace BusinessProcesses.Version2_ImmutableEntities.Core; | ||
|
||
public static class DictionaryExtensions | ||
{ | ||
public static Dictionary<TKey, TValue> With<TKey, TValue>( | ||
this Dictionary<TKey, TValue> first, | ||
TKey key, | ||
TValue value | ||
) where TKey : notnull | ||
{ | ||
var newDictionary = first.ToDictionary(ks => ks.Key, vs => vs.Value); | ||
|
||
newDictionary[key] = value; | ||
|
||
return newDictionary; | ||
} | ||
} |
244 changes: 244 additions & 0 deletions
244
...e/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/BusinessProcessTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,244 @@ | ||
using Bogus; | ||
using BusinessProcesses.Core; | ||
using BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; | ||
using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; | ||
using Xunit; | ||
using Xunit.Abstractions; | ||
using Database = BusinessProcesses.Core.Database; | ||
|
||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas; | ||
|
||
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); | ||
} | ||
} |
148 changes: 148 additions & 0 deletions
148
.../Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/EntityDefinitionTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
using Bogus; | ||
using BusinessProcesses.Core; | ||
using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; | ||
using Xunit; | ||
using Xunit.Abstractions; | ||
using Database = BusinessProcesses.Core.Database; | ||
|
||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas; | ||
|
||
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); | ||
} | ||
} |
156 changes: 156 additions & 0 deletions
156
...ons/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckout.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
using BusinessProcesses.Version2_ImmutableEntities.Core; | ||
|
||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.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<Guid, CheckoutStatus> 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<Guid, CheckoutStatus> 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<Guid, CheckoutStatus> guestStayCheckouts) => | ||
guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Initiated); | ||
|
||
private static bool AreAnyFailedCheckouts(Dictionary<Guid, CheckoutStatus> guestStayCheckouts) => | ||
guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Failed); | ||
|
||
private static Guid[] CheckoutsWith(Dictionary<Guid, CheckoutStatus> 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 | ||
} |
82 changes: 82 additions & 0 deletions
82
...-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutFacade.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
using BusinessProcesses.Core; | ||
using Database = BusinessProcesses.Core.Database; | ||
|
||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.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<GroupCheckout>(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<object>().ToArray(), ct); | ||
} | ||
|
||
public async ValueTask RecordGuestCheckoutFailure( | ||
RecordGuestCheckoutFailure command, | ||
CancellationToken ct = default | ||
) | ||
{ | ||
var groupCheckout = await database.Get<GroupCheckout>(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<object>().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() { } | ||
} |
60 changes: 60 additions & 0 deletions
60
...03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutSaga.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; | ||
|
||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; | ||
|
||
using static GroupCheckoutCommand; | ||
using static GroupCheckoutEvent; | ||
using static GuestStayAccountCommand; | ||
using static GuestStayAccountEvent; | ||
using static SagaResult; | ||
|
||
public static class GroupCheckoutSaga | ||
{ | ||
public static Command<CheckOutGuest>[] 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>(T Message): SagaResult; | ||
|
||
public record Event<T>(T Message): SagaResult; | ||
|
||
public record None: SagaResult; | ||
|
||
public static Command<T> Send<T>(T command) => new(command); | ||
|
||
public static Event<T> Publish<T>(T @event) => new(@event); | ||
|
||
public static readonly None Ignore = new(); | ||
} |
36 changes: 36 additions & 0 deletions
36
...BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutsConfig.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
using BusinessProcesses.Core; | ||
using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; | ||
|
||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.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<GroupCheckoutEvent.GroupCheckoutInitiated>((@event, ct) => | ||
commandBus.Send(GroupCheckoutSaga.Handle(@event).Select(c => c.Message).ToArray(), ct) | ||
) | ||
.Subscribe<GuestCheckedOut>((@event, ct) => | ||
GroupCheckoutSaga.Handle(@event) is Command<RecordGuestCheckoutCompletion>(var command) | ||
? commandBus.Send([command], ct) | ||
: ValueTask.CompletedTask | ||
) | ||
.Subscribe<GuestCheckoutFailed>((@event, ct) => | ||
GroupCheckoutSaga.Handle(@event) is Command<RecordGuestCheckoutFailure>(var command) | ||
? commandBus.Send([command], ct) | ||
: ValueTask.CompletedTask | ||
); | ||
|
||
commandBus.Handle<RecordGuestCheckoutCompletion>(groupCheckoutFacade.RecordGuestCheckoutCompletion); | ||
commandBus.Handle<RecordGuestCheckoutFailure>(groupCheckoutFacade.RecordGuestCheckoutFailure); | ||
} | ||
} |
18 changes: 18 additions & 0 deletions
18
...inessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GroupCheckoutsConfig.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
using BusinessProcesses.Core; | ||
using BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; | ||
using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; | ||
|
||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; | ||
|
||
using static GuestStayAccountCommand; | ||
|
||
public static class GuestStayAccountsConfig | ||
{ | ||
public static void ConfigureGuestStayAccounts( | ||
CommandBus commandBus, | ||
GuestStayFacade guestStayFacade | ||
) | ||
{ | ||
commandBus.Handle<CheckOutGuest>(guestStayFacade.CheckOutGuest); | ||
} | ||
} |
120 changes: 120 additions & 0 deletions
120
...-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayAccount.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.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 | ||
} |
86 changes: 86 additions & 0 deletions
86
...3-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayFacade.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
using BusinessProcesses.Core; | ||
|
||
namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.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<GuestStayAccount>(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<GuestStayAccount>(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<GuestStayAccount>(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 | ||
); | ||
} |