diff --git a/backend/core/src/Core.API/Core.API.csproj b/backend/core/src/Core.API/Core.API.csproj index f4c55484..54635bdf 100644 --- a/backend/core/src/Core.API/Core.API.csproj +++ b/backend/core/src/Core.API/Core.API.csproj @@ -23,8 +23,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/backend/core/src/Core.API/DependencyInjection/InfrastructureInjection.cs b/backend/core/src/Core.API/DependencyInjection/InfrastructureInjection.cs index 375c8ccb..636cb3f6 100644 --- a/backend/core/src/Core.API/DependencyInjection/InfrastructureInjection.cs +++ b/backend/core/src/Core.API/DependencyInjection/InfrastructureInjection.cs @@ -7,6 +7,7 @@ using Core.Infrastructure.Compliance; using Core.Infrastructure.Compliance.IPLocator; using Core.Infrastructure.Compliance.Sanctionlist; +using Core.Infrastructure.Compliance.SendGridMailService; using Core.Infrastructure.CustomerFileStorage; using Core.Infrastructure.Jobs; using Core.Infrastructure.Nexus; @@ -31,6 +32,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi .AddBlobStorage(configuration) .AddCompliance(configuration) .AddTOTPGenerator() + .AddSendGridMailService(configuration) .AddBackgroundJobs(configuration); return services; @@ -51,6 +53,7 @@ private static IServiceCollection AddNexus(this IServiceCollection services, ICo services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } @@ -165,6 +168,20 @@ private static IServiceCollection AddTOTPGenerator(this IServiceCollection servi return services; } + private static IServiceCollection AddSendGridMailService(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("SendGridMailServiceOptions")) + .ValidateDataAnnotationsRecursively() + .ValidateOnStart(); + + services.AddSingleton(sp => sp.GetRequiredService>().Value); + + services.AddSingleton(); + + return services; + } + public static IServiceCollection AddBackgroundJobs(this IServiceCollection services, IConfiguration configuration) { services.AddQuartz(q => @@ -176,6 +193,9 @@ public static IServiceCollection AddBackgroundJobs(this IServiceCollection servi // Register the ProcessPaymentRequestsJob, loading the schedule from configuration q.AddJobAndTrigger(configuration); + + // Register the ProcessEmailsJob, loading the schedule from configuration + q.AddJobAndTrigger(configuration); }); services.AddQuartzHostedService(opt => diff --git a/backend/core/src/Core.API/appsettings.json b/backend/core/src/Core.API/appsettings.json index 48136497..dfb294eb 100644 --- a/backend/core/src/Core.API/appsettings.json +++ b/backend/core/src/Core.API/appsettings.json @@ -59,6 +59,14 @@ "BlobStorageOptions": { "StorageConnectionString": "", "ContainerName": "" + }, + "SendGridMailServiceOptions": { + "ApiKey": "", + "Sender": "", + "Templates": { + "WithdrawalTemplateID": "", + "FundingtemplateID": "" + } }, "ConnectionStrings": { "Database": "" @@ -69,6 +77,7 @@ "AllowedOrigins": [] }, "Quartz": { + "ProcessEmailsJob": "0 * * * * ?", "ProcessCallbacksJob": "0/5 * * * * ?", "ProcessExpiredPaymentRequestJob": "0/5 * * * * ?" }, diff --git a/backend/core/src/Core.Domain/Abstractions/ISendGridMailService.cs b/backend/core/src/Core.Domain/Abstractions/ISendGridMailService.cs new file mode 100644 index 00000000..13a4cfdc --- /dev/null +++ b/backend/core/src/Core.Domain/Abstractions/ISendGridMailService.cs @@ -0,0 +1,15 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Core.Domain.Entities.CustomerAggregate; +using Core.Domain.Entities.MailAggregate; +using Core.Domain.Entities.TransactionAggregate; + +namespace Core.Domain.Abstractions +{ + public interface ISendGridMailService + { + public Task SendMailAsync(Mail mail, Customer customer, Transaction transaction); + } +} \ No newline at end of file diff --git a/backend/core/src/Core.Domain/DateTimeProvider.cs b/backend/core/src/Core.Domain/DateTimeProvider.cs index b57368ba..365635d1 100644 --- a/backend/core/src/Core.Domain/DateTimeProvider.cs +++ b/backend/core/src/Core.Domain/DateTimeProvider.cs @@ -34,6 +34,11 @@ public static long ToUnixTimeInMilliseconds(DateTimeOffset dateTime) { return dateTime.ToUnixTimeMilliseconds(); } + + public static string? FormatDateTimeWithoutMilliseconds(DateTimeOffset? dateTime) + { + return dateTime?.ToString("yyyy-MM-dd HH:mm:ss"); + } } public class DateTimeProviderContext : IDisposable diff --git a/backend/core/src/Core.Domain/Entities/MailAggregate/Mail.cs b/backend/core/src/Core.Domain/Entities/MailAggregate/Mail.cs new file mode 100644 index 00000000..b43a1423 --- /dev/null +++ b/backend/core/src/Core.Domain/Entities/MailAggregate/Mail.cs @@ -0,0 +1,54 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +namespace Core.Domain.Entities.MailAggregate +{ + public class Mail + { + public required string Code { get; set; } + + public string? Created { get; set; } + + public string? Sent { get; set; } + + public string? Status { get; set; } + + public string? Type { get; set; } + + public int? Count { get; set; } + + public MailEntityCodes? References { get; set; } + + public MailContent? Content { get; set; } + + public MailRecipient? Recipient { get; set; } + } + + public class MailEntityCodes + { + public string? AccountCode { get; set; } + + public string? CustomerCode { get; set; } + + public string? TokenPaymentCode { get; set; } + } + + public class MailContent + { + public string? Subject { get; set; } + + public string? Html { get; set; } + + public string? Text { get; set; } + } + + public class MailRecipient + { + public string? Email { get; set; } + + public string? CC { get; set; } + + public string? BCC { get; set; } + } +} diff --git a/backend/core/src/Core.Domain/Entities/TransactionAggregate/Transaction.cs b/backend/core/src/Core.Domain/Entities/TransactionAggregate/Transaction.cs index 371cb962..1e69b1e1 100644 --- a/backend/core/src/Core.Domain/Entities/TransactionAggregate/Transaction.cs +++ b/backend/core/src/Core.Domain/Entities/TransactionAggregate/Transaction.cs @@ -18,13 +18,15 @@ public class Transaction public required DateTimeOffset Created { get; set; } + public DateTimeOffset? Finished { get; set; } + public required string Status { get; set; } public required string Type { get; set; } public string? Memo { get; set; } - public required string Direction { get; set; } + public string? Direction { get; set; } public Payment? Payment { get; set; } diff --git a/backend/core/src/Core.Domain/Repositories/IMailsRepository.cs b/backend/core/src/Core.Domain/Repositories/IMailsRepository.cs new file mode 100644 index 00000000..e1a57690 --- /dev/null +++ b/backend/core/src/Core.Domain/Repositories/IMailsRepository.cs @@ -0,0 +1,15 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Core.Domain.Entities.MailAggregate; + +namespace Core.Domain.Repositories +{ + public interface IMailsRepository + { + Task> GetMailsAsync(string status, CancellationToken cancellationToken = default); + + Task UpdateMailSent(string code, CancellationToken cancellationToken = default); + } +} diff --git a/backend/core/src/Core.Domain/Repositories/ITransactionRepository.cs b/backend/core/src/Core.Domain/Repositories/ITransactionRepository.cs index 5c8ae1d9..63a4f9b3 100644 --- a/backend/core/src/Core.Domain/Repositories/ITransactionRepository.cs +++ b/backend/core/src/Core.Domain/Repositories/ITransactionRepository.cs @@ -15,6 +15,8 @@ public interface ITransactionRepository public Task> GetAsync(string publicKey, int page, int pageSize, CancellationToken cancellationToken = default); + public Task> GetByCodeAsync(string code, CancellationToken cancellationToken = default); + public Task GetWithdrawFeesAsync(Withdraw withdraw, CancellationToken cancellationToken = default); } } diff --git a/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/MailTemplate.cs b/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/MailTemplate.cs new file mode 100644 index 00000000..3b831014 --- /dev/null +++ b/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/MailTemplate.cs @@ -0,0 +1,35 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Newtonsoft.Json; + +namespace Core.Infrastructure.Compliance.SendGridMailService +{ + public class MailTemplate + { + [JsonProperty("customerFullName")] + public string? CustomerFullName { get; set; } + + [JsonProperty("amount")] + public string? Amount { get; set; } + + [JsonProperty("accountCode")] + public string? AccountCode { get; set; } + + [JsonProperty("customerBankAccount")] + public string? BankAccount { get; set; } + + [JsonProperty("transactionCode")] + public string? TransactionCode { get; set; } + + [JsonProperty("payoutAmount")] + public string? PayoutAmount { get; set; } + + [JsonProperty("createdDate")] + public string? CreatedDate { get; set; } + + [JsonProperty("finishedDate")] + public string? FinishedDate { get; set; } + } +} diff --git a/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/SendGridMailService.cs b/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/SendGridMailService.cs new file mode 100644 index 00000000..525bc463 --- /dev/null +++ b/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/SendGridMailService.cs @@ -0,0 +1,83 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Core.Domain; +using Core.Domain.Abstractions; +using Core.Domain.Entities.CustomerAggregate; +using Core.Domain.Entities.MailAggregate; +using Core.Domain.Entities.TransactionAggregate; +using Core.Domain.Exceptions; +using Core.Infrastructure.Nexus; +using SendGrid; +using SendGrid.Helpers.Mail; +using System.Net; + +namespace Core.Infrastructure.Compliance.SendGridMailService +{ + public class SendGridMailService : ISendGridMailService + { + private readonly SendGridClient _sendGridClient; + private readonly SendGridMailServiceOptions _mailOptions; + + public SendGridMailService( + SendGridMailServiceOptions mailOptions) + { + _mailOptions = mailOptions; + _sendGridClient = new SendGridClient(_mailOptions.ApiKey); + } + + public async Task SendMailAsync(Mail mail, Customer customer, Transaction transaction) + { + if (mail == null) + { + throw new CustomErrorsException("MailService", "mail", "An error occured while sending mail."); + } + + var from = new EmailAddress(_mailOptions.Sender); + var to = new EmailAddress(mail.Recipient?.Email) ?? throw new CustomErrorsException("MailService", "toAddress", "An error occured while sending mail."); + + var msg = new SendGridMessage(); + + msg.SetFrom(new EmailAddress(from.Email, from.Name)); + msg.AddTo(new EmailAddress(to.Email, to.Name)); + + // Payout + if (mail.Type == MailType.TransactionSellFinish.ToString()) + { + msg.SetTemplateId(_mailOptions.Templates.WithdrawalTemplateID); + } + else if (mail.Type == MailType.TransactionBuyFinish.ToString()) + { + msg.SetTemplateId(_mailOptions.Templates.FundingtemplateID); + } + + // Fill in the dynamic template fields + var templateData = new MailTemplate() + { + CustomerFullName = customer?.GetName(), + AccountCode = mail.References?.AccountCode, + TransactionCode = mail.References?.TokenPaymentCode, + BankAccount = customer?.BankAccount, + Amount = transaction.Amount.ToString(), + CreatedDate = DateTimeProvider.FormatDateTimeWithoutMilliseconds(transaction.Created), + FinishedDate = DateTimeProvider.FormatDateTimeWithoutMilliseconds(transaction.Finished) + }; + + if (mail.Type == MailType.TransactionSellFinish.ToString()) + { + //TODO: set payout amount when transaction details in nexus api would also return the net fiat amount + //templateData.PayoutAmount = transaction.NetFiatAmount.ToString() + } + + msg.SetTemplateData(templateData); + + var response = await _sendGridClient.SendEmailAsync(msg); + + if (response.StatusCode != HttpStatusCode.Accepted) + { + throw new CustomErrorsException("MailService", "mail", "An error occured while sending mail."); + } + } + } +} diff --git a/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/SendGridMailServiceOptions.cs b/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/SendGridMailServiceOptions.cs new file mode 100644 index 00000000..6e245732 --- /dev/null +++ b/backend/core/src/Core.Infrastructure/Compliance/SendGridMailService/SendGridMailServiceOptions.cs @@ -0,0 +1,29 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using System.ComponentModel.DataAnnotations; + +namespace Core.Infrastructure.Compliance.SendGridMailService +{ + public class SendGridMailServiceOptions + { + [Required] + public required string ApiKey { get; set; } + + [Required] + public required Templates Templates { get; set; } + + [Required] + public required string Sender { get; set; } + } + + public class Templates + { + [Required] + public required string WithdrawalTemplateID { get; set; } + + [Required] + public required string FundingtemplateID { get; set; } + } +} diff --git a/backend/core/src/Core.Infrastructure/Core.Infrastructure.csproj b/backend/core/src/Core.Infrastructure/Core.Infrastructure.csproj index 802e0a6f..844c30f4 100644 --- a/backend/core/src/Core.Infrastructure/Core.Infrastructure.csproj +++ b/backend/core/src/Core.Infrastructure/Core.Infrastructure.csproj @@ -9,8 +9,9 @@ - + + diff --git a/backend/core/src/Core.Infrastructure/Jobs/ProcessEmailsJob.cs b/backend/core/src/Core.Infrastructure/Jobs/ProcessEmailsJob.cs new file mode 100644 index 00000000..a906d2ae --- /dev/null +++ b/backend/core/src/Core.Infrastructure/Jobs/ProcessEmailsJob.cs @@ -0,0 +1,88 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Core.Domain.Abstractions; +using Core.Domain.Entities.TransactionAggregate; +using Core.Domain.Exceptions; +using Core.Domain.Repositories; +using Core.Infrastructure.Nexus; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace Core.Infrastructure.Jobs +{ + public class ProcessEmailsJob : IJob + { + private readonly ILogger _logger; + private readonly IMailsRepository _mailsRepository; + private readonly ICustomerRepository _customerRepository; + private readonly ITransactionRepository _transactionRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ISendGridMailService _sendGridMailService; + + public ProcessEmailsJob( + ILogger logger, + IMailsRepository mailsRepository, + ICustomerRepository customerRepository, + ITransactionRepository transactionRepository, + ISendGridMailService sendGridMailService, + IUnitOfWork unitOfWork) + { + _logger = logger; + _mailsRepository = mailsRepository; + _customerRepository = customerRepository; + _transactionRepository = transactionRepository; + _sendGridMailService = sendGridMailService; + _unitOfWork = unitOfWork; + } + + public async Task Execute(IJobExecutionContext context) + { + var mails = _mailsRepository.GetMailsAsync(MailStatus.ReadyToSend.ToString(), context.CancellationToken).Result; + + if (mails != null && mails.Any()) + { + foreach (var mail in mails) + { + var customerCode = mail.References?.CustomerCode; + + if (string.IsNullOrWhiteSpace(customerCode)) + { + throw new CustomErrorsException("MailService", "customerCode", "An error occured while sending mail."); + } + + var customer = await _customerRepository.GetAsync(customerCode, context.CancellationToken); + + if (mail.References == null || string.IsNullOrWhiteSpace(mail.References.TokenPaymentCode)) + { + throw new CustomErrorsException("MailService", "TokenPaymentCode", "An error occured while sending mail."); + } + + var transactions = await _transactionRepository.GetByCodeAsync(mail.References.TokenPaymentCode, context.CancellationToken); + + Transaction? transaction = null; + if (transactions != null && transactions.Items.Any()) + { + transaction = transactions.Items.FirstOrDefault(); + } + + try + { + await _sendGridMailService.SendMailAsync(mail, customer, transaction!); + } + catch (Exception ex) + { + _logger.LogError("An error occured sending email {code} with message {message}", mail.Code, ex.Message); + } + + // once email has been sent, call nexus to update the status of this mail to 'Sent' + await _mailsRepository.UpdateMailSent(mail.Code); + } + } + + await _unitOfWork.SaveChangesAsync(); + } + } +} + diff --git a/backend/core/src/Core.Infrastructure/Nexus/Enums.cs b/backend/core/src/Core.Infrastructure/Nexus/Enums.cs index 463e2690..0787ebf8 100644 --- a/backend/core/src/Core.Infrastructure/Nexus/Enums.cs +++ b/backend/core/src/Core.Infrastructure/Nexus/Enums.cs @@ -26,4 +26,15 @@ public enum NexusErrorCodes InvalidProperty = 5, TransactionNotFoundError = 6 } + + public enum MailStatus + { + ReadyToSend = 1 + } + + public enum MailType + { + TransactionSellFinish = 1, + TransactionBuyFinish = 2 + } } diff --git a/backend/core/src/Core.Infrastructure/Nexus/Repositories/NexusMailsRepository.cs b/backend/core/src/Core.Infrastructure/Nexus/Repositories/NexusMailsRepository.cs new file mode 100644 index 00000000..f81ccea5 --- /dev/null +++ b/backend/core/src/Core.Infrastructure/Nexus/Repositories/NexusMailsRepository.cs @@ -0,0 +1,85 @@ +// Copyright 2023 Quantoz Technology B.V. and contributors. Licensed +// under the Apache License, Version 2.0. See the NOTICE file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +using Core.Domain.Entities.MailAggregate; +using Core.Domain.Repositories; +using Nexus.Sdk.Shared.Responses; +using Nexus.Sdk.Token; + +namespace Core.Infrastructure.Nexus.Repositories +{ + public class NexusMailsRepository : IMailsRepository + { + private readonly ITokenServer _tokenServer; + + public NexusMailsRepository(ITokenServer tokenServer) + { + _tokenServer = tokenServer; + } + + public async Task> GetMailsAsync(string status, CancellationToken cancellationToken = default) + { + var query = new Dictionary() + { + { + "status", status + } + }; + + var response = await _tokenServer.Compliance.Mails.Get(query); + + var mails = response.Records; + + var items = new List(); + + foreach (var mail in mails) + { + var item = ConvertToMailAsync(mail, cancellationToken); + items.Add(item); + } + + return items; + } + + public async Task UpdateMailSent(string code, CancellationToken cancellationToken = default) + { + var response = await _tokenServer.Compliance.Mails.UpdateMailSent(code); + + return ConvertToMailAsync(response, cancellationToken); + } + + private static Mail ConvertToMailAsync(MailsResponse mail, CancellationToken cancellationToken = default) + { + var response = new Mail + { + Code = mail.Code, + Type = mail.Type, + Status = mail.Status, + Content = new Domain.Entities.MailAggregate.MailContent + { + Html = mail.Content?.Html, + Subject = mail.Content?.Subject, + Text = mail.Content?.Text + }, + Count = mail.Count, + Created = mail.Created, + Recipient = new Domain.Entities.MailAggregate.MailRecipient + { + BCC = mail.Recipient?.BCC, + CC = mail.Recipient?.CC, + Email = mail.Recipient!.Email + }, + References = new Domain.Entities.MailAggregate.MailEntityCodes + { + AccountCode = mail.References?.AccountCode, + CustomerCode = mail.References?.CustomerCode, + TokenPaymentCode = mail.References?.TokenPaymentCode + }, + Sent = mail.Sent + }; + + return response; + } + } +} diff --git a/backend/core/src/Core.Infrastructure/Nexus/Repositories/NexusTransactionRepository.cs b/backend/core/src/Core.Infrastructure/Nexus/Repositories/NexusTransactionRepository.cs index c594a243..0f3aa7b7 100644 --- a/backend/core/src/Core.Infrastructure/Nexus/Repositories/NexusTransactionRepository.cs +++ b/backend/core/src/Core.Infrastructure/Nexus/Repositories/NexusTransactionRepository.cs @@ -109,6 +109,32 @@ public async Task> GetAsync(string publicKey, int page, int p }; } + public async Task> GetByCodeAsync(string code, CancellationToken cancellationToken = default) + { + int page = 1; // default value + int pageSize = 10; // default value + + var response = await _tokenServer.Operations.Get(code); + + var operations = response.Records; + + var items = new List(); + + foreach (var operation in operations) + { + var item = await ConvertAsync(operation, cancellationToken); + items.Add(item); + } + + return new Paged + { + Items = items, + Page = page, + PageSize = pageSize, + Total = response.Total + }; + } + #region private methods private async Task CreateAlgorandPayment(Payment payment, string? ip = null) @@ -249,6 +275,36 @@ private async Task ConvertToTransactionAsync(string publicKey, Toke return transaction; } + + private async Task ConvertAsync(TokenOperationResponse operation, CancellationToken cancellationToken = default) + { + var transaction = new Transaction + { + Amount = operation.Amount, + FromAccountCode = operation.SenderAccount?.AccountCode, + ToAccountCode = operation.ReceiverAccount?.AccountCode, + Created = DateTimeOffset.Parse(operation.Created), + Finished = operation.Finished != null ? DateTimeOffset.Parse(operation.Finished) : null, + Status = operation.Status, + TokenCode = operation.TokenCode, + TransactionCode = operation.Code, + Memo = operation.Memo, + Type = operation.Type + }; + + if (transaction.Type == "Payment") + { + var hasTransaction = await _paymentRepository.HasTransactionAsync(transaction.TransactionCode, cancellationToken); + + if (hasTransaction) + { + transaction.Payment = await _paymentRepository.GetByTransactionCodeAsync(transaction.TransactionCode, cancellationToken); + } + } + + return transaction; + } + #endregion } } diff --git a/backend/core/src/Core.Presentation/Models/Responses/TransactionResponse.cs b/backend/core/src/Core.Presentation/Models/Responses/TransactionResponse.cs index cea3cadd..de8cd813 100644 --- a/backend/core/src/Core.Presentation/Models/Responses/TransactionResponse.cs +++ b/backend/core/src/Core.Presentation/Models/Responses/TransactionResponse.cs @@ -25,6 +25,8 @@ public class TransactionResponse public long Created { get; set; } + public long? Finished { get; set; } + public required string Status { get; set; } public required string Type { get; set; } @@ -41,6 +43,7 @@ public static TransactionResponse FromTransaction(Transaction transaction) ToAccountCode = transaction.ToAccountCode, Amount = transaction.Amount, Created = DateTimeProvider.ToUnixTimeInMilliseconds(transaction.Created), + Finished = DateTimeProvider.ToUnixTimeInMilliseconds(transaction.Finished), Direction = transaction.Direction, Status = transaction.Status, TokenCode = transaction.TokenCode,