Toan Le

Learn Domain Driven Design by building an invoice app

2022-11-26

At the beginning of this project, I didn’t have much knowledge about double-entry accounting or domain-driven design. I wanted to learn more about these topics, so I followed freshbooks.com for ideas. After finishing the project, I was pleased with the end result. I compared all the reports with Freshbooks, and they exactly matched.

Although domain-driven design is a powerful tool, I won’t use it if the application is not complex. It would be overkill for a simple CRUD application.

For this project, the backend was written in C# and .NET, while the front end was built with React.

invoice.toanle88.com

image

Pitfalls

  • One mistake I did was to not persist of the calculated number of bills, invoices, etc to database. When writing reports, I discovered that it was challenging to query.
  • I should render reports from the backend instead sending data and render from the frontend by React component. I’ve noticed that when I run a General Ledgers report and if there are too many data, it responds slowly when I change filters.
  • Expense Account should be replaced with Category in Expense bounced context.
  • Use generated id for internal, and GUID id for external facing.

Resources

Structure

image

  • The domain is the heart of DDD, where it contains business logic. The domain should not depend on applications or infrastructure.
  • The application layer defines the functions of the software and directs the expressive domain objects to solve problems.
  • The infrastructure layer is how the data that is initially held in domain entities (in memory) is persisted in databases.

Bounced Context

Bounced context is the most important part of DDD. The core of DDD is all about designing a rich domain model, and the terms associated with it like aggregate root, value object, and domain event,… are tools to achieve that.

image

Compare FreshBooks reports

To make sure the app working correctly, I compare the app reports with FreshBooks reports.
image
image

Ensure quality with unit test and intergration test

To ensure the quality of the app, I ensure the quality of the tests.

  • Test the domain with unit tests.
  • Test the endpoint with integration tests, and the tests should not depend on the data of the database test.
  • Smoke tests the workflow to ensure business logic.
  • Test the final number of the report, such as profit and loss, balance sheet, cash flow, etc.. Every time this test is run, it will test on a new tenancy, so everything is clean. It will call the endpoints to execute the workflow and then test the final report number.
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
public class ReportSmokeTests : IClassFixture<WebApplicationFixture>
{
    private RequestAdapter _requestAdapter;
    private readonly WebApplicationFixture _fixture;
    public ReportSmokeTests(WebApplicationFixture fixture)
    {
        _fixture = fixture;
        _requestAdapter = new RequestAdapter(_fixture.Client);
    }

    [Fact]
    public async Task Report_Should_Be_OK()
    {
        var tenantId = await _requestAdapter.CreateUserWorkspaceAsync();
        _requestAdapter = new RequestAdapter(_fixture.Client, tenantId);

        await GenerateTestData();

        await Profit_And_Loss_Report_Should_Be_OK();
        await Trial_Balance_Report_Should_Be_OK();
        await Cash_Flow_Report_Should_Be_OK();
        await Balance_Sheet_Report_Should_Be_OK();
        await Credit_Balance_Report_Should_Be_OK();
        await Revenue_By_Customer_Report_Should_Be_OK();
        await Invoice_Details_Report_Should_Be_OK();
        await Payment_Collected_Report_Should_Be_OK();
    }

    private async Task Profit_And_Loss_Report_Should_Be_OK()
    {
        // Arrange
        var fromDate = new DateTime(2020, 01, 01);
        var toDate = DateTime.Now;

        // Act
        var (record, response) = await _requestAdapter.GetAsync<ProfitAndLossReportDto>(
            $"{BaseApi.Report}/profitAndLoss?fromDate={fromDate.ToUniversalTime():o}&toDate={toDate.ToUniversalTime():o}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        record.NetProfit.Should().Be(641.63M);
    }

    private async Task Trial_Balance_Report_Should_Be_OK()
    {
        // Arrange
        var fromDate = new DateTime(2020, 01, 01);
        var toDate = DateTime.Now;

        // Act
        var (record, response) = await _requestAdapter.GetAsync<TrialBalanceReportDto>(
            $"{BaseApi.Report}/trialBalance?fromDate={fromDate.ToUniversalTime():o}&toDate={toDate.ToUniversalTime():o}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        record.TotalDebit.Should().Be(3298.77M);
        record.TotalCredit.Should().Be(3298.77M);
    }

    private async Task Cash_Flow_Report_Should_Be_OK()
    {
        // Arrange
        var fromDate = new DateTime(2020, 01, 01);
        var toDate = DateTime.Now;

        // Act
        var (record, response) = await _requestAdapter.GetAsync<CashFlowReportDto>(
            $"{BaseApi.Report}/cashFlow?fromDate={fromDate.ToUniversalTime():o}&toDate={toDate.ToUniversalTime():o}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        record.StartingBalance.Should().Be(0);
        record.EndingBalance.Should().Be(2423.48M);
    }

    private async Task Balance_Sheet_Report_Should_Be_OK()
    {
        // Arrange
        var date = DateTime.Now;

        // Act
        var (record, response) = await _requestAdapter.GetAsync<BalanceSheetReportDto>(
            $"{BaseApi.Report}/balanceSheet?date={date.ToUniversalTime():o}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        record.Details[0].Total.Should().Be(2522.07M);
        record.Details[1].Total.Should().Be(2522.07M);
    }

    private async Task Credit_Balance_Report_Should_Be_OK()
    {
        // Arrange
        var fromDate = new DateTime(2020, 01, 01);
        var toDate = DateTime.Now;

        // Act
        var (record, response) = await _requestAdapter.GetAsync<CreditBalanceReportDto>(
            $"{BaseApi.Report}/creditBalance?fromDate={fromDate.ToUniversalTime():o}&toDate={toDate.ToUniversalTime():o}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        record.TotalCreditIssued.Should().Be(301.39M);
        record.TotalCreditApplied.Should().Be(30);
        record.TotalCreditBalance.Should().Be(271.39M);
    }

    private async Task Revenue_By_Customer_Report_Should_Be_OK()
    {
        // Arrange
        var fromDate = new DateTime(2020, 01, 01);
        var toDate = DateTime.Now;

        // Act
        var (record, response) = await _requestAdapter.GetAsync<RevenueByCustomerReportDto>(
            $"{BaseApi.Report}/revenueByCustomer?fromDate={fromDate.ToUniversalTime():o}&toDate={toDate.ToUniversalTime():o}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        record.Total.Should().Be(1635.57M);
    }

    private async Task Invoice_Details_Report_Should_Be_OK()
    {
        // Arrange
        var fromDate = new DateTime(2020, 01, 01);
        var toDate = DateTime.Now;

        // Act
        var (record, response) = await _requestAdapter.GetAsync<InvoiceDetailsReportDto>(
            $"{BaseApi.Report}/invoiceDetails?fromDate={fromDate.ToUniversalTime():o}&toDate={toDate.ToUniversalTime():o}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        record.Summary.TotalInvoice.Should().Be(1635.57M);
        record.Summary.AmountPaid.Should().Be(1536.98M);
        record.Summary.AmountDue.Should().Be(98.59M);
    }

    private async Task Payment_Collected_Report_Should_Be_OK()
    {
        // Arrange
        var fromDate = new DateTime(2020, 01, 01);
        var toDate = DateTime.Now;

        // Act
        var (record, response) = await _requestAdapter.GetAsync<PaymentsCollectedReportDto>(
            $"{BaseApi.Report}/paymentsCollected?fromDate={fromDate.ToUniversalTime():o}&toDate={toDate.ToUniversalTime():o}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        record.Total.Should().Be(1808.37M);
    }

    private async Task GenerateTestData()
    {
        var billBuilder = BillBuilder.Create(_requestAdapter);
        var invoiceBuilder = InvoiceBuilder.Create(_requestAdapter);
        var journalFactory = JournalFactory.Create(_requestAdapter);
        var expensePaymentFactory = ExpensePaymentFactory.Create(_requestAdapter);

        await billBuilder.RunAsync(true, BillStatus.Issued, 1, 50, 7, 30, BillStatus.Partial);
        await billBuilder.RunAsync(true, BillStatus.Issued, 2, 23, 4, 12, BillStatus.Partial);
        await billBuilder.RunAsync(true, BillStatus.Issued, 4, 74, 3, 36, BillStatus.Partial);
        await billBuilder.RunAsync(true, BillStatus.Issued, 3, 50, 7, 160.5M, BillStatus.Paid);

        await invoiceBuilder.RunAsync(true, InvoiceStatus.Issued, 1, 50, 7, 9, 30, InvoiceStatus.Partial);
        await invoiceBuilder.RunAsync(true, InvoiceStatus.Issued, 2, 50, 7, 9, 97.37M, InvoiceStatus.Paid);
        await invoiceBuilder.RunAsync(true, InvoiceStatus.Issued, 2, 250, 7, 9, 550M, InvoiceStatus.Paid);
        await invoiceBuilder.RunAsync(true, InvoiceStatus.Issued, 4, 233, 3, 7, 1024M, InvoiceStatus.Paid);
        await invoiceBuilder.RunAsync(true, InvoiceStatus.Issued, 2, 53, 8, 4, 30, 100, InvoiceStatus.Partial);

        await journalFactory.CreateJournalAsync(1237,
                                                AccountType.Asset,
                                                AccountSubType.CashAndBank,
                                                AccountGroup.Bank,
                                                AccountType.Equity,
                                                AccountSubType.Equity,
                                                AccountGroup.Default);

        await expensePaymentFactory.CreateExpensePaymentAsync(42);
        await expensePaymentFactory.CreateExpensePaymentAsync(13);
        await expensePaymentFactory.CreateExpensePaymentAsync(27);
    }
}

Controllers

The endpoint is designed to follow the REST API. For simplicity, I would use only GET and POST. GET is for reading, and POST is for writing.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[HttpGet("{id}")]
public async Task<ActionResult<JournalDto>> Get(int id)
{
    var query = GetJournal.Create(id);
    var result = await _queryBus.Send<GetJournal, JournalDto?>(query);

    if (result == null) return NotFound($"Journal with id {id} does not exist");

    return Ok(result);
}

Commands

The command is for writing only.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class CreateJournal : ICommand<int>
{
    private CreateJournal(bool isPost,
                          string name,
                          DateTime date,
                          CurrencyCode currencyCode,
                          string description,
                          IEnumerable<JournalItem> journalItems)
    {
        IsPost = isPost;
        Name = name;
        Date = date;
        CurrencyCode = currencyCode;
        Description = description;
        JournalItems = journalItems.ToList();
    }

    public static CreateJournal Create(bool isPost,
                                       string name,
                                       DateTime date,
                                       CurrencyCode currencyCode,
                                       string description,
                                       IEnumerable<JournalItem> journalItems)
    {
        return new CreateJournal(isPost, name, date, currencyCode, description, journalItems);
    }
    public bool IsPost { get; private set; }
    public string Name { get; private set; }
    public DateTime Date { get; private set; }
    public CurrencyCode CurrencyCode { get; private set; }
    public int? TransactionId { get; private set; }
    public string Description { get; private set; }
    public List<JournalItem> JournalItems { get; private set; }
}

Commands handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class HandleCreateJournal : ICommandHandler<CreateJournal, int>
{
    private readonly IJournalService _journalService;

    public HandleCreateJournal(IJournalService journalService)
    {
        _journalService = journalService;
    }

    public async Task<int> Handle(CreateJournal request, CancellationToken cancellationToken)
    {
        var journal = await _journalService.CreateJournalAsync(request, cancellationToken: cancellationToken);

        return journal.Id;
    }
}

Queries

The query is for reading only.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class GetJournal : IQuery<JournalDto?>
{
    public int Id { get; private set; }

    private GetJournal(int id)
    {
        Id = id;
    }

    public static GetJournal Create(int id)
    {
        return new GetJournal(id);
    }
} 

Queries handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class GetJournalHandler : IQueryHandler<GetJournal, JournalDto?>
{
    private readonly ApplicationDbContext _dbContext;

    public GetJournalHandler(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<JournalDto?> Handle(GetJournal request, CancellationToken cancellationToken)
    {
        var journal = await (from j in _dbContext.Journals.Include(x => x.JournalItems).AsNoTracking()
                              where j.Id == request.Id
                              select new JournalDto
                              {
                                  StreamId = j.StreamId,
                                  Id = j.Id,
                                  CurrencyCode = j.CurrencyCode,
                                  Date = j.Date,
                                  Description = j.Description,
                                  JournalNumber = j.JournalNumber,
                                  Name = j.Name,
                                  Status = j.Status,
                                  JournalItems = j.JournalItems.Select(i => new JournalItemDto
                                                  {
                                                      Id = i.Id,
                                                      Debit = i.Debit,
                                                      Credit = i.Credit,
                                                      Account = (from a in _dbContext.Accounts.AsNoTracking()
                                                                where a.Id == i.AccountId
                                                                select a.ProjectTo()).First()
                                                  }).ToList()
                              }).FirstOrDefaultAsync(cancellationToken);


        return journal;
    }
}

Application Services

Used by external consumers to talk to your system. If consumers need access to CRUD operations, they would be exposed here.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public async Task<Journal> CreateJournalAsync(CreateJournal request, CancellationToken cancellationToken = default)
{
    _logger.LogInformation(LogEvent.CreateJournal, "Begin to create journal");

    var journal = Journal.Create(request.Name,
                                    request.Date,
                                    request.CurrencyCode,
                                    request.Description,
                                    await GetNumberAsync(),
                                    request.JournalItems);
    if (request.IsPost)
    {
        journal.PostJournal();
    }

    _journalRepository.Add(journal);
    await _journalRepository.UnitOfWork.SaveEntityAsync(cancellationToken);

    _logger.LogInformation(LogEvent.CreateJournal, "Completed to create journal");

    return journal;
}

Domain Services

Encapsulates business logic that doesn’t naturally fit within a domain object.

Infrastructure Services

Used to abstract technical concerns (e.g. MSMQ, email provider, etc).

Aggregate, Entity, ValueObject

  • The aggregate should be associated with one transaction and always be in a valid state.
  • Entity is where we could identify via identity.
  • ValueObject is where we could not idenfity via identity fx example Money, Address.
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
public class Journal : DomainEntity, IAggregateRoot
{
    public const string TypeCode = "JNL";
    private Journal()
    {

    }

    private Journal(string name,
                    DateTime date,
                    CurrencyCode currencyCode,
                    string description,
                    int number,
                    List<JournalItem> journalItems)
    {
        StreamId = Guid.NewGuid();

        var domainEvent = JournalCreated.Create(StreamId,
                                                name,
                                                date,
                                                currencyCode,
                                                description,
                                                number,
                                                journalItems);

        When(domainEvent);
    }

    public static Journal Create(string name,
                                 DateTime date,
                                 CurrencyCode currencyCode,
                                 string description,
                                 int number,
                                 List<JournalItem> journalItems)
    {
        return new Journal(name, date, currencyCode, description, number, journalItems);
    }

    public string Name { get; private set; } = string.Empty;
    public DateTime Date { get; private set; }
    public CurrencyCode CurrencyCode { get; private set; }
    public string Description { get; private set; } = string.Empty;
    public List<JournalItem> JournalItems { get; private set; } = new List<JournalItem>();
    public JournalStatus Status { get; private set; }
    public int Number { get; private set; }
    public string JournalNumber { get; private set; } = string.Empty;
    public bool IsArchive { get; private set; }
    public decimal Amount => JournalItems != null && JournalItems.Count > 0 ? JournalItems.Sum(x => x.Debit) : 0;

    public void ChangeJournalStatus(JournalStatus status)
    {
        switch (status)
        {
            case JournalStatus.InProgress:
                When(JournalInProgressed.Create(StreamId, Id));
                break;
            case JournalStatus.Issued:
                When(JournalIssued.Create(StreamId, Id));
                break;
            default:
                throw new InvalidOperationException();
        }
    }

    public void PostJournal()
    {
        ChangeJournalStatus(JournalStatus.InProgress);

        var domainEvent = JournalPosted.Create(StreamId, this);
        When(domainEvent);
    }

    public void DeleteJournal()
    {
        var domainEvent = JournalDeleted.Create(StreamId, Id);
        When(domainEvent);
    }

    public void UpdateJournal(string name,
                              DateTime date,
                              CurrencyCode currencyCode,
                              string description,
                              IEnumerable<JournalItem> journalItems)
    {
        var domainEvent = JournalUpdated.Create(StreamId, Id, name, date, currencyCode, description, journalItems);
        When(domainEvent);
    }

    protected override void When(IDomainEvent domainEvent)
    {
        switch (domainEvent)
        {
            case JournalCreated journalEvent:
                Apply(journalEvent);
                break;
            case JournalDeleted journalEvent:
                Apply(journalEvent);
                break;
            case JournalUpdated journalEvent:
                Apply(journalEvent);
                break;
            case JournalIssued journalEvent:
                Apply(journalEvent);
                break;
            case JournalPosted journalEvent:
                Apply(journalEvent);
                break;
            case JournalInProgressed journalEvent:
                Apply(journalEvent);
                break;
            default:
                throw new ArgumentException(domainEvent.ToString());
        }

        Validate();
    }

    private void Apply(JournalPosted domainEvent)
    {
        AddDomainEvent(domainEvent);
    }

    private void Apply(JournalIssued domainEvent)
    {
        Status = domainEvent.Status;

        AddDomainEvent(domainEvent);
    }

    private void Apply(JournalInProgressed domainEvent)
    {
        Status = domainEvent.Status;

        AddDomainEvent(domainEvent);
    }

    private void Apply(JournalDeleted domainEvent)
    {
        IsArchive = domainEvent.IsArchive;

        AddDomainEvent(domainEvent);
    }

    private void Apply(JournalUpdated domainEvent)
    {
        Name = domainEvent.Name;
        Date = domainEvent.Date.Date;
        CurrencyCode = domainEvent.CurrencyCode;
        Description = domainEvent.Description;
        JournalItems = domainEvent.JournalItems;

        AddDomainEvent(domainEvent);
    }

    private void Apply(JournalCreated domainEvent)
    {
        Name = domainEvent.Name;
        Date = domainEvent.Date.Date;
        Number = domainEvent.Number;
        CurrencyCode = domainEvent.CurrencyCode;
        Description = domainEvent.Description;
        JournalItems = domainEvent.JournalItems;
        JournalNumber = $"{TypeCode}{Number:00000}";

        AddDomainEvent(domainEvent);
    }

    private void Validate()
    {
        ValidatorAdapter.Validate(new JournalValidator(), this);
    }
}

Domain event

A domain event should be immutable, and its name should be in the past tense.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class JournalPosted : DomainEvent
{
    private JournalPosted(Guid streamId, Journal journal) : base(streamId)
    {
        Journal = journal;
    }

    public static JournalPosted Create(Guid streamId, Journal journal)
    {
        return new JournalPosted(streamId, journal);
    }

    public Journal Journal { get; private set; }
}

Domain event handler

A domain event is raised by the aggregate, and the event handler should be part of the aggregate transaction. In this example, the audit should be saved as part of the transaction. The domain event handler should not commit anything here.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class JournalPostedHandler : IDomainEventHandler<JournalPosted>
{
    private readonly IAuditService _auditService;
    private readonly IEventBus _eventBus;

    public JournalPostedHandler(IAuditService auditService, IEventBus eventBus)
    {
        _auditService = auditService;
        _eventBus = eventBus;
    }

    public async Task Handle(JournalPosted notification, CancellationToken cancellationToken)
    {
        await _auditService.AddAuditAsync(notification.StreamId, 
                                          nameof(JournalPosted), 
                                          JsonAdapter.Serialize(notification), 
                                          cancellationToken: cancellationToken);

        var integrationEvent = JournalTransactionPosted.Create(notification.StreamId, notification.Journal);
        _eventBus.Add(integrationEvent);
    }
}

Integration event

An integration event should be immutable, and its name should be in the past tense. The event is raised when we need to handle multiple aggregates.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class JournalTransactionPosted : IntegrationEvent
{
    private JournalTransactionPosted(Guid streamId, Journal journal) : base(streamId)
    {
        Journal = journal;
    }

    public static JournalTransactionPosted Create(Guid streamId, Journal journal)
    {
        return new JournalTransactionPosted(streamId, journal);
    }

    public Journal Journal { get; private set; }
}

Integration event handler

Before publish an Iintegration event, we needs to make sure that the aggregate that raised it has been completed. Otherwise, we could get to the inconsistent state where the aggregate fails to complete.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class JournalTransactionPostedHandler : IIntegrationEventHandler<JournalTransactionPosted>
{
    private readonly ICommandBus _commandBus;

    public JournalTransactionPostedHandler(ICommandBus commandBus)
    {
        _commandBus = commandBus;
    }

    public async Task Handle(JournalTransactionPosted notification, CancellationToken cancellationToken)
    {
        var transaction = await CreateJournalTransactionAsync(notification.Journal);

        var command = CreateTransaction.Create(transaction.Date,
                                               transaction.ProcessedDate,
                                               transaction.Type,
                                               transaction.ReferenceStreamId,
                                               transaction.ReferenceId,
                                               transaction.Reference,
                                               transaction.Description,
                                               transaction.Ledgers);

        await _commandBus.Send(command);
    }

    private Task<Transaction> CreateJournalTransactionAsync(Journal journal)
    {
        var ledgers = journal.JournalItems.Select(x =>
                Ledger.Create(x.AccountId, Money.Create(x.Debit > 0 ? x.Debit : x.Credit * -1, journal.CurrencyCode))
            ).ToList();

        return Task.FromResult(Transaction.Create(journal.Date,
                                                  DateTime.UtcNow,
                                                  TransactionType.Journal,
                                                  journal.StreamId,
                                                  journal.Id,
                                                  journal.Number.ToString(),
                                                  journal.Description,
                                                  ledgers));
    }
}

Copyright (c) 2023