From 856f48a6cc017329d8a8abd9bd908d17a90db5ea Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 12:39:43 +0200 Subject: [PATCH 01/12] config: move database connection string to single configuration source Centralizes the connection string behind DatabaseAccess.ConnectionString, which reads the EBankingDb entry from App.config via ConfigurationManager. Replaces five hardcoded copies (four repositories + the unused, mismatched default in DatabaseAccess.cs that referenced a different database name). Co-Authored-By: Claude Opus 4.7 --- EBanking.DataAccess/DatabaseAccess.cs | 20 +++++++++++++++++-- .../EBanking.DataAccess.csproj | 1 + .../Implementation/AccountRepository.cs | 12 +++++------ .../CurrencyExchangeRepository.cs | 4 +--- .../Implementation/TransactionRepository.cs | 6 ++---- .../Implementation/UserRepository.cs | 14 ++++++------- EBanking.UI/App.config | 8 ++++++++ 7 files changed, 41 insertions(+), 24 deletions(-) create mode 100644 EBanking.UI/App.config diff --git a/EBanking.DataAccess/DatabaseAccess.cs b/EBanking.DataAccess/DatabaseAccess.cs index 0ae0826..909c395 100644 --- a/EBanking.DataAccess/DatabaseAccess.cs +++ b/EBanking.DataAccess/DatabaseAccess.cs @@ -1,7 +1,23 @@ +using System.Configuration; + namespace EBanking.DataAccess { - public class DatabaseAccess + public static class DatabaseAccess { - private const string _connectionString = @"Data Source = .;Initial Catalog=eBanking;Integrated Security=True"; + private const string ConnectionStringName = "EBankingDb"; + + private static readonly Lazy _connectionString = new(() => + { + var configString = ConfigurationManager.ConnectionStrings[ConnectionStringName]?.ConnectionString; + if (string.IsNullOrWhiteSpace(configString)) + { + throw new InvalidOperationException( + $"Connection string '{ConnectionStringName}' was not found in configuration. " + + "Ensure App.config defines ."); + } + return configString; + }); + + public static string ConnectionString => _connectionString.Value; } } diff --git a/EBanking.DataAccess/EBanking.DataAccess.csproj b/EBanking.DataAccess/EBanking.DataAccess.csproj index ac029dc..90ea637 100644 --- a/EBanking.DataAccess/EBanking.DataAccess.csproj +++ b/EBanking.DataAccess/EBanking.DataAccess.csproj @@ -7,6 +7,7 @@ + diff --git a/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs b/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs index 34c996a..3d76963 100644 --- a/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs @@ -5,11 +5,9 @@ namespace EBanking.DataAccess.Repositories.Implementation { public class AccountRepository : IAccountRepository { - private const string _connectionString = @"Data Source = .\SQLEXPRESS;Initial Catalog=EBankingSystem;Integrated Security=True"; - public void CreateAccount(Account model) { - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -33,7 +31,7 @@ public async Task GetAccountByAccountNumber(string accountNumber) { Account account = new Account(); - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -64,7 +62,7 @@ public List GetAccountsByUserId(int userId) { List accounts = new List(); - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -105,7 +103,7 @@ public bool IsValidAccount(string accountNumber) { object accountId; - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -123,7 +121,7 @@ public bool IsValidAccount(string accountNumber) public void UpdateBalance(double balance, string accountNumber) { - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); diff --git a/EBanking.DataAccess/Repositories/Implementation/CurrencyExchangeRepository.cs b/EBanking.DataAccess/Repositories/Implementation/CurrencyExchangeRepository.cs index 962db0f..e3ddbf1 100644 --- a/EBanking.DataAccess/Repositories/Implementation/CurrencyExchangeRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/CurrencyExchangeRepository.cs @@ -5,13 +5,11 @@ namespace EBanking.DataAccess.Repositories.Implementation { public class CurrencyExchangeRepository : ICurrencyExchangeRepository { - private const string _connectionString = @"Data Source = .\SQLEXPRESS;Initial Catalog=EBankingSystem;Integrated Security=True"; - public List GetExchangeRatesByCurrency(string currency) { List exchangeRates = new List(); - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); diff --git a/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs b/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs index 43dd6da..25249af 100644 --- a/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs @@ -5,11 +5,9 @@ namespace EBanking.DataAccess.Repositories.Implementation { public class TransactionRepository : ITransactionRepository { - private const string _connectionString = @"Data Source = .\SQLEXPRESS;Initial Catalog=EBankingSystem;Integrated Security=True"; - public int CreateTransaction(Transaction transaction) { - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -34,7 +32,7 @@ public List GetTransactionsByAccountNumber(string accountNumber) { List transactionList = new List(); - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); diff --git a/EBanking.DataAccess/Repositories/Implementation/UserRepository.cs b/EBanking.DataAccess/Repositories/Implementation/UserRepository.cs index b88e098..7ef74e9 100644 --- a/EBanking.DataAccess/Repositories/Implementation/UserRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/UserRepository.cs @@ -5,13 +5,11 @@ namespace EBanking.DataAccess.Repositories.Implementation { public class UserRepository : IUserRepository { - private const string _connectionString = @"Data Source = .\SQLEXPRESS;Initial Catalog=EBankingSystem;Integrated Security=True"; - public User GetUserById(int id) { User user = new User(); - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -44,7 +42,7 @@ public int IsValidUser(string email, string password) { object userId; - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -65,7 +63,7 @@ public List GetAllUsers() { List userList = new List(); - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -98,7 +96,7 @@ public List GetAllUsers() public int AddUser(User user) { - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -122,7 +120,7 @@ public int AddUser(User user) public void UpdateUserPassword(string email, string userPin, string password) { - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); @@ -142,7 +140,7 @@ public bool VerifyUser(string email, string userPin) { object userId; - using (SqlConnection sqlConnection = new SqlConnection(_connectionString)) + using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); diff --git a/EBanking.UI/App.config b/EBanking.UI/App.config new file mode 100644 index 0000000..fadec26 --- /dev/null +++ b/EBanking.UI/App.config @@ -0,0 +1,8 @@ + + + + + + From ff3110eb3d0c5c811b4ec8d5456821ee46efc37d Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 12:40:39 +0200 Subject: [PATCH 02/12] security: remove hardcoded login defaults Strips the test email/password that the login screen shipped with by default. Reviewers cloning the repo no longer see prefilled credentials. Co-Authored-By: Claude Opus 4.7 --- EBanking.UI/Models/LoginModel.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EBanking.UI/Models/LoginModel.cs b/EBanking.UI/Models/LoginModel.cs index 6e3f2d9..7d43876 100644 --- a/EBanking.UI/Models/LoginModel.cs +++ b/EBanking.UI/Models/LoginModel.cs @@ -2,9 +2,9 @@ namespace EBanking.UI.Models { public class LoginModel : BaseModel { - public string Email { get; set; } = "123@gmail.com"; + public string Email { get; set; } = ""; - public string Password { get; set; } = "2222"; + public string Password { get; set; } = ""; #region Validation public string EmailError { get; set; } From 1efb44bc3a324fafdfc32089336b4cda8f21f826 Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 12:46:07 +0200 Subject: [PATCH 03/12] security: hash user passwords with BCrypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds BCrypt.Net-Next to the data layer and a PasswordHasher helper (work-factor 11). UserRepository now hashes on AddUser and UpdateUserPassword, and IsValidUser pulls the stored hash and verifies against it instead of comparing plaintext. Also fixes a latent bug in UpdateUserPassword that called ExecuteScalar for a non-query statement. EBankingSystem_Seed.sql now ships pre-hashed passwords; the plaintext demo credentials are kept readable in a comment so a reviewer can still log in. EBanking.TestConsole — previously an empty project — is now a one-shot utility: 'hash ' prints a hash (used to prepare the seed), no-arg mode re-hashes any plaintext rows left over in an existing dev DB (detected via NOT LIKE '\$2%'). Co-Authored-By: Claude Opus 4.7 --- .../EBanking.DataAccess.csproj | 1 + .../Implementation/UserRepository.cs | 26 ++++++---- .../Security/PasswordHasher.cs | 13 +++++ EBanking.TestConsole/App.config | 8 +++ EBanking.TestConsole/Program.cs | 51 +++++++++++++++++++ EBankingSystem_Seed.sql | 15 ++++-- 6 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 EBanking.DataAccess/Security/PasswordHasher.cs create mode 100644 EBanking.TestConsole/App.config create mode 100644 EBanking.TestConsole/Program.cs diff --git a/EBanking.DataAccess/EBanking.DataAccess.csproj b/EBanking.DataAccess/EBanking.DataAccess.csproj index 90ea637..9a3a01d 100644 --- a/EBanking.DataAccess/EBanking.DataAccess.csproj +++ b/EBanking.DataAccess/EBanking.DataAccess.csproj @@ -7,6 +7,7 @@ + diff --git a/EBanking.DataAccess/Repositories/Implementation/UserRepository.cs b/EBanking.DataAccess/Repositories/Implementation/UserRepository.cs index 7ef74e9..29c8ee2 100644 --- a/EBanking.DataAccess/Repositories/Implementation/UserRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/UserRepository.cs @@ -1,4 +1,5 @@ using EBanking.DataAccess.Models; +using EBanking.DataAccess.Security; using System.Data.SqlClient; namespace EBanking.DataAccess.Repositories.Implementation @@ -40,23 +41,28 @@ public User GetUserById(int id) public int IsValidUser(string email, string password) { - object userId; - using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); using (SqlCommand sqlCommand = sqlConnection.CreateCommand()) { - sqlCommand.CommandText = "SELECT * FROM [User] WHERE email = @email AND password = @password"; + sqlCommand.CommandText = "SELECT userId, password FROM [User] WHERE email = @email"; sqlCommand.Parameters.AddWithValue("@email", email); - sqlCommand.Parameters.AddWithValue("@password", password); - userId = sqlCommand.ExecuteScalar(); + using (SqlDataReader reader = sqlCommand.ExecuteReader()) + { + if (!reader.Read()) + { + return 0; + } + + int userId = (int)reader["userId"]; + string storedHash = (string)reader["password"]; + return PasswordHasher.Verify(password, storedHash) ? userId : 0; + } } } - var id = userId == null ? 0 : (int)userId; - return id; } public List GetAllUsers() @@ -111,7 +117,7 @@ public int AddUser(User user) sqlCommand.Parameters.AddWithValue("@phone", user.Phone); sqlCommand.Parameters.AddWithValue("@email", user.Email); sqlCommand.Parameters.AddWithValue("@userPin", user.UserPin); - sqlCommand.Parameters.AddWithValue("@password", user.Password); + sqlCommand.Parameters.AddWithValue("@password", PasswordHasher.Hash(user.Password)); return sqlCommand.ExecuteNonQuery(); } @@ -127,11 +133,11 @@ public void UpdateUserPassword(string email, string userPin, string password) using (SqlCommand sqlCommand = sqlConnection.CreateCommand()) { sqlCommand.CommandText = "UPDATE [User] SET password = @password WHERE email = @email AND userPin = @userPin"; - sqlCommand.Parameters.AddWithValue("@password", password); + sqlCommand.Parameters.AddWithValue("@password", PasswordHasher.Hash(password)); sqlCommand.Parameters.AddWithValue("@email", email); sqlCommand.Parameters.AddWithValue("@userPin", userPin); - sqlCommand.ExecuteScalar(); + sqlCommand.ExecuteNonQuery(); } } } diff --git a/EBanking.DataAccess/Security/PasswordHasher.cs b/EBanking.DataAccess/Security/PasswordHasher.cs new file mode 100644 index 0000000..8c0a7d5 --- /dev/null +++ b/EBanking.DataAccess/Security/PasswordHasher.cs @@ -0,0 +1,13 @@ +namespace EBanking.DataAccess.Security +{ + public static class PasswordHasher + { + private const int WorkFactor = 11; + + public static string Hash(string password) + => BCrypt.Net.BCrypt.HashPassword(password, WorkFactor); + + public static bool Verify(string password, string hash) + => BCrypt.Net.BCrypt.Verify(password, hash); + } +} diff --git a/EBanking.TestConsole/App.config b/EBanking.TestConsole/App.config new file mode 100644 index 0000000..fadec26 --- /dev/null +++ b/EBanking.TestConsole/App.config @@ -0,0 +1,8 @@ + + + + + + diff --git a/EBanking.TestConsole/Program.cs b/EBanking.TestConsole/Program.cs new file mode 100644 index 0000000..ba37d7e --- /dev/null +++ b/EBanking.TestConsole/Program.cs @@ -0,0 +1,51 @@ +using EBanking.DataAccess; +using EBanking.DataAccess.Security; +using System.Data.SqlClient; + +// One-shot utility for the password modernization pass. +// +// Usage: +// dotnet run --project EBanking.TestConsole -> re-hash any plaintext rows in [User] +// dotnet run --project EBanking.TestConsole -- hash -> print a BCrypt hash for +// +// "Plaintext" is detected by the absence of the BCrypt marker '$2' at the +// start of the password column. The rehash pass is idempotent: running it +// again on an already-hashed table is a no-op. + +if (args.Length >= 2 && args[0].Equals("hash", StringComparison.OrdinalIgnoreCase)) +{ + Console.WriteLine(PasswordHasher.Hash(args[1])); + return; +} + +using var connection = new SqlConnection(DatabaseAccess.ConnectionString); +connection.Open(); + +var rows = new List<(int UserId, string Password)>(); +using (var read = connection.CreateCommand()) +{ + read.CommandText = "SELECT userId, password FROM [User] WHERE password NOT LIKE '$2%'"; + using var reader = read.ExecuteReader(); + while (reader.Read()) + { + rows.Add(((int)reader["userId"], (string)reader["password"])); + } +} + +if (rows.Count == 0) +{ + Console.WriteLine("No plaintext passwords found. Nothing to do."); + return; +} + +Console.WriteLine($"Rehashing {rows.Count} user row(s)..."); +foreach (var (userId, plaintext) in rows) +{ + using var update = connection.CreateCommand(); + update.CommandText = "UPDATE [User] SET password = @hash WHERE userId = @id"; + update.Parameters.AddWithValue("@hash", PasswordHasher.Hash(plaintext)); + update.Parameters.AddWithValue("@id", userId); + update.ExecuteNonQuery(); + Console.WriteLine($" userId={userId} updated"); +} +Console.WriteLine("Done."); diff --git a/EBankingSystem_Seed.sql b/EBankingSystem_Seed.sql index b2f69b8..adf5747 100644 --- a/EBankingSystem_Seed.sql +++ b/EBankingSystem_Seed.sql @@ -15,16 +15,23 @@ GO -- ============================================================ -- Users --- Passwords are stored as plain text here for demo purposes. +-- Passwords are stored as BCrypt hashes (work-factor 11). Demo +-- credentials below are kept readable in this comment so a +-- reviewer can log in; the column itself never contains plaintext. +-- ana.petrovic@email.com / password123 +-- marko.jovanovic@email.com / password456 +-- jelena.nikolic@email.com / password789 +-- Hashes were generated with EBanking.TestConsole: +-- dotnet run --project EBanking.TestConsole -- hash -- ============================================================ SET IDENTITY_INSERT [dbo].[User] ON; INSERT INTO [dbo].[User] ([userId], [firstName], [lastName], [dateOfBirth], [idCardNumber], [phone], [email], [userPin], [password]) VALUES - (1, 'Ana', 'Petrovic', '1990-03-15', 'ID100001', '+381641234567', 'ana.petrovic@email.com', '1234', 'password123'), - (2, 'Marko', 'Jovanovic', '1985-07-22', 'ID100002', '+381651234567', 'marko.jovanovic@email.com', '5678', 'password456'), - (3, 'Jelena', 'Nikolic', '1995-11-08', 'ID100003', '+381661234567', 'jelena.nikolic@email.com', '9012', 'password789'); + (1, 'Ana', 'Petrovic', '1990-03-15', 'ID100001', '+381641234567', 'ana.petrovic@email.com', '1234', '$2a$11$BrfMZu862HHa6HDo.RdwOuXJFU30i8vfs4np5ImZ48p6CexOcw7KC'), + (2, 'Marko', 'Jovanovic', '1985-07-22', 'ID100002', '+381651234567', 'marko.jovanovic@email.com', '5678', '$2a$11$Jqsw0dv0st46HNJBsK6hnOuQnpjM5x.pF3LZnr.PUQ/d9Y2cBbdva'), + (3, 'Jelena', 'Nikolic', '1995-11-08', 'ID100003', '+381661234567', 'jelena.nikolic@email.com', '9012', '$2a$11$E/eJKQnGgtfEs5xLfMsQRuf0RyCJ1EIsQMHF70Szv838vwN/YnE2e'); SET IDENTITY_INSERT [dbo].[User] OFF; GO From c1db0bc05184cba7653685c44731adf676111711 Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 12:49:54 +0200 Subject: [PATCH 04/12] money: replace double with decimal for monetary values Balance, Amount, BalanceAfterTransaction, and exchange-rate Value are now decimal end to end (DataAccess models, Services models, UI models, the IAccountRepository / IAccountService.UpdateBalance signatures, and the service implementation). Repository readers drop the decimal.ToDouble round-trip and read DECIMAL(18,2) directly. Writes for balance and transaction amounts now use explicit SqlParameter with Precision=18, Scale=2 instead of AddWithValue, which silently negotiates the wrong precision for decimal values. Side fix: two latent ExecuteScalar calls on UPDATE/INSERT statements (CreateAccount, UpdateBalance) are now ExecuteNonQuery. The DB schema is unchanged; this commit is purely about not laundering DECIMAL through binary floating-point in the application tier. Co-Authored-By: Claude Opus 4.7 --- EBanking.DataAccess/Models/Account.cs | 2 +- EBanking.DataAccess/Models/CurrencyExchange.cs | 2 +- EBanking.DataAccess/Models/Transaction.cs | 4 ++-- .../Repositories/IAccountRepository.cs | 2 +- .../Implementation/AccountRepository.cs | 15 ++++++++------- .../Implementation/CurrencyExchangeRepository.cs | 2 +- .../Implementation/TransactionRepository.cs | 9 +++++---- EBanking.Services/IAccountService.cs | 2 +- .../Implementation/AccountService.cs | 2 +- EBanking.Services/Models/AccountModel.cs | 2 +- EBanking.Services/Models/CurrencyExchangeModel.cs | 2 +- EBanking.Services/Models/TransactionInfo.cs | 2 +- EBanking.Services/Models/TransactionModel.cs | 4 ++-- EBanking.UI/Models/AccountModel.cs | 2 +- EBanking.UI/Models/CurrencyExchangeModel.cs | 4 ++-- EBanking.UI/Models/PaymentModel.cs | 4 ++-- 16 files changed, 31 insertions(+), 29 deletions(-) diff --git a/EBanking.DataAccess/Models/Account.cs b/EBanking.DataAccess/Models/Account.cs index aa1670e..9850938 100644 --- a/EBanking.DataAccess/Models/Account.cs +++ b/EBanking.DataAccess/Models/Account.cs @@ -4,7 +4,7 @@ public class Account { public string AccountNumber { get; set; } public int UserId { get; set; } - public double Balance { get; set; } + public decimal Balance { get; set; } public string Type { get; set; } public string Currency { get; set; } public DateTime DateCreated { get; set; } diff --git a/EBanking.DataAccess/Models/CurrencyExchange.cs b/EBanking.DataAccess/Models/CurrencyExchange.cs index fec6d89..a30c62a 100644 --- a/EBanking.DataAccess/Models/CurrencyExchange.cs +++ b/EBanking.DataAccess/Models/CurrencyExchange.cs @@ -3,6 +3,6 @@ namespace EBanking.DataAccess.Models public class CurrencyExchange { public string Currency { get; set; } - public double Value { get; set; } + public decimal Value { get; set; } } } diff --git a/EBanking.DataAccess/Models/Transaction.cs b/EBanking.DataAccess/Models/Transaction.cs index d52ee74..ab385de 100644 --- a/EBanking.DataAccess/Models/Transaction.cs +++ b/EBanking.DataAccess/Models/Transaction.cs @@ -5,8 +5,8 @@ public class Transaction public int TransactionId { get; set; } public string AccountNumber { get; set; } public string CardNumber { get; set; } - public double Amount { get; set; } - public double BalanceAfterTransaction { get; set; } + public decimal Amount { get; set; } + public decimal BalanceAfterTransaction { get; set; } public DateTime Date { get; set; } public string SecondaryPartyName { get; set; } public string SecondaryPartyAccountNumber { get; set; } diff --git a/EBanking.DataAccess/Repositories/IAccountRepository.cs b/EBanking.DataAccess/Repositories/IAccountRepository.cs index bd8c400..8f8e060 100644 --- a/EBanking.DataAccess/Repositories/IAccountRepository.cs +++ b/EBanking.DataAccess/Repositories/IAccountRepository.cs @@ -7,7 +7,7 @@ public interface IAccountRepository void CreateAccount(Account model); List GetAccountsByUserId(int userId); Task GetAccountByAccountNumber(string accountNumber); - void UpdateBalance(double balance, string accountNumber); + void UpdateBalance(decimal balance, string accountNumber); bool IsValidAccount(string accountNumber); } } diff --git a/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs b/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs index 3d76963..26748f7 100644 --- a/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs @@ -1,4 +1,5 @@ using EBanking.DataAccess.Models; +using System.Data; using System.Data.SqlClient; namespace EBanking.DataAccess.Repositories.Implementation @@ -17,12 +18,12 @@ public void CreateAccount(Account model) "VALUES(@accountNumber, @userId, @balance, @currency, @type, @dateCreated)"; sqlCommand.Parameters.AddWithValue("@accountNumber", model.AccountNumber); sqlCommand.Parameters.AddWithValue("@userId", model.UserId); - sqlCommand.Parameters.AddWithValue("@balance", model.Balance); + sqlCommand.Parameters.Add(new SqlParameter("@balance", SqlDbType.Decimal) { Precision = 18, Scale = 2, Value = model.Balance }); sqlCommand.Parameters.AddWithValue("@currency", model.Currency); sqlCommand.Parameters.AddWithValue("@type", model.Type); sqlCommand.Parameters.AddWithValue("@dateCreated", model.DateCreated); - sqlCommand.ExecuteScalar(); + sqlCommand.ExecuteNonQuery(); } } } @@ -46,7 +47,7 @@ public async Task GetAccountByAccountNumber(string accountNumber) { account.UserId = (int)reader["userId"]; account.AccountNumber = reader["accountNumber"] as string; - account.Balance = decimal.ToDouble((decimal)reader["balance"]); + account.Balance = (decimal)reader["balance"]; account.DateCreated = (DateTime)reader["dateCreated"]; account.Type = reader["type"] as string; account.Currency = reader["currency"] as string; @@ -79,7 +80,7 @@ public List GetAccountsByUserId(int userId) { UserId = (int)reader["userId"], AccountNumber = reader["accountNumber"] as string, - Balance = decimal.ToDouble((decimal)reader["balance"]), + Balance = (decimal)reader["balance"], DateCreated = (DateTime)reader["dateCreated"], Type = reader["type"] as string, Currency = reader["currency"] as string, @@ -119,7 +120,7 @@ public bool IsValidAccount(string accountNumber) return accountId as string is not null; } - public void UpdateBalance(double balance, string accountNumber) + public void UpdateBalance(decimal balance, string accountNumber) { using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { @@ -128,10 +129,10 @@ public void UpdateBalance(double balance, string accountNumber) using (SqlCommand sqlCommand = sqlConnection.CreateCommand()) { sqlCommand.CommandText = "UPDATE Account SET balance = @balance WHERE accountNumber = @accountNumber"; - sqlCommand.Parameters.AddWithValue("@balance", balance); + sqlCommand.Parameters.Add(new SqlParameter("@balance", SqlDbType.Decimal) { Precision = 18, Scale = 2, Value = balance }); sqlCommand.Parameters.AddWithValue("@accountNumber", accountNumber); - sqlCommand.ExecuteScalar(); + sqlCommand.ExecuteNonQuery(); } } } diff --git a/EBanking.DataAccess/Repositories/Implementation/CurrencyExchangeRepository.cs b/EBanking.DataAccess/Repositories/Implementation/CurrencyExchangeRepository.cs index e3ddbf1..ae82597 100644 --- a/EBanking.DataAccess/Repositories/Implementation/CurrencyExchangeRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/CurrencyExchangeRepository.cs @@ -24,7 +24,7 @@ public List GetExchangeRatesByCurrency(string currency) exchangeRates.Add(new CurrencyExchange { Currency = reader["currency"] as string, - Value = decimal.ToDouble((decimal)reader["value"]), + Value = (decimal)reader["value"], }); } } diff --git a/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs b/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs index 25249af..0df22e4 100644 --- a/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs @@ -1,4 +1,5 @@ using EBanking.DataAccess.Models; +using System.Data; using System.Data.SqlClient; namespace EBanking.DataAccess.Repositories.Implementation @@ -17,8 +18,8 @@ public int CreateTransaction(Transaction transaction) " VALUES (@accountNumber, @cardNumber, @amount, @balanceAfterTransaction, @date, @secondaryPartyName, @secondaryPartyAccountNumber)"; sqlCommand.Parameters.AddWithValue("@accountNumber", transaction.AccountNumber); sqlCommand.Parameters.AddWithValue("@cardNumber", (object)transaction.CardNumber ?? DBNull.Value); - sqlCommand.Parameters.AddWithValue("@amount", transaction.Amount); - sqlCommand.Parameters.AddWithValue("@balanceAfterTransaction", transaction.BalanceAfterTransaction); + sqlCommand.Parameters.Add(new SqlParameter("@amount", SqlDbType.Decimal) { Precision = 18, Scale = 2, Value = transaction.Amount }); + sqlCommand.Parameters.Add(new SqlParameter("@balanceAfterTransaction", SqlDbType.Decimal) { Precision = 18, Scale = 2, Value = transaction.BalanceAfterTransaction }); sqlCommand.Parameters.AddWithValue("@date", transaction.Date); sqlCommand.Parameters.AddWithValue("@secondaryPartyName", (object)transaction.SecondaryPartyName ?? DBNull.Value); sqlCommand.Parameters.AddWithValue("@secondaryPartyAccountNumber", (object)transaction.SecondaryPartyAccountNumber ?? DBNull.Value); @@ -50,8 +51,8 @@ public List GetTransactionsByAccountNumber(string accountNumber) TransactionId = (int)reader["transactionId"], CardNumber = reader["cardNumber"] as string, AccountNumber = reader["accountNumber"] as string, - Amount = decimal.ToDouble((decimal)reader["amount"]), - BalanceAfterTransaction = decimal.ToDouble((decimal)reader["balanceAfterTransaction"]), + Amount = (decimal)reader["amount"], + BalanceAfterTransaction = (decimal)reader["balanceAfterTransaction"], Date = (DateTime)reader["date"], SecondaryPartyName = reader["secondaryPartyName"] as string, SecondaryPartyAccountNumber = reader["secondaryPartyAccountNumber"] as string diff --git a/EBanking.Services/IAccountService.cs b/EBanking.Services/IAccountService.cs index ab90b5b..c767369 100644 --- a/EBanking.Services/IAccountService.cs +++ b/EBanking.Services/IAccountService.cs @@ -5,7 +5,7 @@ namespace EBanking.Services public interface IAccountService { List GetAccountsForUser(int userId); - void UpdateBalance(double newBalance, string accountNumber); + void UpdateBalance(decimal newBalance, string accountNumber); bool IsValidAccount(string accountNumber); Task GetAccountByAccountNumber(string accountNumber); } diff --git a/EBanking.Services/Implementation/AccountService.cs b/EBanking.Services/Implementation/AccountService.cs index 0e66176..cbda546 100644 --- a/EBanking.Services/Implementation/AccountService.cs +++ b/EBanking.Services/Implementation/AccountService.cs @@ -108,7 +108,7 @@ public bool IsValidAccount(string accountNumber) return _accountRepository.IsValidAccount(accountNumber); } - public void UpdateBalance(double newBalance, string accountNumber) + public void UpdateBalance(decimal newBalance, string accountNumber) { _accountRepository.UpdateBalance(newBalance, accountNumber); } diff --git a/EBanking.Services/Models/AccountModel.cs b/EBanking.Services/Models/AccountModel.cs index f0ac43a..f652f42 100644 --- a/EBanking.Services/Models/AccountModel.cs +++ b/EBanking.Services/Models/AccountModel.cs @@ -4,7 +4,7 @@ public class AccountModel { public string AccountNumber { get; set; } public int UserId { get; set; } - public double Balance { get; set; } + public decimal Balance { get; set; } public string Type { get; set; } public string Currency { get; set; } public DateTime DateCreated { get; set; } diff --git a/EBanking.Services/Models/CurrencyExchangeModel.cs b/EBanking.Services/Models/CurrencyExchangeModel.cs index 960f34a..197f21d 100644 --- a/EBanking.Services/Models/CurrencyExchangeModel.cs +++ b/EBanking.Services/Models/CurrencyExchangeModel.cs @@ -3,6 +3,6 @@ namespace EBanking.Services.Models public class CurrencyExchangeModel { public string Currency { get; set; } - public double Value { get; set; } + public decimal Value { get; set; } } } diff --git a/EBanking.Services/Models/TransactionInfo.cs b/EBanking.Services/Models/TransactionInfo.cs index 1e3cc77..b63b99f 100644 --- a/EBanking.Services/Models/TransactionInfo.cs +++ b/EBanking.Services/Models/TransactionInfo.cs @@ -4,6 +4,6 @@ public class TransactionInfo { public string AccountNumber { get; set; } public string UserFullName { get; set; } - public double CurrentBalance { get; set; } + public decimal CurrentBalance { get; set; } } } diff --git a/EBanking.Services/Models/TransactionModel.cs b/EBanking.Services/Models/TransactionModel.cs index ddb88f7..590cf35 100644 --- a/EBanking.Services/Models/TransactionModel.cs +++ b/EBanking.Services/Models/TransactionModel.cs @@ -5,8 +5,8 @@ public class TransactionModel public int TransactionId { get; set; } public string AccountNumber { get; set; } public string CardNumber { get; set; } - public double Amount { get; set; } - public double BalanceAfterTransaction { get; set; } + public decimal Amount { get; set; } + public decimal BalanceAfterTransaction { get; set; } public DateTime Date { get; set; } public string SecondaryPartyName { get; set; } public string SecondaryPartyAccountNumber { get; set; } diff --git a/EBanking.UI/Models/AccountModel.cs b/EBanking.UI/Models/AccountModel.cs index 31fef73..5c30e13 100644 --- a/EBanking.UI/Models/AccountModel.cs +++ b/EBanking.UI/Models/AccountModel.cs @@ -5,7 +5,7 @@ namespace EBanking.UI.Models public class AccountModel : BaseModel { public string AccountNumber { get; set; } - public double Balance { get; set; } + public decimal Balance { get; set; } public string UserFullName { get; set; } public Services.Models.AccountModel SelectedAccount { get; set; } public List Accounts { get; set; } diff --git a/EBanking.UI/Models/CurrencyExchangeModel.cs b/EBanking.UI/Models/CurrencyExchangeModel.cs index 15ca636..ccec88c 100644 --- a/EBanking.UI/Models/CurrencyExchangeModel.cs +++ b/EBanking.UI/Models/CurrencyExchangeModel.cs @@ -5,8 +5,8 @@ namespace EBanking.UI.Models { public class CurrencyExchangeModel : BaseModel { - public double Amount { get; set; } - public double ConvertedValue { get; set; } + public decimal Amount { get; set; } + public decimal ConvertedValue { get; set; } public Services.Models.AccountModel Account { get; set; } public Services.Models.AccountModel SelectedAccount { get; set; } public List UserAccounts { get; set; } diff --git a/EBanking.UI/Models/PaymentModel.cs b/EBanking.UI/Models/PaymentModel.cs index c283a3b..d5bca03 100644 --- a/EBanking.UI/Models/PaymentModel.cs +++ b/EBanking.UI/Models/PaymentModel.cs @@ -3,12 +3,12 @@ namespace EBanking.UI.Models public class PaymentModel : BaseModel { public string PayerAccountNumber { get; set; } - public double CurrentBalance { get; set; } + public decimal CurrentBalance { get; set; } public string RecipientName { get; set; } public string RecipientAccountNumber { get; set; } public string ReferenceNumber { get; set; } public string PaymentPurpose { get; set; } - public double Amount { get; set; } + public decimal Amount { get; set; } #region Validation public string PayerAccountNumberError { get; set; } From d5b20f0d0677e0959c64adedaf85e3bc63e76a03 Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 12:57:53 +0200 Subject: [PATCH 05/12] payments: make transfers atomic with a single SQL transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old PaymentViewModel.Pay() inserted the payer's transaction row, updated the payer's balance, checked the recipient, and then fired an async-void recipient-credit before calling Close(). Any failure between the four steps left money debited but uncredited, or an orphan transaction row; in practice the window often closed before the fire-and-forget credit completed. Replaces the whole flow with ITransactionService.TransferFunds(request), which: - validates Amount > 0 and payer != recipient before touching SQL, - opens one SqlConnection + SqlTransaction (ReadCommitted), - reads the payer balance with (UPDLOCK, ROWLOCK) so a concurrent transfer can't race the balance check, - throws InvalidOperationException("Insufficient funds.") inside the transaction — the real defense, since UI validation can be bypassed, - debits the payer, and if the recipient is internal credits them and writes their transaction row, - writes the payer's transaction row, - commits, or rolls back and rethrows on any failure. PaymentViewModel is now a thin caller: validate, TransferFunds, Close — or MessageBox the exception. The async-void AddTransactionToRecipient helper is gone, and PaymentViewModel no longer needs IAccountService. PaymentViewValidator now also blocks payer == recipient and Amount > CurrentBalance, and drops two dead `== null` checks on non-nullable fields. Side fixes (same change set): - AccountRepository.IsValidAccount switched from SELECT * + a fragile `as string is not null` cast on the first column to SELECT 1 + `ExecuteScalar() is not null`. - GetAccountByAccountNumber loses its async marker; the method was declared async Task but did no awaiting. Propagated the sync signature through IAccountRepository, IAccountService, and AccountService. Co-Authored-By: Claude Opus 4.7 --- .../Repositories/IAccountRepository.cs | 2 +- .../Repositories/ITransactionRepository.cs | 7 ++ .../Implementation/AccountRepository.cs | 10 +- .../Implementation/TransactionRepository.cs | 100 ++++++++++++++++++ EBanking.Services/IAccountService.cs | 2 +- EBanking.Services/ITransactionService.cs | 1 + .../Implementation/AccountService.cs | 4 +- .../Implementation/TransactionService.cs | 24 +++++ EBanking.Services/Models/TransferRequest.cs | 12 +++ .../Common/Validation/PaymentViewValidator.cs | 16 ++- .../ViewModels/Windows/AccountViewModel.cs | 2 +- .../ViewModels/Windows/PaymentViewModel.cs | 50 +++------ 12 files changed, 182 insertions(+), 48 deletions(-) create mode 100644 EBanking.Services/Models/TransferRequest.cs diff --git a/EBanking.DataAccess/Repositories/IAccountRepository.cs b/EBanking.DataAccess/Repositories/IAccountRepository.cs index 8f8e060..36520c2 100644 --- a/EBanking.DataAccess/Repositories/IAccountRepository.cs +++ b/EBanking.DataAccess/Repositories/IAccountRepository.cs @@ -6,7 +6,7 @@ public interface IAccountRepository { void CreateAccount(Account model); List GetAccountsByUserId(int userId); - Task GetAccountByAccountNumber(string accountNumber); + Account GetAccountByAccountNumber(string accountNumber); void UpdateBalance(decimal balance, string accountNumber); bool IsValidAccount(string accountNumber); } diff --git a/EBanking.DataAccess/Repositories/ITransactionRepository.cs b/EBanking.DataAccess/Repositories/ITransactionRepository.cs index 4739b00..2efeb6e 100644 --- a/EBanking.DataAccess/Repositories/ITransactionRepository.cs +++ b/EBanking.DataAccess/Repositories/ITransactionRepository.cs @@ -6,5 +6,12 @@ public interface ITransactionRepository { int CreateTransaction(Transaction transaction); List GetTransactionsByAccountNumber(string accountNumber); + void ExecuteTransfer( + string payerAccountNumber, + string recipientAccountNumber, + decimal amount, + string recipientName, + string payerFullName, + DateTime occurredAt); } } diff --git a/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs b/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs index 26748f7..9b5a665 100644 --- a/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/AccountRepository.cs @@ -28,7 +28,7 @@ public void CreateAccount(Account model) } } - public async Task GetAccountByAccountNumber(string accountNumber) + public Account GetAccountByAccountNumber(string accountNumber) { Account account = new Account(); @@ -102,22 +102,18 @@ public List GetAccountsByUserId(int userId) public bool IsValidAccount(string accountNumber) { - object accountId; - using (SqlConnection sqlConnection = new SqlConnection(DatabaseAccess.ConnectionString)) { sqlConnection.Open(); using (SqlCommand sqlCommand = sqlConnection.CreateCommand()) { - sqlCommand.CommandText = "SELECT * FROM Account WHERE accountNumber = @accountNumber"; + sqlCommand.CommandText = "SELECT 1 FROM Account WHERE accountNumber = @accountNumber"; sqlCommand.Parameters.AddWithValue("@accountNumber", accountNumber); - accountId = sqlCommand.ExecuteScalar(); + return sqlCommand.ExecuteScalar() is not null; } } - - return accountId as string is not null; } public void UpdateBalance(decimal balance, string accountNumber) diff --git a/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs b/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs index 0df22e4..a4be9da 100644 --- a/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs @@ -29,6 +29,106 @@ public int CreateTransaction(Transaction transaction) } } + public void ExecuteTransfer( + string payerAccountNumber, + string recipientAccountNumber, + decimal amount, + string recipientName, + string payerFullName, + DateTime occurredAt) + { + using var connection = new SqlConnection(DatabaseAccess.ConnectionString); + connection.Open(); + using var transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + decimal payerBalance = ReadBalanceWithUpdLock(connection, transaction, payerAccountNumber) + ?? throw new InvalidOperationException("Payer account not found."); + + if (payerBalance < amount) + { + throw new InvalidOperationException("Insufficient funds."); + } + + UpdateBalanceByDelta(connection, transaction, payerAccountNumber, -amount); + decimal payerBalanceAfter = payerBalance - amount; + + decimal? recipientBalance = ReadBalanceWithUpdLock(connection, transaction, recipientAccountNumber); + if (recipientBalance.HasValue) + { + UpdateBalanceByDelta(connection, transaction, recipientAccountNumber, amount); + InsertTransactionRow( + connection, transaction, + accountNumber: recipientAccountNumber, + amount: amount, + balanceAfter: recipientBalance.Value + amount, + date: occurredAt, + secondaryPartyName: payerFullName, + secondaryPartyAccountNumber: payerAccountNumber); + } + + InsertTransactionRow( + connection, transaction, + accountNumber: payerAccountNumber, + amount: amount, + balanceAfter: payerBalanceAfter, + date: occurredAt, + secondaryPartyName: recipientName, + secondaryPartyAccountNumber: recipientAccountNumber); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + + private static decimal? ReadBalanceWithUpdLock(SqlConnection conn, SqlTransaction tx, string accountNumber) + { + using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = "SELECT balance FROM Account WITH (UPDLOCK, ROWLOCK) WHERE accountNumber = @accountNumber"; + cmd.Parameters.AddWithValue("@accountNumber", accountNumber); + var result = cmd.ExecuteScalar(); + return result is null or DBNull ? null : (decimal)result; + } + + private static void UpdateBalanceByDelta(SqlConnection conn, SqlTransaction tx, string accountNumber, decimal delta) + { + using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = "UPDATE Account SET balance = balance + @delta WHERE accountNumber = @accountNumber"; + cmd.Parameters.Add(new SqlParameter("@delta", SqlDbType.Decimal) { Precision = 18, Scale = 2, Value = delta }); + cmd.Parameters.AddWithValue("@accountNumber", accountNumber); + cmd.ExecuteNonQuery(); + } + + private static void InsertTransactionRow( + SqlConnection conn, + SqlTransaction tx, + string accountNumber, + decimal amount, + decimal balanceAfter, + DateTime date, + string secondaryPartyName, + string secondaryPartyAccountNumber) + { + using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = + "INSERT INTO [Transaction](accountNumber, cardNumber, amount, balanceAfterTransaction, date, secondaryPartyName, secondaryPartyAccountNumber) " + + "VALUES (@accountNumber, NULL, @amount, @balanceAfterTransaction, @date, @secondaryPartyName, @secondaryPartyAccountNumber)"; + cmd.Parameters.AddWithValue("@accountNumber", accountNumber); + cmd.Parameters.Add(new SqlParameter("@amount", SqlDbType.Decimal) { Precision = 18, Scale = 2, Value = amount }); + cmd.Parameters.Add(new SqlParameter("@balanceAfterTransaction", SqlDbType.Decimal) { Precision = 18, Scale = 2, Value = balanceAfter }); + cmd.Parameters.AddWithValue("@date", date); + cmd.Parameters.AddWithValue("@secondaryPartyName", (object?)secondaryPartyName ?? DBNull.Value); + cmd.Parameters.AddWithValue("@secondaryPartyAccountNumber", (object?)secondaryPartyAccountNumber ?? DBNull.Value); + cmd.ExecuteNonQuery(); + } + public List GetTransactionsByAccountNumber(string accountNumber) { List transactionList = new List(); diff --git a/EBanking.Services/IAccountService.cs b/EBanking.Services/IAccountService.cs index c767369..1d977ea 100644 --- a/EBanking.Services/IAccountService.cs +++ b/EBanking.Services/IAccountService.cs @@ -7,6 +7,6 @@ public interface IAccountService List GetAccountsForUser(int userId); void UpdateBalance(decimal newBalance, string accountNumber); bool IsValidAccount(string accountNumber); - Task GetAccountByAccountNumber(string accountNumber); + AccountModel GetAccountByAccountNumber(string accountNumber); } } diff --git a/EBanking.Services/ITransactionService.cs b/EBanking.Services/ITransactionService.cs index f0f8761..73341a6 100644 --- a/EBanking.Services/ITransactionService.cs +++ b/EBanking.Services/ITransactionService.cs @@ -6,5 +6,6 @@ public interface ITransactionService { void CreateTransaction(TransactionModel transactionModel); List GetTransactionsByAccountNumber(string accountNumber); + void TransferFunds(TransferRequest request); } } diff --git a/EBanking.Services/Implementation/AccountService.cs b/EBanking.Services/Implementation/AccountService.cs index cbda546..87d0a9d 100644 --- a/EBanking.Services/Implementation/AccountService.cs +++ b/EBanking.Services/Implementation/AccountService.cs @@ -17,9 +17,9 @@ public AccountService(IAccountRepository accountRepository, ITransactionReposito _currencyExchangeRepository = currencyExchangeRepository; } - public async Task GetAccountByAccountNumber(string accountNumber) + public AccountModel GetAccountByAccountNumber(string accountNumber) { - Account account = await _accountRepository.GetAccountByAccountNumber(accountNumber); + Account account = _accountRepository.GetAccountByAccountNumber(accountNumber); return new AccountModel { diff --git a/EBanking.Services/Implementation/TransactionService.cs b/EBanking.Services/Implementation/TransactionService.cs index 0d1cfd0..bb54c9f 100644 --- a/EBanking.Services/Implementation/TransactionService.cs +++ b/EBanking.Services/Implementation/TransactionService.cs @@ -13,6 +13,30 @@ public TransactionService(ITransactionRepository transactionRepository) _transactionRepository = transactionRepository; } + public void TransferFunds(TransferRequest request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + if (request.Amount <= 0) + { + throw new ArgumentException("Amount must be greater than zero.", nameof(request)); + } + if (string.Equals(request.PayerAccountNumber, request.RecipientAccountNumber, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Cannot transfer to the same account."); + } + + _transactionRepository.ExecuteTransfer( + payerAccountNumber: request.PayerAccountNumber, + recipientAccountNumber: request.RecipientAccountNumber, + amount: request.Amount, + recipientName: request.RecipientName, + payerFullName: request.PayerFullName, + occurredAt: DateTime.UtcNow); + } + public void CreateTransaction(TransactionModel transactionModel) { _transactionRepository.CreateTransaction(new Transaction diff --git a/EBanking.Services/Models/TransferRequest.cs b/EBanking.Services/Models/TransferRequest.cs new file mode 100644 index 0000000..3f05a5f --- /dev/null +++ b/EBanking.Services/Models/TransferRequest.cs @@ -0,0 +1,12 @@ +namespace EBanking.Services.Models +{ + public class TransferRequest + { + public string PayerAccountNumber { get; set; } = ""; + public string RecipientAccountNumber { get; set; } = ""; + public decimal Amount { get; set; } + public string RecipientName { get; set; } = ""; + public string PaymentPurpose { get; set; } = ""; + public string PayerFullName { get; set; } = ""; + } +} diff --git a/EBanking.UI/Common/Validation/PaymentViewValidator.cs b/EBanking.UI/Common/Validation/PaymentViewValidator.cs index fddda2b..e42f36e 100644 --- a/EBanking.UI/Common/Validation/PaymentViewValidator.cs +++ b/EBanking.UI/Common/Validation/PaymentViewValidator.cs @@ -8,17 +8,22 @@ public bool ValidateModel(TModel model) { int errors = 0; - if (model.RecipientAccountNumber == null || model.RecipientAccountNumber == "") + if (string.IsNullOrEmpty(model.RecipientAccountNumber)) { model.RecipientAccountNumberError = "You have not entered the recipient's account number"; errors++; } + else if (model.RecipientAccountNumber == model.PayerAccountNumber) + { + model.RecipientAccountNumberError = "Cannot transfer to the same account"; + errors++; + } else { model.RecipientAccountNumberError = null; } - if (model.RecipientName == null || model.RecipientName == "") + if (string.IsNullOrEmpty(model.RecipientName)) { model.RecipientNameError = "You have not entered the recipient's name"; errors++; @@ -28,11 +33,16 @@ public bool ValidateModel(TModel model) model.RecipientNameError = null; } - if (model.Amount == null || model.Amount <= 0) + if (model.Amount <= 0) { model.AmountError = "You have not entered the amount"; errors++; } + else if (model.Amount > model.CurrentBalance) + { + model.AmountError = "Insufficient funds"; + errors++; + } else { model.AmountError = null; diff --git a/EBanking.UI/ViewModels/Windows/AccountViewModel.cs b/EBanking.UI/ViewModels/Windows/AccountViewModel.cs index b067811..cc44e72 100644 --- a/EBanking.UI/ViewModels/Windows/AccountViewModel.cs +++ b/EBanking.UI/ViewModels/Windows/AccountViewModel.cs @@ -81,7 +81,7 @@ public async void Payment() { PaymentView view = new PaymentView { - DataContext = new PaymentViewModel(_transactionService, _accountService, + DataContext = new PaymentViewModel(_transactionService, new TransactionInfo { AccountNumber = Model.SelectedAccount.AccountNumber, CurrentBalance = Model.SelectedAccount.Balance, UserFullName = Model.Accounts.First().User.FirstName + " " + Model.Accounts.First().User.LastName }) }; diff --git a/EBanking.UI/ViewModels/Windows/PaymentViewModel.cs b/EBanking.UI/ViewModels/Windows/PaymentViewModel.cs index f261991..3806728 100644 --- a/EBanking.UI/ViewModels/Windows/PaymentViewModel.cs +++ b/EBanking.UI/ViewModels/Windows/PaymentViewModel.cs @@ -5,24 +5,23 @@ using EBanking.UI.Models; using GalaSoft.MvvmLight.Ioc; using System; +using System.Windows; namespace EBanking.UI.ViewModels.Windows { public class PaymentViewModel : BaseViewModel { private readonly ITransactionService _transactionService; - private readonly IAccountService _accountService; [PreferredConstructor] public PaymentViewModel() { } - public PaymentViewModel(ITransactionService transactionService, IAccountService accountService, TransactionInfo transactionInfo) + public PaymentViewModel(ITransactionService transactionService, TransactionInfo transactionInfo) { Validator = new PaymentViewValidator(); _transactionService = transactionService; - _accountService = accountService; Model.Title = "New Payment"; Model.PayerAccountNumber = transactionInfo.AccountNumber; Model.CurrentBalance = transactionInfo.CurrentBalance; @@ -35,43 +34,28 @@ public PaymentViewModel(ITransactionService transactionService, IAccountService public void Pay() { - if (Validator.ValidateModel(Model)) + if (!Validator.ValidateModel(Model)) { - _transactionService.CreateTransaction(new TransactionModel + return; + } + + try + { + _transactionService.TransferFunds(new TransferRequest { - AccountNumber = Model.PayerAccountNumber, + PayerAccountNumber = Model.PayerAccountNumber, + RecipientAccountNumber = Model.RecipientAccountNumber, Amount = Model.Amount, - SecondaryPartyAccountNumber = Model.RecipientAccountNumber, - SecondaryPartyName = Model.RecipientName, - BalanceAfterTransaction = Model.CurrentBalance - Model.Amount, - Date = DateTime.UtcNow + RecipientName = Model.RecipientName, + PaymentPurpose = Model.PaymentPurpose, + PayerFullName = TransactionInfo.UserFullName, }); - - _accountService.UpdateBalance(Model.CurrentBalance - Model.Amount, Model.PayerAccountNumber); - - if (_accountService.IsValidAccount(Model.RecipientAccountNumber)) - { - AddTransactionToRecipient(); - } Close(); } - } - - private async void AddTransactionToRecipient() - { - var account = await _accountService.GetAccountByAccountNumber(Model.RecipientAccountNumber); - - _transactionService.CreateTransaction(new TransactionModel + catch (Exception ex) { - AccountNumber = Model.RecipientAccountNumber, - Amount = Model.Amount, - SecondaryPartyAccountNumber = Model.PayerAccountNumber, - SecondaryPartyName = TransactionInfo.UserFullName, - BalanceAfterTransaction = account.Balance + Model.Amount, - Date = DateTime.UtcNow - }); - - _accountService.UpdateBalance(account.Balance + Model.Amount, Model.RecipientAccountNumber); + MessageBox.Show(ex.Message, "Payment failed"); + } } } } From f6b4a4527673c824c4a41eb1001870a9939d37f3 Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 13:01:15 +0200 Subject: [PATCH 06/12] ui: remove async void handlers and Closing-event leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountViewModel.Payment() was declared async void but did no awaiting (left over from when PaymentViewModel.AddTransactionToRecipient was called from here as an awaited helper). Drop the async marker; the method is plain void now. After this commit a solution-wide search for 'async void' in EBanking.UI/ returns no hits. The three view.Closing subscriptions — accountView.Closing in LoginViewModel.Login(), and view.Closing for both CurrencyExchangeView and PaymentView in AccountViewModel — were attached as anonymous lambdas that captured 'this' and were never removed. Each child-window open added a new subscription with no detach, pinning every prior ViewModel and its services for the lifetime of the app. Replaced with named local-function handlers that '-=' themselves on the first invocation, so the closure (and the captured ViewModel chain) is collectible after the child window closes. Co-Authored-By: Claude Opus 4.7 --- .../ViewModels/Windows/AccountViewModel.cs | 15 ++++++++++----- EBanking.UI/ViewModels/Windows/LoginViewModel.cs | 7 +++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/EBanking.UI/ViewModels/Windows/AccountViewModel.cs b/EBanking.UI/ViewModels/Windows/AccountViewModel.cs index cc44e72..ceb2fb9 100644 --- a/EBanking.UI/ViewModels/Windows/AccountViewModel.cs +++ b/EBanking.UI/ViewModels/Windows/AccountViewModel.cs @@ -4,6 +4,7 @@ using EBanking.UI.Views; using GalaSoft.MvvmLight.Ioc; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using System.Windows; @@ -58,10 +59,12 @@ public void OpenCurrencyExchange() { DataContext = new CurrencyExchangeViewModel(_transactionService, _accountService, _currencyExchangeService, Model.SelectedAccount, accounts, Model.UserFullName) }; - view.Closing += (s, o) => + void OnCurrencyExchangeClosing(object? sender, CancelEventArgs e) { + view.Closing -= OnCurrencyExchangeClosing; GetAccount(UserId); - }; + } + view.Closing += OnCurrencyExchangeClosing; view.Show(); } else @@ -75,7 +78,7 @@ public void Logout() Close(); } - public async void Payment() + public void Payment() { if (Model.SelectedAccount is not null) { @@ -85,10 +88,12 @@ public async void Payment() new TransactionInfo { AccountNumber = Model.SelectedAccount.AccountNumber, CurrentBalance = Model.SelectedAccount.Balance, UserFullName = Model.Accounts.First().User.FirstName + " " + Model.Accounts.First().User.LastName }) }; - view.Closing += (s, o) => + void OnPaymentClosing(object? sender, CancelEventArgs e) { + view.Closing -= OnPaymentClosing; GetAccount(UserId); - }; + } + view.Closing += OnPaymentClosing; view.Show(); } else diff --git a/EBanking.UI/ViewModels/Windows/LoginViewModel.cs b/EBanking.UI/ViewModels/Windows/LoginViewModel.cs index 7b58317..8d1ae6e 100644 --- a/EBanking.UI/ViewModels/Windows/LoginViewModel.cs +++ b/EBanking.UI/ViewModels/Windows/LoginViewModel.cs @@ -3,6 +3,7 @@ using EBanking.UI.Common.Validation; using EBanking.UI.Models; using EBanking.UI.Views; +using System.ComponentModel; using System.Windows; namespace EBanking.UI.ViewModels.Windows @@ -46,14 +47,16 @@ public void Login() { DataContext = new AccountViewModel(_accountService, _transactionService, _currencyExchangeService, userId) }; - accountView.Closing += (s, o) => + void OnAccountViewClosing(object? sender, CancelEventArgs e) { + accountView.Closing -= OnAccountViewClosing; LoginView view = new LoginView { DataContext = this }; view.Show(); - }; + } + accountView.Closing += OnAccountViewClosing; accountView.Show(); Close(); } From 865d5b9440121d5885ad594bde695f1c3499e060 Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 13:02:57 +0200 Subject: [PATCH 07/12] docs: document modernization changes in README Adds a README covering tech stack, the four projects, local setup, seed credentials (now BCrypt-hashed), and a per-bullet summary of the modernization pass that matches the six commits on this branch. Also documents the EBanking.TestConsole utility (hash generator + idempotent rehash for legacy dev DBs). Co-Authored-By: Claude Opus 4.7 --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..06c6086 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# EBanking + +A WPF desktop application that simulates a small retail-banking workflow: +login, view accounts and transaction history, make a payment, and run a +currency exchange between two of your own accounts. School / portfolio +project, not a real banking system. + +## Tech stack + +- **.NET 6** (WPF, `net6.0-windows`) +- **MVVM:** [CommunityToolkit.Mvvm](https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/) for `RelayCommand` and observables, **MvvmLight** `SimpleIoc` as the DI container, [Fody.PropertyChanged](https://github.com/Fody/PropertyChanged) for INPC weaving +- **Data access:** raw ADO.NET (`System.Data.SqlClient`) against SQL Server Express +- **Password hashing:** [BCrypt.Net-Next](https://github.com/BcryptNet/bcrypt.net) (work-factor 11) +- **Configuration:** `App.config` via `System.Configuration.ConfigurationManager` + +The solution has four projects: + +| Project | Purpose | +| --- | --- | +| `EBanking.UI` | WPF entry point (Views, ViewModels, Models, validators) | +| `EBanking.Services` | Business-logic layer over the repositories | +| `EBanking.DataAccess` | Repositories, ADO.NET, `PasswordHasher` | +| `EBanking.TestConsole` | One-shot utility: generate BCrypt hashes for seed data, or re-hash any plaintext rows left in a legacy dev database | + +## Running it locally + +1. Open `EBankingSystem_Create.sql` in SQL Server Management Studio against a local `SQLEXPRESS` instance and run it. This drops and recreates the `EBankingSystem` database. +2. Run `EBankingSystem_Seed.sql`. This inserts the demo users (with BCrypt-hashed passwords), accounts, cards, currency rates, and a handful of transactions. +3. If your SQL Server isn't `.\SQLEXPRESS` or the database name differs, edit the connection string in `EBanking.UI/App.config` — it's the single source of truth, and all four repositories read from it. +4. Open `EBanking.sln` in Visual Studio 2022 and run `EBanking.UI`. + +### Seed credentials + +Demo passwords are stored as BCrypt hashes; the plaintext values below are the seeded passwords (kept readable so reviewers can sign in): + +| Email | Password | +| --- | --- | +| `ana.petrovic@email.com` | `password123` | +| `marko.jovanovic@email.com` | `password456` | +| `jelena.nikolic@email.com` | `password789` | + +### Re-hashing a stale dev database + +If you have an older snapshot of the database where `[User].password` is still plaintext, run the migration utility once: + +``` +dotnet run --project EBanking.TestConsole +``` + +It picks up any row where `password NOT LIKE '$2%'` and rehashes it in place. The pass is idempotent — running it on an already-hashed table is a no-op. + +To generate a hash for a new password (e.g. when editing the seed script): + +``` +dotnet run --project EBanking.TestConsole -- hash MyNewPassword +``` + +## Modernization pass + +This branch (`critical-fixes-modernization`) addresses the issues that would make a reviewer wince at the original codebase. Each change landed as its own commit: + +- **Refactored the payment flow to use a single SQL transaction.** Transfers now run inside one `SqlConnection` + `SqlTransaction`. The payer row is locked with `WITH (UPDLOCK, ROWLOCK)`; balance and insufficient-funds checks happen inside the transaction, so UI validation isn't load-bearing. Either both balance changes and both transaction rows land, or the transaction rolls back and nothing does. Previously the flow was four sequential calls with an `async void` fire-and-forget for the recipient credit — failures left orphan rows or debited money that was never credited. +- **Replaced `double` with `decimal` for all monetary values.** `Balance`, `Amount`, `BalanceAfterTransaction`, and exchange-rate `Value` are `decimal` end-to-end. Repository readers stopped round-tripping through `decimal.ToDouble`, and `decimal` parameters are now bound via explicit `SqlParameter` with `Precision = 18, Scale = 2` instead of `AddWithValue`. The DB schema (`DECIMAL(18,2)`) was already correct; the application tier had been quietly laundering it through binary floating point. +- **Hashed user passwords with BCrypt** (work-factor 11). Plaintext storage and string-equality comparison are gone; `UserRepository.IsValidUser` pulls the stored hash and calls `BCrypt.Verify`. Seed data ships pre-hashed, and `EBanking.TestConsole` can rehash any leftover plaintext rows in a stale dev database. +- **Moved the database connection string into a single configuration source** (`EBanking.UI/App.config`). Removed five hardcoded copies across the data layer, including a dead `DatabaseAccess.cs` constant that pointed at a different database name. +- **Cleaned up WPF lifecycle bugs.** Removed the last `async void` handler in the UI project (`async void Payment()` was a stranded remnant after the payment-flow refactor). The three `view.Closing` subscriptions (login → account view, account → payment view, account → currency exchange) were anonymous lambdas that captured `this` and were never detached, pinning every prior ViewModel for the app's lifetime; they're now named local-function handlers that `-=` themselves on first invocation. + +## What's intentionally out of scope + +This pass kept the architecture as-is: still MvvmLight `SimpleIoc`, still raw ADO.NET, still .NET 6, no automated test project, no `INavigationService`, no logging framework. Those are bigger swings worth considering separately — they aren't here. From e7344f4a9117fc100c2ea0aed21176b38f461897 Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 13:39:31 +0200 Subject: [PATCH 08/12] payments: reject transfer when recipient account does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExecuteTransfer previously preserved the original PaymentViewModel.Pay behavior of debiting the payer even when the recipient account number didn't exist in [Account] (modeling a send to another bank). For a closed-system demo this is confusing — the UI shows the payer balance dropping with no indication that the money went anywhere. Move the recipient lookup before the payer debit and throw InvalidOperationException("Recipient account not found.") when it returns null. The catch in ExecuteTransfer rolls back, so on this path no rows are written at all. Payer-account-missing already had the symmetric throw. PaymentViewModel.Pay catches and surfaces the message via MessageBox, so the user gets clear feedback instead of a silent debit. Co-Authored-By: Claude Opus 4.7 --- .../Implementation/TransactionRepository.cs | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs b/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs index a4be9da..8a9b37e 100644 --- a/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs +++ b/EBanking.DataAccess/Repositories/Implementation/TransactionRepository.cs @@ -45,33 +45,31 @@ public void ExecuteTransfer( decimal payerBalance = ReadBalanceWithUpdLock(connection, transaction, payerAccountNumber) ?? throw new InvalidOperationException("Payer account not found."); + decimal recipientBalance = ReadBalanceWithUpdLock(connection, transaction, recipientAccountNumber) + ?? throw new InvalidOperationException("Recipient account not found."); + if (payerBalance < amount) { throw new InvalidOperationException("Insufficient funds."); } UpdateBalanceByDelta(connection, transaction, payerAccountNumber, -amount); - decimal payerBalanceAfter = payerBalance - amount; + UpdateBalanceByDelta(connection, transaction, recipientAccountNumber, amount); - decimal? recipientBalance = ReadBalanceWithUpdLock(connection, transaction, recipientAccountNumber); - if (recipientBalance.HasValue) - { - UpdateBalanceByDelta(connection, transaction, recipientAccountNumber, amount); - InsertTransactionRow( - connection, transaction, - accountNumber: recipientAccountNumber, - amount: amount, - balanceAfter: recipientBalance.Value + amount, - date: occurredAt, - secondaryPartyName: payerFullName, - secondaryPartyAccountNumber: payerAccountNumber); - } + InsertTransactionRow( + connection, transaction, + accountNumber: recipientAccountNumber, + amount: amount, + balanceAfter: recipientBalance + amount, + date: occurredAt, + secondaryPartyName: payerFullName, + secondaryPartyAccountNumber: payerAccountNumber); InsertTransactionRow( connection, transaction, accountNumber: payerAccountNumber, amount: amount, - balanceAfter: payerBalanceAfter, + balanceAfter: payerBalance - amount, date: occurredAt, secondaryPartyName: recipientName, secondaryPartyAccountNumber: recipientAccountNumber); From 5ff2fc21edde63cfc0265fe37334ed7d55ff84ad Mon Sep 17 00:00:00 2001 From: MarijaGojkov Date: Mon, 11 May 2026 14:54:47 +0200 Subject: [PATCH 09/12] ui: label source and target currencies on Currency Exchange screen The Amount and Converted-value fields gave no indication of which currency they were in, so a user clicking exchange from an RSD account to an EUR account would see '1000 -> 8.5' and assume the math was broken (it wasn't: 1000 RSD * 0.0085 = 8.50 EUR). Adds three small currency badges: - next to the Amount textbox: source currency (Model.Account.Currency) - next to the dropdown: target currency (was already there but had no Foreground set, so it rendered invisible on the dark gradient) - next to the Converted-value textbox: target currency Co-Authored-By: Claude Opus 4.7 --- EBanking.UI/Views/CurrencyExchangeView.xaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EBanking.UI/Views/CurrencyExchangeView.xaml b/EBanking.UI/Views/CurrencyExchangeView.xaml index 89830ba..a974205 100644 --- a/EBanking.UI/Views/CurrencyExchangeView.xaml +++ b/EBanking.UI/Views/CurrencyExchangeView.xaml @@ -35,8 +35,10 @@