diff --git a/src/main/java/de/financer/ResponseReason.java b/src/main/java/de/financer/ResponseReason.java index bd1b02e..e5877cb 100644 --- a/src/main/java/de/financer/ResponseReason.java +++ b/src/main/java/de/financer/ResponseReason.java @@ -29,7 +29,9 @@ public enum ResponseReason { INVALID_TRANSACTION_ID(HttpStatus.INTERNAL_SERVER_ERROR), TRANSACTION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR), ACCOUNT_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR), - DUPLICATE_ACCOUNT_KEY(HttpStatus.INTERNAL_SERVER_ERROR); + DUPLICATE_ACCOUNT_KEY(HttpStatus.INTERNAL_SERVER_ERROR), + DUPLICATE_ACCOUNT_GROUP_NAME(HttpStatus.INTERNAL_SERVER_ERROR), + ACCOUNT_GROUP_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR); private HttpStatus httpStatus; diff --git a/src/main/java/de/financer/controller/AccountController.java b/src/main/java/de/financer/controller/AccountController.java index 9c37805..46b0b04 100644 --- a/src/main/java/de/financer/controller/AccountController.java +++ b/src/main/java/de/financer/controller/AccountController.java @@ -24,7 +24,7 @@ public class AccountController { final String decoded = ControllerUtil.urlDecode(key); if (LOGGER.isDebugEnabled()) { - LOGGER.debug(String.format("/accounts/getAccountByKey got parameter: %s", decoded)); + LOGGER.debug(String.format("/accounts/getByKey got parameter: %s", decoded)); } return this.accountService.getAccountByKey(decoded); @@ -36,14 +36,15 @@ public class AccountController { } @RequestMapping("createAccount") - public ResponseEntity createAccount(String key, String type) { + public ResponseEntity createAccount(String key, String type, String accountGroupName) { final String decoded = ControllerUtil.urlDecode(key); + final String decodedGroup = ControllerUtil.urlDecode(accountGroupName); if (LOGGER.isDebugEnabled()) { - LOGGER.debug(String.format("/accounts/createAccount got parameters: %s, %s", decoded, type)); + LOGGER.debug(String.format("/accounts/createAccount got parameters: %s, %s, %s", decoded, type, decodedGroup)); } - final ResponseReason responseReason = this.accountService.createAccount(decoded, type); + final ResponseReason responseReason = this.accountService.createAccount(decoded, type, decodedGroup); if (LOGGER.isDebugEnabled()) { LOGGER.debug(String.format("/accounts/createAccount returns with %s", responseReason.name())); diff --git a/src/main/java/de/financer/controller/AccountGroupController.java b/src/main/java/de/financer/controller/AccountGroupController.java new file mode 100644 index 0000000..109f9ce --- /dev/null +++ b/src/main/java/de/financer/controller/AccountGroupController.java @@ -0,0 +1,53 @@ +package de.financer.controller; + +import de.financer.ResponseReason; +import de.financer.model.AccountGroup; +import de.financer.service.AccountGroupService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("accountGroups") +public class AccountGroupController { + private static final Logger LOGGER = LoggerFactory.getLogger(AccountGroupController.class); + + @Autowired + private AccountGroupService accountGroupService; + + @RequestMapping("getByName") + public AccountGroup getAccountGroupByName(String name) { + final String decoded = ControllerUtil.urlDecode(name); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("/accountGroups/getByName got parameter: %s", decoded)); + } + + return this.accountGroupService.getAccountGroupByName(decoded); + } + + @RequestMapping("getAll") + public Iterable getAll() { + return this.accountGroupService.getAll(); + } + + @RequestMapping("createAccountGroup") + public ResponseEntity createAccountGroup(String name) { + final String decoded = ControllerUtil.urlDecode(name); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("/accountGroups/createAccountGroup got parameter: %s", decoded)); + } + + final ResponseReason responseReason = this.accountGroupService.createAccountGroup(decoded); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(String.format("/accountGroups/createAccountGroup returns with %s", responseReason.name())); + } + + return responseReason.toResponseEntity(); + } +} diff --git a/src/main/java/de/financer/dba/AccountGroupRepository.java b/src/main/java/de/financer/dba/AccountGroupRepository.java new file mode 100644 index 0000000..144cb66 --- /dev/null +++ b/src/main/java/de/financer/dba/AccountGroupRepository.java @@ -0,0 +1,11 @@ +package de.financer.dba; + +import de.financer.model.AccountGroup; +import org.springframework.data.repository.CrudRepository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(propagation = Propagation.REQUIRED) +public interface AccountGroupRepository extends CrudRepository { + AccountGroup findByName(String name); +} diff --git a/src/main/java/de/financer/model/Account.java b/src/main/java/de/financer/model/Account.java index b9c8eea..732b947 100644 --- a/src/main/java/de/financer/model/Account.java +++ b/src/main/java/de/financer/model/Account.java @@ -14,6 +14,8 @@ public class Account { @Enumerated(EnumType.STRING) private AccountStatus status; private Long currentBalance; + @ManyToOne + private AccountGroup accountGroup; public Long getId() { return id; @@ -50,4 +52,12 @@ public class Account { public void setCurrentBalance(Long currentBalance) { this.currentBalance = currentBalance; } + + public AccountGroup getAccountGroup() { + return accountGroup; + } + + public void setAccountGroup(AccountGroup accountGroup) { + this.accountGroup = accountGroup; + } } diff --git a/src/main/java/de/financer/model/AccountGroup.java b/src/main/java/de/financer/model/AccountGroup.java new file mode 100644 index 0000000..1ec6d15 --- /dev/null +++ b/src/main/java/de/financer/model/AccountGroup.java @@ -0,0 +1,23 @@ +package de.financer.model; + +import javax.persistence.*; + +@Entity +public class AccountGroup { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/src/main/java/de/financer/service/AccountGroupService.java b/src/main/java/de/financer/service/AccountGroupService.java new file mode 100644 index 0000000..1b41de9 --- /dev/null +++ b/src/main/java/de/financer/service/AccountGroupService.java @@ -0,0 +1,69 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.AccountGroupRepository; +import de.financer.model.AccountGroup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountGroupService { + private static final Logger LOGGER = LoggerFactory.getLogger(AccountGroupService.class); + + @Autowired + private AccountGroupRepository accountGroupRepository; + + /** + * @return all existing account groups + */ + public Iterable getAll() { + return this.accountGroupRepository.findAll(); + } + + /** + * This method returns the account group with the given name. + * + * @param name the name to get the account group for + * @return the account group or null if no account group with the given name can be found + */ + public AccountGroup getAccountGroupByName(String name) { + return this.accountGroupRepository.findByName(name); + } + + /** + * This method creates a new account group with the given name. + * + * @param name the name of the new account group + * @return {@link ResponseReason#DUPLICATE_ACCOUNT_GROUP_NAME} if an account group with the given name already exists, + * {@link ResponseReason#UNKNOWN_ERROR} if an unknown error occurs, + * {@link ResponseReason#OK} if the operation completed successfully. + * Never returns null. + */ + @Transactional(propagation = Propagation.SUPPORTS) + public ResponseReason createAccountGroup(String name) { + final AccountGroup accountGroup = new AccountGroup(); + + accountGroup.setName(name); + + try { + this.accountGroupRepository.save(accountGroup); + } + catch (DataIntegrityViolationException dive) { + LOGGER.error(String.format("Duplicate account group name! %s", name), dive); + + return ResponseReason.DUPLICATE_ACCOUNT_GROUP_NAME; + } + catch (Exception e) { + LOGGER.error(String.format("Could not save account group %s", name), e); + + return ResponseReason.UNKNOWN_ERROR; + } + + return ResponseReason.OK; + } +} diff --git a/src/main/java/de/financer/service/AccountService.java b/src/main/java/de/financer/service/AccountService.java index 67deea5..9e4559a 100644 --- a/src/main/java/de/financer/service/AccountService.java +++ b/src/main/java/de/financer/service/AccountService.java @@ -3,6 +3,7 @@ package de.financer.service; import de.financer.ResponseReason; import de.financer.dba.AccountRepository; import de.financer.model.Account; +import de.financer.model.AccountGroup; import de.financer.model.AccountStatus; import de.financer.model.AccountType; import org.apache.commons.lang3.StringUtils; @@ -23,6 +24,8 @@ public class AccountService { @Autowired private AccountRepository accountRepository; + @Autowired + private AccountGroupService accountGroupService; /** * This method returns the account identified by the given key. @@ -58,18 +61,32 @@ public class AccountService { * * @param key the key of the new account * @param type the type of the new account. Must be one of {@link AccountType}. + * @param accountGroupName the name of the account group to use, can be null * @return {@link ResponseReason#INVALID_ACCOUNT_TYPE} if the given type is not a valid {@link AccountType}, - * {@link ResponseReason#UNKNOWN_ERROR} if an unexpected error occurs and - * {@link ResponseReason#OK} if the operation completed successfully. Never returns null. + * {@link ResponseReason#UNKNOWN_ERROR} if an unexpected error occurs, + * {@link ResponseReason#OK} if the operation completed successfully, + * {@link ResponseReason#DUPLICATE_ACCOUNT_KEY} if an account with the given key + * already exists and {@link ResponseReason#ACCOUNT_GROUP_NOT_FOUND} if the optional parameter + * accountGroupName does not identify a valid account group. Never returns null. */ @Transactional(propagation = Propagation.SUPPORTS) - public ResponseReason createAccount(String key, String type) { + public ResponseReason createAccount(String key, String type, String accountGroupName) { if (!AccountType.isValidType(type)) { return ResponseReason.INVALID_ACCOUNT_TYPE; } final Account account = new Account(); + if (StringUtils.isNotEmpty(accountGroupName)) { + final AccountGroup accountGroup = this.accountGroupService.getAccountGroupByName(accountGroupName); + + if (accountGroup == null) { + return ResponseReason.ACCOUNT_GROUP_NOT_FOUND; // early return + } + + account.setAccountGroup(accountGroup); + } + account.setKey(key); account.setType(AccountType.valueOf(type)); // If we create an account it's implicitly open @@ -81,12 +98,12 @@ public class AccountService { this.accountRepository.save(account); } catch (DataIntegrityViolationException dive) { - LOGGER.error(String.format("Duplicate key! %s|%s", key, type), dive); + LOGGER.error(String.format("Duplicate key! %s|%s|%s", key, type, accountGroupName), dive); return ResponseReason.DUPLICATE_ACCOUNT_KEY; } catch (Exception e) { - LOGGER.error(String.format("Could not save account %s|%s", key, type), e); + LOGGER.error(String.format("Could not save account %s|%s|%s", key, type, accountGroupName), e); return ResponseReason.UNKNOWN_ERROR; } diff --git a/src/main/resources/database/common/V7_0_1__initAccountGroups.sql b/src/main/resources/database/common/V7_0_1__initAccountGroups.sql new file mode 100644 index 0000000..ac3fa07 --- /dev/null +++ b/src/main/resources/database/common/V7_0_1__initAccountGroups.sql @@ -0,0 +1,17 @@ +INSERT INTO account_group (name) +VALUES ('Miscellaneous'); + +INSERT INTO account_group (name) +VALUES ('Car'); + +INSERT INTO account_group (name) +VALUES ('Housing'); + +INSERT INTO account_group (name) +VALUES ('Child'); + +INSERT INTO account_group (name) +VALUES ('Insurance'); + +INSERT INTO account_group (name) +VALUES ('Entertainment'); \ No newline at end of file diff --git a/src/main/resources/database/hsqldb/V7_0_0__accountGroup.sql b/src/main/resources/database/hsqldb/V7_0_0__accountGroup.sql new file mode 100644 index 0000000..769184b --- /dev/null +++ b/src/main/resources/database/hsqldb/V7_0_0__accountGroup.sql @@ -0,0 +1,16 @@ +-- Account group table +CREATE TABLE account_group ( + id BIGINT NOT NULL PRIMARY KEY IDENTITY, + name VARCHAR(1000) NOT NULL, + + CONSTRAINT un_account_group_name_key UNIQUE (name) +); + +-- Add a column for the Account group +ALTER TABLE account + ADD COLUMN account_group_id BIGINT; + +-- Add a foreign key column to the Account table referencing an Account group +ALTER TABLE account + ADD CONSTRAINT fk_account_account_group + FOREIGN KEY (account_group_id) REFERENCES account_group (id); \ No newline at end of file diff --git a/src/main/resources/database/postgres/V7_0_0__accountGroup.sql b/src/main/resources/database/postgres/V7_0_0__accountGroup.sql new file mode 100644 index 0000000..5d34e0f --- /dev/null +++ b/src/main/resources/database/postgres/V7_0_0__accountGroup.sql @@ -0,0 +1,16 @@ +-- Account group table +CREATE TABLE account_group ( + id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name VARCHAR(1000) NOT NULL, + + CONSTRAINT un_account_group_name_key UNIQUE (name) +); + +-- Add a column for the Account group +ALTER TABLE account + ADD COLUMN account_group_id BIGINT; + +-- Add a foreign key column to the Account table referencing an Account group +ALTER TABLE account + ADD CONSTRAINT fk_account_account_group + FOREIGN KEY (account_group_id) REFERENCES account_group (id); \ No newline at end of file diff --git a/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java b/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java new file mode 100644 index 0000000..9f097db --- /dev/null +++ b/src/test/java/de/financer/service/AccountGroupService_createAccountGroupTest.java @@ -0,0 +1,62 @@ +package de.financer.service; + +import de.financer.ResponseReason; +import de.financer.dba.AccountGroupRepository; +import de.financer.model.AccountGroup; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.dao.DataIntegrityViolationException; + +@RunWith(MockitoJUnitRunner.class) +public class AccountGroupService_createAccountGroupTest { + + @InjectMocks + private AccountGroupService classUnderTest; + + @Mock + private AccountGroupRepository accountGroupRepository; + + @Test + public void test_createAccount_UNKNOWN_ERROR() { + // Arrange + Mockito.doThrow(new NullPointerException()).when(this.accountGroupRepository).save(Mockito.any(AccountGroup.class)); + + // Act + ResponseReason response = this.classUnderTest.createAccountGroup("Test"); + + // Assert + Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response); + } + + @Test + public void test_createAccount_OK() { + // Arrange + // Nothing to do + + // Act + ResponseReason response = this.classUnderTest.createAccountGroup("Test"); + + // Assert + Assert.assertEquals(ResponseReason.OK, response); + Mockito.verify(this.accountGroupRepository, Mockito.times(1)) + .save(ArgumentMatchers.argThat((ag) -> "Test".equals(ag.getName()))); + } + + @Test + public void test_createAccount_DUPLICATE_ACCOUNT_GROUP_NAME() { + // Arrange + Mockito.doThrow(new DataIntegrityViolationException("DIVE")).when(this.accountGroupRepository).save(Mockito.any(AccountGroup.class)); + + // Act + ResponseReason response = this.classUnderTest.createAccountGroup("Test"); + + // Assert + Assert.assertEquals(ResponseReason.DUPLICATE_ACCOUNT_GROUP_NAME, response); + } +} diff --git a/src/test/java/de/financer/service/AccountService_createAccountTest.java b/src/test/java/de/financer/service/AccountService_createAccountTest.java index ed4e007..400b77a 100644 --- a/src/test/java/de/financer/service/AccountService_createAccountTest.java +++ b/src/test/java/de/financer/service/AccountService_createAccountTest.java @@ -3,6 +3,7 @@ package de.financer.service; import de.financer.ResponseReason; import de.financer.dba.AccountRepository; import de.financer.model.Account; +import de.financer.model.AccountGroup; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -11,12 +12,16 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.dao.DataIntegrityViolationException; @RunWith(MockitoJUnitRunner.class) public class AccountService_createAccountTest { @InjectMocks private AccountService classUnderTest; + @Mock + private AccountGroupService accountGroupService; + @Mock private AccountRepository accountRepository; @@ -26,7 +31,7 @@ public class AccountService_createAccountTest { // Nothing to do // Act - ResponseReason response = this.classUnderTest.createAccount(null, null); + ResponseReason response = this.classUnderTest.createAccount(null, null, null); // Assert Assert.assertEquals(ResponseReason.INVALID_ACCOUNT_TYPE, response); @@ -38,7 +43,7 @@ public class AccountService_createAccountTest { Mockito.doThrow(new NullPointerException()).when(this.accountRepository).save(Mockito.any(Account.class)); // Act - ResponseReason response = this.classUnderTest.createAccount("Test", "BANK"); + ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null); // Assert Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response); @@ -50,11 +55,36 @@ public class AccountService_createAccountTest { // Nothing to do // Act - ResponseReason response = this.classUnderTest.createAccount("Test", "BANK"); + ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null); // Assert Assert.assertEquals(ResponseReason.OK, response); Mockito.verify(this.accountRepository, Mockito.times(1)) .save(ArgumentMatchers.argThat((acc) -> "Test".equals(acc.getKey()))); } + + @Test + public void test_createAccount_ACCOUNT_GROUP_NOT_FOUND() { + // Arrange + Mockito.when(this.accountGroupService.getAccountGroupByName(Mockito.anyString())) + .thenReturn(null); + + // Act + ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", "Group1"); + + // Assert + Assert.assertEquals(ResponseReason.ACCOUNT_GROUP_NOT_FOUND, response); + } + + @Test + public void test_createAccount_DUPLICATE_ACCOUNT_KEY() { + // Arrange + Mockito.doThrow(new DataIntegrityViolationException("DIVE")).when(this.accountRepository).save(Mockito.any(Account.class)); + + // Act + ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null); + + // Assert + Assert.assertEquals(ResponseReason.DUPLICATE_ACCOUNT_KEY, response); + } }