diff --git a/src/Application/DTOs/Account/AccountCsvModel.cs b/src/Application/DTOs/Account/AccountCsvModel.cs index e1a09ca..3c1c01b 100644 --- a/src/Application/DTOs/Account/AccountCsvModel.cs +++ b/src/Application/DTOs/Account/AccountCsvModel.cs @@ -1,15 +1,27 @@ -namespace Application.DTOs.Account; +using CsvHelper.Configuration.Attributes; + +namespace Application.DTOs.Account; public class AccountCsvModel { - public long AccountID { get; set; } - public long CardID { get; set; } - public string IBAN { get; set; } = string.Empty; + [Name("AccountId", "AccountID")] + public long AccountId { get; set; } + [Name("CardId", "CardID")] + public long CardId { get; set; } + [Name("Iban", "IBAN", "Sheba")] + public string Iban { get; set; } = string.Empty; + [Name("AccountType")] public string AccountType { get; set; } = string.Empty; + [Name("BranchTelephone")] public string BranchTelephone { get; set; } = string.Empty; - public string BranchAdress { get; set; } = string.Empty; + [Name("BranchAddress", "BranchAdress")] + public string BranchAddress { get; set; } = string.Empty; + [Name("BranchName")] public string BranchName { get; set; } = string.Empty; + [Name("OwnerName")] public string OwnerName { get; set; } = string.Empty; + [Name("OwnerLastName", "OwnerFamilyName")] public string OwnerLastName { get; set; } = string.Empty; - public long OwnerID { get; set; } + [Name("OwnerId", "OwnerID")] + public long OwnerId { get; set; } } \ No newline at end of file diff --git a/src/Application/DTOs/Result.cs b/src/Application/DTOs/Result.cs index e2ed9d1..da726f8 100644 --- a/src/Application/DTOs/Result.cs +++ b/src/Application/DTOs/Result.cs @@ -5,11 +5,12 @@ public class Result public string Message { get; protected set; } = string.Empty; public bool Succeed { get; protected set; } - public static Result Ok() + public static Result Ok(string message = "succeed") { return new Result { - Succeed = true + Succeed = true, + Message = message }; } diff --git a/src/Application/DTOs/Transaction/TransactionCsvModel.cs b/src/Application/DTOs/Transaction/TransactionCsvModel.cs index a5b2596..9ff9f85 100644 --- a/src/Application/DTOs/Transaction/TransactionCsvModel.cs +++ b/src/Application/DTOs/Transaction/TransactionCsvModel.cs @@ -5,11 +5,20 @@ namespace Application.DTOs.Transaction; public class TransactionCsvModel { - public long TransactionID { get; set; } - public long SourceAcount { get; set; } - public long DestiantionAccount { get; set; } + [Name("TransactionId", "TransactionID")] + public long TransactionId { get; set; } + [Name("SourceAccount", "SourceAcount")] + public long SourceAccount { get; set; } + [Name("DestinationAccount","DestiantionAccount")] + public long DestinationAccount { get; set; } + [Name("Amount")] public decimal Amount { get; set; } - [TypeConverter(typeof(PersianDateConverter))] - public DateTime Date { get; set; } + [Name("Date")] + public string Date { get; set; } = string.Empty; + [Name("Time")] + public string Time { get; set; } = string.Empty; + [Name("Type")] public string Type { get; set; } = string.Empty; + [Name("TrackingId", "TrackingID")] + public long TrackingId { get; set; } } \ No newline at end of file diff --git a/src/Application/Mappers/AccountMapper.cs b/src/Application/Mappers/AccountMapper.cs index 9ca9d4c..ceef8a3 100644 --- a/src/Application/Mappers/AccountMapper.cs +++ b/src/Application/Mappers/AccountMapper.cs @@ -9,16 +9,16 @@ public static Account ToAccount(this AccountCsvModel csvModel) { return new Account { - AccountId = csvModel.AccountID, - CardId = csvModel.CardID, - Iban = csvModel.IBAN, + AccountId = csvModel.AccountId, + CardId = csvModel.CardId, + Iban = csvModel.Iban, AccountType = csvModel.AccountType, BranchTelephone = csvModel.BranchTelephone, - BranchAddress = csvModel.BranchAdress, + BranchAddress = csvModel.BranchAddress, BranchName = csvModel.BranchName, OwnerName = csvModel.OwnerName, OwnerLastName = csvModel.OwnerLastName, - OwnerId = csvModel.OwnerID + OwnerId = csvModel.OwnerId }; } } \ No newline at end of file diff --git a/src/Application/Mappers/TransactionMapper.cs b/src/Application/Mappers/TransactionMapper.cs index 693e954..79bf50e 100644 --- a/src/Application/Mappers/TransactionMapper.cs +++ b/src/Application/Mappers/TransactionMapper.cs @@ -6,14 +6,17 @@ public static class TransactionMapper { public static Transaction ToTransaction(this TransactionCsvModel csvModel) { + var date = DateOnly.Parse(csvModel.Date).ToDateTime(TimeOnly.Parse(csvModel.Time)); + var utcDate = DateTime.SpecifyKind(date, DateTimeKind.Utc); return new Transaction { - TransactionId = csvModel.TransactionID, - SourceAccountId = csvModel.SourceAcount, - DestinationAccountId = csvModel.DestiantionAccount, + TransactionId = csvModel.TransactionId, + SourceAccountId = csvModel.SourceAccount, + DestinationAccountId = csvModel.DestinationAccount, Amount = csvModel.Amount, - Date = csvModel.Date, - Type = csvModel.Type + Date = utcDate, + Type = csvModel.Type, + TrackingId = csvModel.TrackingId }; } } \ No newline at end of file diff --git a/src/Application/Services/DomainService/TransactionService.cs b/src/Application/Services/DomainService/TransactionService.cs index a3c5a76..17f31cd 100644 --- a/src/Application/Services/DomainService/TransactionService.cs +++ b/src/Application/Services/DomainService/TransactionService.cs @@ -13,19 +13,40 @@ public class TransactionService : ITransactionService { private readonly ITransactionRepository _transactionRepository; private readonly IFileReaderService _fileReaderService; + private readonly IAccountRepository _accountRepository; - public TransactionService(ITransactionRepository transactionRepository, IFileReaderService fileReaderService) + public TransactionService(ITransactionRepository transactionRepository, IFileReaderService fileReaderService, IAccountRepository accountRepository) { _transactionRepository = transactionRepository; _fileReaderService = fileReaderService; + _accountRepository = accountRepository; } - + + private async Task> ValidateTransactionCsvModelsAsync(List transactionCsvModels) + { + var invalidTransactionIds = new List(); + foreach (var transactionCsvModel in transactionCsvModels) + { + var sourceAccount = await _accountRepository.GetByIdAsync(transactionCsvModel.SourceAccount); + var destinationAccount = await _accountRepository.GetByIdAsync(transactionCsvModel.DestinationAccount); + bool isValidDate = DateOnly.TryParseExact(transactionCsvModel.Date, "MM/dd/yyyy", null, System.Globalization.DateTimeStyles.None, out var date); + bool isValidTime = TimeOnly.TryParse(transactionCsvModel.Time, out var time); + if(sourceAccount == null || destinationAccount == null || !isValidDate || !isValidTime) + { + invalidTransactionIds.Add(transactionCsvModel.TransactionId); + } + } + return invalidTransactionIds; + } + public async Task AddTransactionsFromCsvAsync(string filePath) { try { var transactionCsvModels = _fileReaderService.ReadFromFile(filePath); + var invalidTransactionCsvModels = await ValidateTransactionCsvModelsAsync(transactionCsvModels); var transactions = transactionCsvModels + .Where(csvModel => !invalidTransactionCsvModels.Contains(csvModel.TransactionId)) .Select(csvModel => csvModel.ToTransaction()) .ToList(); @@ -33,7 +54,9 @@ public async Task AddTransactionsFromCsvAsync(string filePath) var newTransactions = transactions.Where(t => !existingTransactionsIds.Contains(t.TransactionId)).ToList(); await _transactionRepository.CreateBulkAsync(newTransactions); - return Result.Ok(); + return Result.Ok(invalidTransactionCsvModels.Count == 0 + ? "All transactions were added successfully." + : $"Some transactions were not added because of invalid data: {string.Join(", ", invalidTransactionCsvModels)}"); } catch (Exception ex) { @@ -77,12 +100,14 @@ public async Task>> GetTransacti AccountId = group.Key, TransactionWithSources = group.Select(t => new TransactionCsvModel { - TransactionID = t.TransactionId, - SourceAcount = t.SourceAccountId, - DestiantionAccount = t.DestinationAccountId, + TransactionId = t.TransactionId, + SourceAccount = t.SourceAccountId, + DestinationAccount = t.DestinationAccountId, Amount = t.Amount, - Date = t.Date, + Date = DateOnly.FromDateTime(t.Date).ToString(), + Time = TimeOnly.FromDateTime(t.Date).ToString(), Type = t.Type, + TrackingId = t.TrackingId }).ToList() }; diff --git a/src/Application/Services/SharedService/PersianDateConverter.cs b/src/Application/Services/SharedService/PersianDateConverter.cs deleted file mode 100644 index efd626f..0000000 --- a/src/Application/Services/SharedService/PersianDateConverter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Globalization; -using CsvHelper; -using CsvHelper.Configuration; -using CsvHelper.TypeConversion; - -namespace Application.Services.SharedService; - -public class PersianDateConverter : DateTimeConverter -{ - public override object ConvertFromString(string? text, IReaderRow row, MemberMapData memberMapData) - { - if (string.IsNullOrWhiteSpace(text)) - { - return DateTime.MinValue; - } - - try - { - var persianCalendar = new PersianCalendar(); - var parts = text.Split('/'); - - var year = int.Parse(parts[0]); - var month = int.Parse(parts[1]); - var day = int.Parse(parts[2]); - - var gregorianDate = persianCalendar.ToDateTime(year, month, day, 0, 0, 0, 0); - return DateTime.SpecifyKind(gregorianDate, DateTimeKind.Utc); - } - catch (Exception ex) - { - throw new TypeConverterException(this, memberMapData, text, row.Context, ex.Message); - } - } -} \ No newline at end of file diff --git a/src/Domain/Entities/Transaction.cs b/src/Domain/Entities/Transaction.cs index c0f9c3f..5b34ccb 100644 --- a/src/Domain/Entities/Transaction.cs +++ b/src/Domain/Entities/Transaction.cs @@ -15,4 +15,5 @@ public class Transaction public DateTime Date { get; set; } [MaxLength(50)] public string Type { get; set; } = String.Empty; + public long TrackingId { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure/Migrations/20240819152618_accounts-transactions.Designer.cs b/src/Infrastructure/Migrations/20240907102333_update-transaction-entity.Designer.cs similarity index 97% rename from src/Infrastructure/Migrations/20240819152618_accounts-transactions.Designer.cs rename to src/Infrastructure/Migrations/20240907102333_update-transaction-entity.Designer.cs index aac0f18..429c292 100644 --- a/src/Infrastructure/Migrations/20240819152618_accounts-transactions.Designer.cs +++ b/src/Infrastructure/Migrations/20240907102333_update-transaction-entity.Designer.cs @@ -12,8 +12,8 @@ namespace Infrastructure.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20240819152618_accounts-transactions")] - partial class accountstransactions + [Migration("20240907102333_update-transaction-entity")] + partial class updatetransactionentity { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -173,6 +173,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("SourceAccountId") .HasColumnType("bigint"); + b.Property("TrackingId") + .HasColumnType("bigint"); + b.Property("Type") .IsRequired() .HasMaxLength(50) @@ -215,19 +218,19 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasData( new { - Id = "5a15c66d-6214-4dbb-a00d-10a4f7ca4cf8", + Id = "8a0429eb-6031-4546-a06e-0a0f5ae9248c", Name = "Admin", NormalizedName = "ADMIN" }, new { - Id = "46a9f2ed-8738-448a-9ca9-3afa00eee4ff", + Id = "b70df7e0-cb8a-4131-bf26-3d2e52ee9834", Name = "DataAdmin", NormalizedName = "DATAADMIN" }, new { - Id = "48732a60-c9aa-4aec-9ef3-b880ab162088", + Id = "31f9f360-a3de-4dc3-bd7d-8f16d8da7fed", Name = "DataAnalyst", NormalizedName = "DATAANALYST" }); diff --git a/src/Infrastructure/Migrations/20240819152618_accounts-transactions.cs b/src/Infrastructure/Migrations/20240907102333_update-transaction-entity.cs similarity index 97% rename from src/Infrastructure/Migrations/20240819152618_accounts-transactions.cs rename to src/Infrastructure/Migrations/20240907102333_update-transaction-entity.cs index 90fe19d..939c37f 100644 --- a/src/Infrastructure/Migrations/20240819152618_accounts-transactions.cs +++ b/src/Infrastructure/Migrations/20240907102333_update-transaction-entity.cs @@ -9,7 +9,7 @@ namespace Infrastructure.Migrations { /// - public partial class accountstransactions : Migration + public partial class updatetransactionentity : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -86,7 +86,8 @@ protected override void Up(MigrationBuilder migrationBuilder) DestinationAccountId = table.Column(type: "bigint", nullable: false), Amount = table.Column(type: "numeric", nullable: false), Date = table.Column(type: "timestamp with time zone", nullable: false), - Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + TrackingId = table.Column(type: "bigint", nullable: false) }, constraints: table => { @@ -216,9 +217,9 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "Id", "ConcurrencyStamp", "Name", "NormalizedName" }, values: new object[,] { - { "46a9f2ed-8738-448a-9ca9-3afa00eee4ff", null, "DataAdmin", "DATAADMIN" }, - { "48732a60-c9aa-4aec-9ef3-b880ab162088", null, "DataAnalyst", "DATAANALYST" }, - { "5a15c66d-6214-4dbb-a00d-10a4f7ca4cf8", null, "Admin", "ADMIN" } + { "31f9f360-a3de-4dc3-bd7d-8f16d8da7fed", null, "DataAnalyst", "DATAANALYST" }, + { "8a0429eb-6031-4546-a06e-0a0f5ae9248c", null, "Admin", "ADMIN" }, + { "b70df7e0-cb8a-4131-bf26-3d2e52ee9834", null, "DataAdmin", "DATAADMIN" } }); migrationBuilder.CreateIndex( diff --git a/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index d2c3b40..6f3faaf 100644 --- a/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -170,6 +170,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SourceAccountId") .HasColumnType("bigint"); + b.Property("TrackingId") + .HasColumnType("bigint"); + b.Property("Type") .IsRequired() .HasMaxLength(50) @@ -212,19 +215,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasData( new { - Id = "5a15c66d-6214-4dbb-a00d-10a4f7ca4cf8", + Id = "8a0429eb-6031-4546-a06e-0a0f5ae9248c", Name = "Admin", NormalizedName = "ADMIN" }, new { - Id = "46a9f2ed-8738-448a-9ca9-3afa00eee4ff", + Id = "b70df7e0-cb8a-4131-bf26-3d2e52ee9834", Name = "DataAdmin", NormalizedName = "DATAADMIN" }, new { - Id = "48732a60-c9aa-4aec-9ef3-b880ab162088", + Id = "31f9f360-a3de-4dc3-bd7d-8f16d8da7fed", Name = "DataAnalyst", NormalizedName = "DATAANALYST" }); diff --git a/src/Web/Controllers/TransactionsController.cs b/src/Web/Controllers/TransactionsController.cs index 3efb8d1..ee13799 100644 --- a/src/Web/Controllers/TransactionsController.cs +++ b/src/Web/Controllers/TransactionsController.cs @@ -46,10 +46,10 @@ public async Task UploadTransactions([FromForm] IFormFile file) return BadRequest(errorResponse); } - return Ok("Transactions uploaded successfully!"); + return Ok(result.Message); } - [HttpGet()] + [HttpGet] [Authorize] [RequiresAnyRole(Claims.Role, AppRoles.Admin, AppRoles.DataAdmin, AppRoles.DataAnalyst)] [ProducesResponseType(200)] diff --git a/src/Web/DTOs/Transaction/TransactionDto.cs b/src/Web/DTOs/Transaction/TransactionDto.cs index b75add9..236d52f 100644 --- a/src/Web/DTOs/Transaction/TransactionDto.cs +++ b/src/Web/DTOs/Transaction/TransactionDto.cs @@ -8,4 +8,5 @@ public class TransactionDto public decimal Amount { get; set; } public DateTime Date { get; set; } public string Type { get; set; } = String.Empty; + public long TrackingId { get; set; } } \ No newline at end of file diff --git a/src/Web/Mappers/TransactionMapper.cs b/src/Web/Mappers/TransactionMapper.cs index a84c72a..8d53871 100644 --- a/src/Web/Mappers/TransactionMapper.cs +++ b/src/Web/Mappers/TransactionMapper.cs @@ -27,7 +27,8 @@ public static List ToGotAllTransactionsDto(this List(); _fileReaderService = Substitute.For(); - _transactionService = new TransactionService(_transactionRepository, _fileReaderService); + _accountRepository = Substitute.For(); + _transactionService = new TransactionService(_transactionRepository, _fileReaderService, _accountRepository); } [Fact] @@ -30,19 +29,21 @@ public async Task AddTransactionsFromCsvAsync_ShouldReturnOk_WhenTransactionsAre var filePath = "test.csv"; var transactionCsvModels = new List { - new() { TransactionID = 1, SourceAcount = 101, DestiantionAccount = 102, Amount = 100, Date = new DateTime(1399, 04, 15, new PersianCalendar()), Type = "کارت به کارت" }, - new() { TransactionID = 2, SourceAcount = 101, DestiantionAccount = 103, Amount = 200, Date = new DateTime(1399, 04, 30, new PersianCalendar()), Type = "ساتنا" } + new() { TransactionId = 1, SourceAccount = 101, DestinationAccount = 102, Amount = 100, Date = "05/24/2018", Time = "6:42:05", Type = "کارت به کارت", TrackingId = 12102}, + new() { TransactionId = 2, SourceAccount = 101, DestinationAccount = 103, Amount = 200, Date = "04/04/2017", Time = "4:12:08", Type = "ساتنا", TrackingId = 12103} }; - // Expect these dates to be converted to Gregorian: - var expectedFirstDate = new DateTime(2020, 7, 5); // 1399/04/15 in Gregorian Calendar - var expectedSecondDate = new DateTime(2020, 7, 20); // 1399/04/30 in Gregorian Calendar - - var transactions = transactionCsvModels.Select(csvModel => csvModel.ToTransaction()).ToList(); + + var expectedFirstDateTime = new DateTime(2018, 5, 24, 6, 42, 5); + var expectedSecondDateTime = new DateTime(2017, 4, 4, 4, 12, 8); + var existingTransactionIds = new List { 3 }; _fileReaderService.ReadFromFile(filePath).Returns(transactionCsvModels); _transactionRepository.GetAllIdsAsync().Returns(existingTransactionIds); _transactionRepository.CreateBulkAsync(Arg.Any>()).Returns(Task.CompletedTask); + _accountRepository.GetByIdAsync(101).Returns(new Account()); + _accountRepository.GetByIdAsync(102).Returns(new Account()); + _accountRepository.GetByIdAsync(103).Returns(new Account()); // Act var result = await _transactionService.AddTransactionsFromCsvAsync(filePath); @@ -53,8 +54,8 @@ public async Task AddTransactionsFromCsvAsync_ShouldReturnOk_WhenTransactionsAre // Verifying if the dates were converted correctly await _transactionRepository.Received(1).CreateBulkAsync(Arg.Is>(x => x.Count == transactionCsvModels.Count && - x[0].Date == expectedFirstDate && // Ensure the first date was converted correctly - x[1].Date == expectedSecondDate // Ensure the second date was converted correctly + x[0].Date == expectedFirstDateTime && + x[1].Date == expectedSecondDateTime )); } @@ -65,19 +66,24 @@ public async Task AddTransactionsFromCsvAsync_ShouldOnlyAddNewTransactions_WhenS var filePath = "test.csv"; var transactionCsvModels = new List { - new() { TransactionID = 1, SourceAcount = 101, DestiantionAccount = 102, Amount = 100, Date = new DateTime(1399, 04, 29, new PersianCalendar()), Type = "کارت به کارت" }, - new() { TransactionID = 2, SourceAcount = 101, DestiantionAccount = 103, Amount = 200, Date = new DateTime(1399, 04, 15, new PersianCalendar()), Type = "ساتنا" }, - new() { TransactionID = 3, SourceAcount = 104, DestiantionAccount = 105, Amount = 300, Date = new DateTime(1399, 04, 17, new PersianCalendar()), Type = "ساتنا"} + new() { TransactionId = 1, SourceAccount = 101, DestinationAccount = 102, Amount = 100, Date = "05/24/2018", Time = "10:00:00", Type = "کارت به کارت", TrackingId = 12101 }, + new() { TransactionId = 2, SourceAccount = 101, DestinationAccount = 103, Amount = 200, Date = "05/24/2018", Time = "11:00:00", Type = "ساتنا", TrackingId = 12102 }, + new() { TransactionId = 3, SourceAccount = 104, DestinationAccount = 105, Amount = 300, Date = "05/24/2018", Time = "12:00:00", Type = "ساتنا", TrackingId = 12103 } }; - - var transactions = transactionCsvModels.Select(csvModel => csvModel.ToTransaction()).ToList(); - - // Let's say TransactionID = 3 already exists in the database. + var existingTransactionIds = new List { 3 }; + + var expectedFirstDateTime = new DateTime(2018, 5, 24, 10, 0, 0); + var expectedSecondDateTime = new DateTime(2018, 5, 24, 11, 0, 0); _fileReaderService.ReadFromFile(filePath).Returns(transactionCsvModels); _transactionRepository.GetAllIdsAsync().Returns(existingTransactionIds); _transactionRepository.CreateBulkAsync(Arg.Any>()).Returns(Task.CompletedTask); + _accountRepository.GetByIdAsync(101).Returns(new Account()); + _accountRepository.GetByIdAsync(102).Returns(new Account()); + _accountRepository.GetByIdAsync(103).Returns(new Account()); + _accountRepository.GetByIdAsync(104).Returns(new Account()); + _accountRepository.GetByIdAsync(105).Returns(new Account()); // Act var result = await _transactionService.AddTransactionsFromCsvAsync(filePath); @@ -88,13 +94,12 @@ public async Task AddTransactionsFromCsvAsync_ShouldOnlyAddNewTransactions_WhenS // Only the new transactions (TransactionID = 1 and TransactionID = 2) should be added. await _transactionRepository.Received(1).CreateBulkAsync(Arg.Is>(x => x.Count == 2 && - x.Any(t => t.TransactionId == 1) && - x.Any(t => t.TransactionId == 2) && + x.Any(t => t.TransactionId == 1 && t.Date == expectedFirstDateTime) && + x.Any(t => t.TransactionId == 2 && t.Date == expectedSecondDateTime) && x.All(t => t.TransactionId != 3) )); } - [Fact] public async Task AddTransactionsFromCsvAsync_ShouldReturnFail_WhenExceptionIsThrown() { @@ -125,6 +130,7 @@ public async Task GetAllTransactionsAsync_ShouldReturnAllTransactions() }; _transactionRepository.GetAllTransactions().Returns(transactions); + // Act var result = await _transactionService.GetAllTransactionsAsync();