diff --git a/src/main/java/com/example/bankapp/repository/TransactionRepository.java b/src/main/java/com/example/bankapp/repository/TransactionRepository.java index 7d4f2578..fab472ec 100644 --- a/src/main/java/com/example/bankapp/repository/TransactionRepository.java +++ b/src/main/java/com/example/bankapp/repository/TransactionRepository.java @@ -3,8 +3,11 @@ import com.example.bankapp.model.Transaction; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; import java.util.List; public interface TransactionRepository extends JpaRepository { List findByAccountId(Long accountId); + + List findByAccountIdAndTimestampBetween(Long accountId, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/com/example/bankapp/service/AccountService.java b/src/main/java/com/example/bankapp/service/AccountService.java index 5d7d90ec..fa844c33 100644 --- a/src/main/java/com/example/bankapp/service/AccountService.java +++ b/src/main/java/com/example/bankapp/service/AccountService.java @@ -5,6 +5,7 @@ import com.example.bankapp.repository.AccountRepository; import com.example.bankapp.repository.TransactionRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -14,6 +15,7 @@ import org.springframework.stereotype.Service; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Collection; @@ -31,6 +33,9 @@ public class AccountService implements UserDetailsService { @Autowired private TransactionRepository transactionRepository; + @Value("${bankapp.daily-withdrawal-limit}") + private BigDecimal dailyWithdrawalLimit; + public Account findAccountByUsername(String username) { return accountRepository.findByUsername(username).orElseThrow(() -> new RuntimeException("Account not found")); } @@ -62,6 +67,13 @@ public void deposit(Account account, BigDecimal amount) { } public void withdraw(Account account, BigDecimal amount) { + if (amount == null || amount.signum() <= 0) { + throw new RuntimeException("Withdrawal amount must be positive"); + } + LocalDateTime now = LocalDateTime.now(); + if (getWithdrawnOn(account, now.toLocalDate()).add(amount).compareTo(dailyWithdrawalLimit) > 0) { + throw new RuntimeException("Daily withdrawal limit of $" + dailyWithdrawalLimit.toPlainString() + " exceeded"); + } if (account.getBalance().compareTo(amount) < 0) { throw new RuntimeException("Insufficient funds"); } @@ -71,12 +83,23 @@ public void withdraw(Account account, BigDecimal amount) { Transaction transaction = new Transaction( amount, "Withdrawal", - LocalDateTime.now(), + now, account ); transactionRepository.save(transaction); } + private BigDecimal getWithdrawnOn(Account account, LocalDate day) { + LocalDateTime startOfDay = day.atStartOfDay(); + LocalDateTime startOfNextDay = startOfDay.plusDays(1); + return transactionRepository + .findByAccountIdAndTimestampBetween(account.getId(), startOfDay, startOfNextDay) + .stream() + .filter(transaction -> "Withdrawal".equals(transaction.getType())) + .map(Transaction::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + public List getTransactionHistory(Account account) { return transactionRepository.findByAccountId(account.getId()); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 08663a63..df971e0c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,3 +9,6 @@ spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect spring.jpa.show-sql=true + +# Banking rules +bankapp.daily-withdrawal-limit=5000 diff --git a/src/test/java/com/example/bankapp/service/AccountServiceDailyWithdrawalLimitTest.java b/src/test/java/com/example/bankapp/service/AccountServiceDailyWithdrawalLimitTest.java new file mode 100644 index 00000000..5d44d6b5 --- /dev/null +++ b/src/test/java/com/example/bankapp/service/AccountServiceDailyWithdrawalLimitTest.java @@ -0,0 +1,130 @@ +package com.example.bankapp.service; + +import com.example.bankapp.model.Account; +import com.example.bankapp.model.Transaction; +import com.example.bankapp.repository.AccountRepository; +import com.example.bankapp.repository.TransactionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AccountServiceDailyWithdrawalLimitTest { + + @Mock + private AccountRepository accountRepository; + + @Mock + private TransactionRepository transactionRepository; + + @InjectMocks + private AccountService accountService; + + private Account account; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(accountService, "dailyWithdrawalLimit", new BigDecimal("5000")); + account = new Account(); + account.setId(1L); + account.setBalance(new BigDecimal("10000")); + } + + private void givenTodaysTransactions(Transaction... transactions) { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + when(transactionRepository.findByAccountIdAndTimestampBetween(1L, startOfDay, startOfDay.plusDays(1))) + .thenReturn(List.of(transactions)); + } + + private Transaction today(String amount, String type) { + return new Transaction(new BigDecimal(amount), type, LocalDateTime.now(), account); + } + + @Test + void allowsWithdrawalsUpToTheDailyLimit() { + givenTodaysTransactions(today("3000", "Withdrawal")); + + accountService.withdraw(account, new BigDecimal("2000")); + + assertEquals(new BigDecimal("8000"), account.getBalance()); + verify(accountRepository).save(account); + verify(transactionRepository).save(any(Transaction.class)); + } + + @Test + void rejectsWithdrawalThatPushesTodaysTotalOverTheLimit() { + givenTodaysTransactions(today("3000", "Withdrawal"), today("1500", "Withdrawal")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.withdraw(account, new BigDecimal("500.01"))); + + assertEquals("Daily withdrawal limit of $5000 exceeded", ex.getMessage()); + assertEquals(new BigDecimal("10000"), account.getBalance()); + verify(accountRepository, never()).save(any()); + verify(transactionRepository, never()).save(any()); + } + + @Test + void rejectsSingleWithdrawalOverTheLimit() { + givenTodaysTransactions(); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.withdraw(account, new BigDecimal("5001"))); + + assertEquals("Daily withdrawal limit of $5000 exceeded", ex.getMessage()); + } + + @Test + void ignoresDepositsAndTransfersWhenSummingTodaysWithdrawals() { + givenTodaysTransactions( + today("4000", "Deposit"), + today("4000", "Transfer Out to bob"), + today("1000", "Withdrawal")); + + accountService.withdraw(account, new BigDecimal("4000")); + + assertEquals(new BigDecimal("6000"), account.getBalance()); + } + + @Test + void keepsInsufficientFundsCheckWithinTheLimit() { + account.setBalance(new BigDecimal("100")); + givenTodaysTransactions(); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.withdraw(account, new BigDecimal("200"))); + + assertEquals("Insufficient funds", ex.getMessage()); + verify(transactionRepository, never()).save(any()); + verify(transactionRepository).findByAccountIdAndTimestampBetween(eq(1L), any(), any()); + } + + @Test + void rejectsNonPositiveAmountsSoTheyCannotRestoreDailyAllowance() { + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.withdraw(account, new BigDecimal("-1000"))); + + assertEquals("Withdrawal amount must be positive", ex.getMessage()); + assertThrows(RuntimeException.class, () -> accountService.withdraw(account, BigDecimal.ZERO)); + assertEquals(new BigDecimal("10000"), account.getBalance()); + verify(accountRepository, never()).save(any()); + verify(transactionRepository, never()).save(any()); + } +}