Introduce new feature 'Account groups'

Account groups are a simple way to group accounts. Currently the design
of the group is very sparse and supports only a name. However, as this
feature is a stepping stone for the much bigger 'Reports' feature this
will most likely be subject to change.
With this change new accounts are also required to get assigned to a group.
This commit is contained in:
2019-06-16 01:34:55 +02:00
parent 5e12dbeb2b
commit f5fac22347
13 changed files with 340 additions and 13 deletions

View File

@@ -29,7 +29,9 @@ public enum ResponseReason {
INVALID_TRANSACTION_ID(HttpStatus.INTERNAL_SERVER_ERROR), INVALID_TRANSACTION_ID(HttpStatus.INTERNAL_SERVER_ERROR),
TRANSACTION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR), TRANSACTION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR),
ACCOUNT_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; private HttpStatus httpStatus;

View File

@@ -24,7 +24,7 @@ public class AccountController {
final String decoded = ControllerUtil.urlDecode(key); final String decoded = ControllerUtil.urlDecode(key);
if (LOGGER.isDebugEnabled()) { 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); return this.accountService.getAccountByKey(decoded);
@@ -36,14 +36,15 @@ public class AccountController {
} }
@RequestMapping("createAccount") @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 decoded = ControllerUtil.urlDecode(key);
final String decodedGroup = ControllerUtil.urlDecode(accountGroupName);
if (LOGGER.isDebugEnabled()) { 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()) { if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/accounts/createAccount returns with %s", responseReason.name())); LOGGER.debug(String.format("/accounts/createAccount returns with %s", responseReason.name()));

View File

@@ -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<AccountGroup> 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();
}
}

View File

@@ -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, Long> {
AccountGroup findByName(String name);
}

View File

@@ -14,6 +14,8 @@ public class Account {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private AccountStatus status; private AccountStatus status;
private Long currentBalance; private Long currentBalance;
@ManyToOne
private AccountGroup accountGroup;
public Long getId() { public Long getId() {
return id; return id;
@@ -50,4 +52,12 @@ public class Account {
public void setCurrentBalance(Long currentBalance) { public void setCurrentBalance(Long currentBalance) {
this.currentBalance = currentBalance; this.currentBalance = currentBalance;
} }
public AccountGroup getAccountGroup() {
return accountGroup;
}
public void setAccountGroup(AccountGroup accountGroup) {
this.accountGroup = accountGroup;
}
} }

View File

@@ -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;
}
}

View File

@@ -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<AccountGroup> 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 <code>null</code> 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 <code>null</code>.
*/
@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;
}
}

View File

@@ -3,6 +3,7 @@ package de.financer.service;
import de.financer.ResponseReason; import de.financer.ResponseReason;
import de.financer.dba.AccountRepository; import de.financer.dba.AccountRepository;
import de.financer.model.Account; import de.financer.model.Account;
import de.financer.model.AccountGroup;
import de.financer.model.AccountStatus; import de.financer.model.AccountStatus;
import de.financer.model.AccountType; import de.financer.model.AccountType;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -23,6 +24,8 @@ public class AccountService {
@Autowired @Autowired
private AccountRepository accountRepository; private AccountRepository accountRepository;
@Autowired
private AccountGroupService accountGroupService;
/** /**
* This method returns the account identified by the given key. * 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 key the key of the new account
* @param type the type of the new account. Must be one of {@link AccountType}. * @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 <code>null</code>
* @return {@link ResponseReason#INVALID_ACCOUNT_TYPE} if the given type is not a valid {@link AccountType}, * @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#UNKNOWN_ERROR} if an unexpected error occurs,
* {@link ResponseReason#OK} if the operation completed successfully. Never returns <code>null</code>. * {@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
* <code>accountGroupName</code> does not identify a valid account group. Never returns <code>null</code>.
*/ */
@Transactional(propagation = Propagation.SUPPORTS) @Transactional(propagation = Propagation.SUPPORTS)
public ResponseReason createAccount(String key, String type) { public ResponseReason createAccount(String key, String type, String accountGroupName) {
if (!AccountType.isValidType(type)) { if (!AccountType.isValidType(type)) {
return ResponseReason.INVALID_ACCOUNT_TYPE; return ResponseReason.INVALID_ACCOUNT_TYPE;
} }
final Account account = new Account(); 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.setKey(key);
account.setType(AccountType.valueOf(type)); account.setType(AccountType.valueOf(type));
// If we create an account it's implicitly open // If we create an account it's implicitly open
@@ -81,12 +98,12 @@ public class AccountService {
this.accountRepository.save(account); this.accountRepository.save(account);
} }
catch (DataIntegrityViolationException dive) { 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; return ResponseReason.DUPLICATE_ACCOUNT_KEY;
} }
catch (Exception e) { 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; return ResponseReason.UNKNOWN_ERROR;
} }

View File

@@ -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');

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -3,6 +3,7 @@ package de.financer.service;
import de.financer.ResponseReason; import de.financer.ResponseReason;
import de.financer.dba.AccountRepository; import de.financer.dba.AccountRepository;
import de.financer.model.Account; import de.financer.model.Account;
import de.financer.model.AccountGroup;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
@@ -11,12 +12,16 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.dao.DataIntegrityViolationException;
@RunWith(MockitoJUnitRunner.class) @RunWith(MockitoJUnitRunner.class)
public class AccountService_createAccountTest { public class AccountService_createAccountTest {
@InjectMocks @InjectMocks
private AccountService classUnderTest; private AccountService classUnderTest;
@Mock
private AccountGroupService accountGroupService;
@Mock @Mock
private AccountRepository accountRepository; private AccountRepository accountRepository;
@@ -26,7 +31,7 @@ public class AccountService_createAccountTest {
// Nothing to do // Nothing to do
// Act // Act
ResponseReason response = this.classUnderTest.createAccount(null, null); ResponseReason response = this.classUnderTest.createAccount(null, null, null);
// Assert // Assert
Assert.assertEquals(ResponseReason.INVALID_ACCOUNT_TYPE, response); 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)); Mockito.doThrow(new NullPointerException()).when(this.accountRepository).save(Mockito.any(Account.class));
// Act // Act
ResponseReason response = this.classUnderTest.createAccount("Test", "BANK"); ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null);
// Assert // Assert
Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response); Assert.assertEquals(ResponseReason.UNKNOWN_ERROR, response);
@@ -50,11 +55,36 @@ public class AccountService_createAccountTest {
// Nothing to do // Nothing to do
// Act // Act
ResponseReason response = this.classUnderTest.createAccount("Test", "BANK"); ResponseReason response = this.classUnderTest.createAccount("Test", "BANK", null);
// Assert // Assert
Assert.assertEquals(ResponseReason.OK, response); Assert.assertEquals(ResponseReason.OK, response);
Mockito.verify(this.accountRepository, Mockito.times(1)) Mockito.verify(this.accountRepository, Mockito.times(1))
.save(ArgumentMatchers.argThat((acc) -> "Test".equals(acc.getKey()))); .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);
}
} }